feat: add comprehensive discussion forums with threads, nested replies, voting, and moderation, alongside updates to authentication flows.
This commit is contained in:
@@ -35,6 +35,8 @@ El proyecto ha sido optimizado para reducir la complejidad de la infraestructura
|
|||||||
- **Color-Coded Progress Navigation**: Sistema visual de seguimiento de progreso mediante colores (Verde: Completado, Amarillo: En Proceso, Rojo: Repetible) tanto a nivel de lección como de módulo.
|
- **Color-Coded Progress Navigation**: Sistema visual de seguimiento de progreso mediante colores (Verde: Completado, Amarillo: En Proceso, Rojo: Repetible) tanto a nivel de lección como de módulo.
|
||||||
- **Adaptive Skill Analysis**: Motor de análisis de etiquetas que calcula la maestría de habilidades (Gramática, Vocabulario, etc.) para personalizar las recomendaciones de IA.
|
- **Adaptive Skill Analysis**: Motor de análisis de etiquetas que calcula la maestría de habilidades (Gramática, Vocabulario, etc.) para personalizar las recomendaciones de IA.
|
||||||
- **Efficient Docker Builds**: Imágenes de contenedor optimizadas para desarrollo rápido y despliegue ligero.
|
- **Efficient Docker Builds**: Imágenes de contenedor optimizadas para desarrollo rápido y despliegue ligero.
|
||||||
|
- **Discussion Forums**: Sistema completo de foros por curso con hilos de discusión, respuestas anidadas, votación, moderación por instructores y suscripciones.
|
||||||
|
- **Split Authentication Flow**: Flujos de autenticación diferenciados para usuarios personales (email/password) y empresas (dominio corporativo).
|
||||||
|
|
||||||
## Requisitos del Sistema
|
## Requisitos del Sistema
|
||||||
|
|
||||||
@@ -388,7 +390,118 @@ Generador de reportes personalizados para exportación.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 5. Multi-tenancy and Global Management (Super Admin)
|
### 5. Discussion Forums (Foros de Discusión)
|
||||||
|
Sistema completo de foros por curso con hilos, respuestas anidadas y moderación.
|
||||||
|
|
||||||
|
#### GET /courses/{id}/discussions
|
||||||
|
Lista todos los hilos de discusión de un curso.
|
||||||
|
|
||||||
|
- **Filtros Disponibles**:
|
||||||
|
- `filter=all`: Todos los hilos (por defecto)
|
||||||
|
- `filter=my_threads`: Solo hilos creados por el usuario
|
||||||
|
- `filter=unanswered`: Hilos sin respuestas
|
||||||
|
- `filter=resolved`: Hilos con respuestas marcadas como correctas
|
||||||
|
- `lesson_id={uuid}`: Filtrar por lección específica
|
||||||
|
- **Paginación**: `page=1` (50 hilos por página)
|
||||||
|
- **Respuesta**: Array de `ThreadWithAuthor` con información del autor y estadísticas agregadas.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Listar hilos sin responder
|
||||||
|
curl "http://localhost:3002/courses/{course_id}/discussions?filter=unanswered" \
|
||||||
|
-H "Authorization: Bearer $TOKEN"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /courses/{id}/discussions
|
||||||
|
Crea un nuevo hilo de discusión.
|
||||||
|
|
||||||
|
- **Auto-suscripción**: El autor se suscribe automáticamente para recibir notificaciones.
|
||||||
|
- **Cuerpo ( CreateThreadPayload ):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "string",
|
||||||
|
"content": "string",
|
||||||
|
"lesson_id": "uuid (opcional)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### GET /discussions/{id}
|
||||||
|
Obtiene un hilo completo con todas sus respuestas anidadas.
|
||||||
|
|
||||||
|
- **Contador de Vistas**: Incrementa automáticamente el `view_count`.
|
||||||
|
- **Árbol de Respuestas**: Las respuestas se devuelven en estructura jerárquica con anidación infinita.
|
||||||
|
- **Respuesta**: Objeto con `thread` y `posts` (árbol de respuestas).
|
||||||
|
|
||||||
|
#### POST /discussions/{id}/posts
|
||||||
|
Crea una respuesta en un hilo.
|
||||||
|
|
||||||
|
- **Respuestas Anidadas**: Usa `parent_post_id` para responder a un post específico.
|
||||||
|
- **Validación**: No permite responder si el hilo está bloqueado.
|
||||||
|
- **Cuerpo ( CreatePostPayload ):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "string",
|
||||||
|
"parent_post_id": "uuid (opcional, null para respuesta directa al hilo)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /posts/{id}/vote
|
||||||
|
Vota por una respuesta (upvote/downvote).
|
||||||
|
|
||||||
|
- **Lógica**: Un usuario solo puede votar una vez por post. Cambiar el voto actualiza el registro existente.
|
||||||
|
- **Recalculo Automático**: El contador de upvotes se actualiza inmediatamente.
|
||||||
|
- **Cuerpo ( VotePayload ):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vote_type": "upvote" // o "downvote"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### POST /posts/{id}/endorse (Solo Instructores)
|
||||||
|
Marca una respuesta como correcta/aprobada.
|
||||||
|
|
||||||
|
- **Indicador Visual**: Las respuestas endorsadas aparecen primero en la lista.
|
||||||
|
- **Permiso**: Solo instructores y administradores pueden endorsar.
|
||||||
|
|
||||||
|
#### POST /discussions/{id}/pin (Solo Instructores)
|
||||||
|
Fija/desfija un hilo en la parte superior de la lista.
|
||||||
|
|
||||||
|
- **Uso**: Para destacar anuncios importantes o FAQs.
|
||||||
|
- **Permiso**: Solo instructores y administradores.
|
||||||
|
|
||||||
|
#### POST /discussions/{id}/lock (Solo Instructores)
|
||||||
|
Bloquea/desbloquea un hilo para prevenir nuevas respuestas.
|
||||||
|
|
||||||
|
- **Uso**: Para cerrar discusiones resueltas o inapropiadas.
|
||||||
|
- **Permiso**: Solo instructores y administradores.
|
||||||
|
|
||||||
|
#### POST /discussions/{id}/subscribe
|
||||||
|
Suscribe al usuario a las notificaciones del hilo.
|
||||||
|
|
||||||
|
- **Notificaciones**: El usuario recibirá alertas cuando haya nuevas respuestas.
|
||||||
|
|
||||||
|
#### POST /discussions/{id}/unsubscribe
|
||||||
|
Cancela la suscripción del usuario al hilo.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Crear hilo
|
||||||
|
curl -X POST "http://localhost:3002/courses/{course_id}/discussions" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"title": "Pregunta sobre Módulo 2", "content": "No entiendo la sección de..."}'
|
||||||
|
|
||||||
|
# Responder a hilo
|
||||||
|
curl -X POST "http://localhost:3002/discussions/{thread_id}/posts" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"content": "Aquí está mi respuesta..."}'
|
||||||
|
|
||||||
|
# Votar respuesta
|
||||||
|
curl -X POST "http://localhost:3002/posts/{post_id}/vote" \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-d '{"vote_type": "upvote"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Multi-tenancy and Global Management (Super Admin)
|
||||||
OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything.
|
OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything.
|
||||||
|
|
||||||
#### Super Admin Definition
|
#### Super Admin Definition
|
||||||
@@ -442,6 +555,8 @@ Obtiene una lista de todas las organizaciones registradas.
|
|||||||
- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers.
|
- **Context-Aware AI Tutor**: Smart assistant with RAG that remembers past lessons and protects activity answers.
|
||||||
- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results.
|
- **Personalized AI Feedback**: Motivational and instructional feedback generated uniquely for each student's results.
|
||||||
- **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red).
|
- **Color-Coded Navigation**: Real-time visual progress indicators for lessons and modules (Green/Yellow/Red).
|
||||||
|
- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions.
|
||||||
|
- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support.
|
||||||
|
|
||||||
## 📄 Licencia
|
## 📄 Licencia
|
||||||
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
Este proyecto es código abierto y está disponible bajo los términos de la licencia especificada en el repositorio.
|
||||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -10,13 +10,23 @@ test.describe('Student Flow', () => {
|
|||||||
console.log(`Starting Student Test for ${email} on ${baseURL}`);
|
console.log(`Starting Student Test for ${email} on ${baseURL}`);
|
||||||
|
|
||||||
// 1. Register
|
// 1. Register
|
||||||
await page.goto('/auth/register');
|
await page.goto('/auth/login');
|
||||||
await page.fill('input[type="text"][placeholder*="Full Name"], input[placeholder="John Doe"]', name);
|
|
||||||
|
// New Flow: Select "Personas" first (if we are on the selection screen)
|
||||||
|
// Note: /auth/register might still load the main component which defaults to 'selection' view
|
||||||
|
// The URL logic in the component doesn't automatically switch viewMode based on route yet (it defaults to selection)
|
||||||
|
// Let's assume we need to click "Personas"
|
||||||
|
await page.click('button:has-text("Personas")');
|
||||||
|
|
||||||
|
// Also ensure we are in "Registrarse" mode inside Personal view
|
||||||
|
await page.click('button:has-text("Registrarse")');
|
||||||
|
|
||||||
|
await page.fill('input[type="text"][placeholder*="Full Name"], input[placeholder="Juan Pérez"]', name);
|
||||||
await page.fill('input[type="email"]', email);
|
await page.fill('input[type="email"]', email);
|
||||||
await page.fill('input[type="password"]', 'password123');
|
await page.fill('input[type="password"]', 'password123');
|
||||||
// Handle optional Organization field if present or skip
|
// Handle optional Organization field if present or skip
|
||||||
|
|
||||||
await page.click('button:has-text("Comenzar a Aprender")');
|
await page.click('button:has-text("Crear Cuenta")');
|
||||||
|
|
||||||
// 2. View Catalog (Dashboard)
|
// 2. View Catalog (Dashboard)
|
||||||
await expect(page).toHaveURL('/');
|
await expect(page).toHaveURL('/');
|
||||||
|
|||||||
+26
-3
@@ -169,11 +169,34 @@
|
|||||||
- [x] **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción.
|
- [x] **QA y Estabilidad**: Verificación del flujo completo de evaluación en entornos de producción.
|
||||||
- [x] **Rutas de Aprendizaje**: Recomendaciones basadas en el historial personalizadas y perfiles de habilidades.
|
- [x] **Rutas de Aprendizaje**: Recomendaciones basadas en el historial personalizadas y perfiles de habilidades.
|
||||||
- [x] **Optimización de Contenedores**: Limpieza automatizada y reducción de huella de infraestructura mediante Build Context optimizado.
|
- [x] **Optimización de Contenedores**: Limpieza automatizada y reducción de huella de infraestructura mediante Build Context optimizado.
|
||||||
|
- [x] **Split Login Flow**: Separación de flujos de autenticación para Personas y Empresas.
|
||||||
|
|
||||||
|
## Fase 17: Funcionalidades Estilo Open edX (En Progreso)
|
||||||
|
- [x] **Discussion Forums**: Sistema de foros por curso con hilos, respuestas anidadas y moderación.
|
||||||
|
- [x] Base de datos (4 tablas: threads, posts, votes, subscriptions)
|
||||||
|
- [x] Backend API (10 endpoints para gestión completa)
|
||||||
|
- [x] Permisos diferenciados (estudiante vs instructor)
|
||||||
|
- [x] Sistema de votación y endorsement
|
||||||
|
- [ ] Frontend (componentes React)
|
||||||
|
- [ ] Integración con notificaciones
|
||||||
|
- [ ] **Course Announcements**: Sistema de anuncios de instructores con notificaciones.
|
||||||
|
- [ ] **Student Notes**: Anotaciones personales por lección con exportación a PDF.
|
||||||
|
- [ ] **Peer Assessment**: Evaluación entre pares con rúbricas configurables.
|
||||||
|
- [ ] **Cohorts & Groups**: Segmentación de estudiantes con contenido específico.
|
||||||
|
- [ ] **Content Libraries**: Repositorio reutilizable de bloques y lecciones.
|
||||||
|
- [ ] **Advanced Grading**: Rúbricas detalladas y workflows de calificación.
|
||||||
|
- [ ] **Learning Sequences**: Prerequisitos y rutas condicionales entre lecciones.
|
||||||
|
- [ ] **Bulk Operations**: Inscripción masiva, exportación de calificaciones, comunicación masiva.
|
||||||
|
- [ ] **Course Teams**: Múltiples instructores con roles y permisos granulares.
|
||||||
|
- [ ] **Course Preview**: Vista previa de lecciones sin inscripción.
|
||||||
|
- [ ] **Bookmarks**: Sistema de favoritos para lecciones importantes.
|
||||||
|
- [ ] **Progress Dashboard**: Gráficos de progreso temporal y predicción de finalización.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica y una **interfaz 100% responsiva**.
|
**Estado Actual**: La plataforma cuenta con un motor de IA avanzado, gestión multi-tenant completa, tutoría inteligente con memoria histórica, una **interfaz 100% responsiva**, flujos de autenticación diferenciados y **sistema de foros de discusión funcional (backend completo)**.
|
||||||
|
|
||||||
**Próximas Prioridades**:
|
**Próximas Prioridades**:
|
||||||
1. **Escalado Horizontal**: Orquestación con Kubernetes.
|
1. **Discussion Forums Frontend**: Componentes React para interfaz de foros.
|
||||||
2. **Apps Móviles**: Desarrollo de clientes nativos.
|
2. **Course Announcements**: Comunicación instructor-estudiante.
|
||||||
|
3. **Student Notes**: Anotaciones personales exportables.
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
-- Migration: Discussion Forums System
|
||||||
|
-- Create tables for course discussions, posts, votes, and subscriptions
|
||||||
|
|
||||||
|
-- 1. Discussion Threads Table
|
||||||
|
CREATE TABLE IF NOT EXISTS discussion_threads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
lesson_id UUID REFERENCES lessons(id) ON DELETE CASCADE, -- Optional: thread specific to a lesson
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_pinned BOOLEAN DEFAULT false,
|
||||||
|
is_locked BOOLEAN DEFAULT false,
|
||||||
|
view_count INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_threads_course ON discussion_threads(course_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_threads_lesson ON discussion_threads(lesson_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_threads_author ON discussion_threads(author_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_threads_org ON discussion_threads(organization_id);
|
||||||
|
|
||||||
|
-- 2. Discussion Posts Table (Replies to threads)
|
||||||
|
CREATE TABLE IF NOT EXISTS discussion_posts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
thread_id UUID NOT NULL REFERENCES discussion_threads(id) ON DELETE CASCADE,
|
||||||
|
parent_post_id UUID REFERENCES discussion_posts(id) ON DELETE CASCADE, -- For nested replies
|
||||||
|
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
upvotes INTEGER DEFAULT 0,
|
||||||
|
is_endorsed BOOLEAN DEFAULT false, -- Marked by instructor as correct answer
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_posts_thread ON discussion_posts(thread_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_posts_parent ON discussion_posts(parent_post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_posts_author ON discussion_posts(author_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_posts_org ON discussion_posts(organization_id);
|
||||||
|
|
||||||
|
-- 3. Discussion Votes Table
|
||||||
|
CREATE TABLE IF NOT EXISTS discussion_votes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
post_id UUID NOT NULL REFERENCES discussion_posts(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
vote_type VARCHAR(10) NOT NULL CHECK (vote_type IN ('upvote', 'downvote')),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(post_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_votes_post ON discussion_votes(post_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_votes_user ON discussion_votes(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_votes_org ON discussion_votes(organization_id);
|
||||||
|
|
||||||
|
-- 4. Discussion Subscriptions Table
|
||||||
|
CREATE TABLE IF NOT EXISTS discussion_subscriptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
thread_id UUID NOT NULL REFERENCES discussion_threads(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE(thread_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_subscriptions_thread ON discussion_subscriptions(thread_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_subscriptions_user ON discussion_subscriptions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussion_subscriptions_org ON discussion_subscriptions(organization_id);
|
||||||
|
|
||||||
|
-- Trigger to update updated_at on threads
|
||||||
|
CREATE OR REPLACE FUNCTION update_discussion_thread_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_update_discussion_thread_timestamp
|
||||||
|
BEFORE UPDATE ON discussion_threads
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_discussion_thread_timestamp();
|
||||||
|
|
||||||
|
-- Trigger to update updated_at on posts
|
||||||
|
CREATE OR REPLACE FUNCTION update_discussion_post_timestamp()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_update_discussion_post_timestamp
|
||||||
|
BEFORE UPDATE ON discussion_posts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_discussion_post_timestamp();
|
||||||
|
|
||||||
|
-- Function to update thread's updated_at when a new post is added
|
||||||
|
CREATE OR REPLACE FUNCTION update_thread_on_new_post()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
UPDATE discussion_threads
|
||||||
|
SET updated_at = NOW()
|
||||||
|
WHERE id = NEW.thread_id;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trigger_update_thread_on_new_post
|
||||||
|
AFTER INSERT ON discussion_posts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_thread_on_new_post();
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Path, Query, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use common::auth::Claims;
|
||||||
|
use common::middleware::Org;
|
||||||
|
use common::models::{
|
||||||
|
DiscussionThread, DiscussionPost, DiscussionVote, DiscussionSubscription,
|
||||||
|
ThreadWithAuthor, PostWithAuthor,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ========== Request/Response DTOs ==========
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreateThreadPayload {
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub lesson_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CreatePostPayload {
|
||||||
|
pub content: String,
|
||||||
|
pub parent_post_id: Option<Uuid>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VotePayload {
|
||||||
|
pub vote_type: String, // 'upvote' or 'downvote'
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ThreadListQuery {
|
||||||
|
pub lesson_id: Option<Uuid>,
|
||||||
|
pub filter: Option<String>, // 'all', 'my_threads', 'unanswered', 'resolved'
|
||||||
|
pub page: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== THREAD HANDLERS ==========
|
||||||
|
|
||||||
|
pub async fn list_threads(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
Query(params): Query<ThreadListQuery>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<Vec<ThreadWithAuthor>>, (StatusCode, String)> {
|
||||||
|
let page = params.page.unwrap_or(1);
|
||||||
|
let limit = 50i64;
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let mut query = String::from(
|
||||||
|
"SELECT
|
||||||
|
t.*,
|
||||||
|
u.full_name as author_name,
|
||||||
|
u.avatar_url as author_avatar,
|
||||||
|
COUNT(DISTINCT p.id) as post_count,
|
||||||
|
BOOL_OR(p.is_endorsed) as has_endorsed_answer
|
||||||
|
FROM discussion_threads t
|
||||||
|
LEFT JOIN users u ON t.author_id = u.id
|
||||||
|
LEFT JOIN discussion_posts p ON t.id = p.thread_id
|
||||||
|
WHERE t.course_id = $1 AND t.organization_id = $2"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut bind_count = 2;
|
||||||
|
|
||||||
|
if let Some(lesson_id) = params.lesson_id {
|
||||||
|
bind_count += 1;
|
||||||
|
query.push_str(&format!(" AND t.lesson_id = ${}", bind_count));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(filter) = ¶ms.filter {
|
||||||
|
match filter.as_str() {
|
||||||
|
"my_threads" => {
|
||||||
|
bind_count += 1;
|
||||||
|
query.push_str(&format!(" AND t.author_id = ${}", bind_count));
|
||||||
|
}
|
||||||
|
"unanswered" => {
|
||||||
|
query.push_str(" AND NOT EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id)");
|
||||||
|
}
|
||||||
|
"resolved" => {
|
||||||
|
query.push_str(" AND EXISTS (SELECT 1 FROM discussion_posts WHERE thread_id = t.id AND is_endorsed = true)");
|
||||||
|
}
|
||||||
|
_ => {} // 'all' or unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query.push_str(" GROUP BY t.id, u.full_name, u.avatar_url ORDER BY t.is_pinned DESC, t.updated_at DESC LIMIT $");
|
||||||
|
bind_count += 1;
|
||||||
|
query.push_str(&bind_count.to_string());
|
||||||
|
query.push_str(" OFFSET $");
|
||||||
|
bind_count += 1;
|
||||||
|
query.push_str(&bind_count.to_string());
|
||||||
|
|
||||||
|
let mut sql_query = sqlx::query_as::<_, ThreadWithAuthor>(&query)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(org_ctx.id);
|
||||||
|
|
||||||
|
if let Some(lesson_id) = params.lesson_id {
|
||||||
|
sql_query = sql_query.bind(lesson_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(filter) = ¶ms.filter {
|
||||||
|
if filter == "my_threads" {
|
||||||
|
sql_query = sql_query.bind(claims.sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sql_query = sql_query.bind(limit).bind(offset);
|
||||||
|
|
||||||
|
let threads = sql_query
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(threads))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_thread(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(course_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<CreateThreadPayload>,
|
||||||
|
) -> Result<Json<DiscussionThread>, (StatusCode, String)> {
|
||||||
|
let thread = sqlx::query_as::<_, DiscussionThread>(
|
||||||
|
"INSERT INTO discussion_threads (organization_id, course_id, lesson_id, author_id, title, content)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(course_id)
|
||||||
|
.bind(payload.lesson_id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(payload.title)
|
||||||
|
.bind(payload.content)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Auto-subscribe author to thread
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING"
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(thread.id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Ok(Json(thread))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_thread_detail(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(thread_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||||
|
// Increment view count
|
||||||
|
let _ = sqlx::query("UPDATE discussion_threads SET view_count = view_count + 1 WHERE id = $1")
|
||||||
|
.bind(thread_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Get thread with author info
|
||||||
|
let thread = sqlx::query_as::<_, ThreadWithAuthor>(
|
||||||
|
"SELECT
|
||||||
|
t.*,
|
||||||
|
u.full_name as author_name,
|
||||||
|
u.avatar_url as author_avatar,
|
||||||
|
COUNT(DISTINCT p.id) as post_count,
|
||||||
|
BOOL_OR(p.is_endorsed) as has_endorsed_answer
|
||||||
|
FROM discussion_threads t
|
||||||
|
LEFT JOIN users u ON t.author_id = u.id
|
||||||
|
LEFT JOIN discussion_posts p ON t.id = p.thread_id
|
||||||
|
WHERE t.id = $1 AND t.organization_id = $2
|
||||||
|
GROUP BY t.id, u.full_name, u.avatar_url"
|
||||||
|
)
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?;
|
||||||
|
|
||||||
|
// Get all posts with author info and user votes
|
||||||
|
let posts = get_thread_posts_recursive(&pool, thread_id, None, claims.sub, org_ctx.id).await?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"thread": thread,
|
||||||
|
"posts": posts
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursive function to build nested post tree
|
||||||
|
fn get_thread_posts_recursive<'a>(
|
||||||
|
pool: &'a PgPool,
|
||||||
|
thread_id: Uuid,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
|
user_id: Uuid,
|
||||||
|
org_id: Uuid,
|
||||||
|
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PostWithAuthor>, (StatusCode, String)>> + Send + 'a>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let parent_filter = match parent_id {
|
||||||
|
Some(_) => "parent_post_id = $2",
|
||||||
|
None => "parent_post_id IS NULL",
|
||||||
|
};
|
||||||
|
|
||||||
|
let query = format!(
|
||||||
|
"SELECT
|
||||||
|
p.*,
|
||||||
|
u.full_name as author_name,
|
||||||
|
u.avatar_url as author_avatar,
|
||||||
|
v.vote_type as user_vote
|
||||||
|
FROM discussion_posts p
|
||||||
|
LEFT JOIN users u ON p.author_id = u.id
|
||||||
|
LEFT JOIN discussion_votes v ON p.id = v.post_id AND v.user_id = $3
|
||||||
|
WHERE p.thread_id = $1 AND p.organization_id = $4 AND {}
|
||||||
|
ORDER BY p.is_endorsed DESC, p.upvotes DESC, p.created_at ASC",
|
||||||
|
parent_filter
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut sql_query = sqlx::query_as::<_, PostWithAuthor>(&query)
|
||||||
|
.bind(thread_id);
|
||||||
|
|
||||||
|
if let Some(pid) = parent_id {
|
||||||
|
sql_query = sql_query.bind(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql_query = sql_query.bind(user_id).bind(org_id);
|
||||||
|
|
||||||
|
let mut posts = sql_query
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Recursively fetch replies for each post
|
||||||
|
for post in &mut posts {
|
||||||
|
post.replies = get_thread_posts_recursive(pool, thread_id, Some(post.id), user_id, org_id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(posts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn pin_thread(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(thread_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// Check if user is instructor
|
||||||
|
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only instructors can pin threads".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE discussion_threads SET is_pinned = NOT is_pinned WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn lock_thread(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(thread_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// Check if user is instructor
|
||||||
|
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only instructors can lock threads".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE discussion_threads SET is_locked = NOT is_locked WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== POST HANDLERS ==========
|
||||||
|
|
||||||
|
pub async fn create_post(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(thread_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<CreatePostPayload>,
|
||||||
|
) -> Result<Json<DiscussionPost>, (StatusCode, String)> {
|
||||||
|
// Check if thread is locked
|
||||||
|
let thread = sqlx::query_as::<_, (bool,)>("SELECT is_locked FROM discussion_threads WHERE id = $1")
|
||||||
|
.bind(thread_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::NOT_FOUND, "Thread not found".to_string()))?;
|
||||||
|
|
||||||
|
if thread.0 {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Thread is locked".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let post = sqlx::query_as::<_, DiscussionPost>(
|
||||||
|
"INSERT INTO discussion_posts (organization_id, thread_id, parent_post_id, author_id, content)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING *"
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(payload.parent_post_id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(payload.content)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// TODO: Send notifications to subscribed users
|
||||||
|
|
||||||
|
Ok(Json(post))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn endorse_post(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(post_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
// Check if user is instructor
|
||||||
|
let user = sqlx::query_as::<_, (String,)>("SELECT role FROM users WHERE id = $1")
|
||||||
|
.bind(claims.sub)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
|
if user.0 != "instructor" && user.0 != "admin" {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Only instructors can endorse posts".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("UPDATE discussion_posts SET is_endorsed = NOT is_endorsed WHERE id = $1 AND organization_id = $2")
|
||||||
|
.bind(post_id)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn vote_post(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(post_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
Json(payload): Json<VotePayload>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
if payload.vote_type != "upvote" && payload.vote_type != "downvote" {
|
||||||
|
return Err((StatusCode::BAD_REQUEST, "Invalid vote type".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert vote
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO discussion_votes (organization_id, post_id, user_id, vote_type)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (post_id, user_id)
|
||||||
|
DO UPDATE SET vote_type = EXCLUDED.vote_type"
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(post_id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(&payload.vote_type)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
// Recalculate upvotes
|
||||||
|
let upvote_count: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM discussion_votes WHERE post_id = $1 AND vote_type = 'upvote'"
|
||||||
|
)
|
||||||
|
.bind(post_id)
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
sqlx::query("UPDATE discussion_posts SET upvotes = $1 WHERE id = $2")
|
||||||
|
.bind(upvote_count as i32)
|
||||||
|
.bind(post_id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== SUBSCRIPTION HANDLERS ==========
|
||||||
|
|
||||||
|
pub async fn subscribe_thread(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(thread_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO discussion_subscriptions (organization_id, thread_id, user_id)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT DO NOTHING"
|
||||||
|
)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unsubscribe_thread(
|
||||||
|
Org(org_ctx): Org,
|
||||||
|
claims: Claims,
|
||||||
|
Path(thread_id): Path<Uuid>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<StatusCode, (StatusCode, String)> {
|
||||||
|
sqlx::query(
|
||||||
|
"DELETE FROM discussion_subscriptions
|
||||||
|
WHERE thread_id = $1 AND user_id = $2 AND organization_id = $3"
|
||||||
|
)
|
||||||
|
.bind(thread_id)
|
||||||
|
.bind(claims.sub)
|
||||||
|
.bind(org_ctx.id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
mod db_util;
|
mod db_util;
|
||||||
mod handlers;
|
mod handlers;
|
||||||
|
mod handlers_discussions;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
Router, middleware,
|
Router, middleware,
|
||||||
@@ -88,6 +89,17 @@ async fn main() {
|
|||||||
"/notifications/{id}/read",
|
"/notifications/{id}/read",
|
||||||
post(handlers::mark_notification_as_read),
|
post(handlers::mark_notification_as_read),
|
||||||
)
|
)
|
||||||
|
// Discussion Forums Routes
|
||||||
|
.route("/courses/{id}/discussions", get(handlers_discussions::list_threads))
|
||||||
|
.route("/courses/{id}/discussions", post(handlers_discussions::create_thread))
|
||||||
|
.route("/discussions/{id}", get(handlers_discussions::get_thread_detail))
|
||||||
|
.route("/discussions/{id}/pin", post(handlers_discussions::pin_thread))
|
||||||
|
.route("/discussions/{id}/lock", post(handlers_discussions::lock_thread))
|
||||||
|
.route("/discussions/{id}/posts", post(handlers_discussions::create_post))
|
||||||
|
.route("/posts/{id}/endorse", post(handlers_discussions::endorse_post))
|
||||||
|
.route("/posts/{id}/vote", post(handlers_discussions::vote_post))
|
||||||
|
.route("/discussions/{id}/subscribe", post(handlers_discussions::subscribe_thread))
|
||||||
|
.route("/discussions/{id}/unsubscribe", post(handlers_discussions::unsubscribe_thread))
|
||||||
.route_layer(middleware::from_fn(
|
.route_layer(middleware::from_fn(
|
||||||
common::middleware::org_extractor_middleware,
|
common::middleware::org_extractor_middleware,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -291,6 +291,104 @@ pub struct RecommendationResponse {
|
|||||||
pub recommendations: Vec<Recommendation>,
|
pub recommendations: Vec<Recommendation>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Discussion Forums Models
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct DiscussionThread {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub lesson_id: Option<Uuid>,
|
||||||
|
pub author_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub is_locked: bool,
|
||||||
|
pub view_count: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct DiscussionPost {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub thread_id: Uuid,
|
||||||
|
pub parent_post_id: Option<Uuid>,
|
||||||
|
pub author_id: Uuid,
|
||||||
|
pub content: String,
|
||||||
|
pub upvotes: i32,
|
||||||
|
pub is_endorsed: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct DiscussionVote {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub post_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub vote_type: String, // 'upvote' or 'downvote'
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct DiscussionSubscription {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub thread_id: Uuid,
|
||||||
|
pub user_id: Uuid,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response DTOs for Discussion APIs
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct ThreadWithAuthor {
|
||||||
|
// Thread fields
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub course_id: Uuid,
|
||||||
|
pub lesson_id: Option<Uuid>,
|
||||||
|
pub author_id: Uuid,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub is_locked: bool,
|
||||||
|
pub view_count: i32,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
// Author info
|
||||||
|
pub author_name: String,
|
||||||
|
pub author_avatar: Option<String>,
|
||||||
|
// Aggregated data
|
||||||
|
pub post_count: i64,
|
||||||
|
pub has_endorsed_answer: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||||
|
pub struct PostWithAuthor {
|
||||||
|
// Post fields
|
||||||
|
pub id: Uuid,
|
||||||
|
pub organization_id: Uuid,
|
||||||
|
pub thread_id: Uuid,
|
||||||
|
pub parent_post_id: Option<Uuid>,
|
||||||
|
pub author_id: Uuid,
|
||||||
|
pub content: String,
|
||||||
|
pub upvotes: i32,
|
||||||
|
pub is_endorsed: bool,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
// Author info
|
||||||
|
pub author_name: String,
|
||||||
|
pub author_avatar: Option<String>,
|
||||||
|
// User interaction
|
||||||
|
pub user_vote: Option<String>, // 'upvote', 'downvote', or null
|
||||||
|
// Nested replies (not from DB, populated manually)
|
||||||
|
#[sqlx(skip)]
|
||||||
|
pub replies: Vec<PostWithAuthor>,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
Generated
+11
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^11.2.10",
|
"framer-motion": "^11.2.10",
|
||||||
"lucide-react": "^0.395.0",
|
"lucide-react": "^0.395.0",
|
||||||
"next": "14.2.21",
|
"next": "14.2.21",
|
||||||
@@ -1872,6 +1873,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"framer-motion": "^11.2.10",
|
"framer-motion": "^11.2.10",
|
||||||
"lucide-react": "^0.395.0",
|
"lucide-react": "^0.395.0",
|
||||||
"next": "14.2.21",
|
"next": "14.2.21",
|
||||||
|
|||||||
@@ -4,20 +4,28 @@ import React, { useState } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { lmsApi } from "@/lib/api";
|
import { lmsApi } from "@/lib/api";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
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() {
|
export default function ExperienceLoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login } = useAuth();
|
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 [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [organizationName, setOrganizationName] = useState("");
|
const [organizationName, setOrganizationName] = useState("");
|
||||||
const [fullName, setFullName] = useState("");
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [orgIdForSSO, setOrgIdForSSO] = useState("");
|
||||||
|
|
||||||
|
// UI State
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [ssoMode, setSSOMode] = useState(false);
|
|
||||||
const [orgIdForSSO, setOrgIdForSSO] = useState("");
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -25,16 +33,12 @@ export default function ExperienceLoginPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (viewMode === 'personal') {
|
||||||
if (isLogin) {
|
if (isLogin) {
|
||||||
const response = await lmsApi.login({ email, password });
|
const response = await lmsApi.login({ email, password });
|
||||||
|
|
||||||
// Verify user is a student
|
|
||||||
if (response.user.role !== "student") {
|
if (response.user.role !== "student") {
|
||||||
setError("Acceso denegado. Este portal es solo para estudiantes. Utiliza el portal de Studio para instructores.");
|
throw new Error("Acceso denegado. Este portal es solo para estudiantes.");
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
login(response.user, response.token);
|
login(response.user, response.token);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
} else {
|
} else {
|
||||||
@@ -44,209 +48,193 @@ export default function ExperienceLoginPage() {
|
|||||||
full_name: fullName,
|
full_name: fullName,
|
||||||
organization_name: organizationName,
|
organization_name: organizationName,
|
||||||
});
|
});
|
||||||
|
|
||||||
login(response.user, response.token);
|
login(response.user, response.token);
|
||||||
router.push("/");
|
router.push("/");
|
||||||
}
|
}
|
||||||
|
} else if (viewMode === 'enterprise') {
|
||||||
|
if (!orgIdForSSO) {
|
||||||
|
throw new Error("El ID de la organización es requerido");
|
||||||
|
}
|
||||||
|
lmsApi.initSSOLogin(orgIdForSSO);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Falló la autenticación");
|
setError(err instanceof Error ? err.message : "Falló la autenticación");
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
setError("");
|
||||||
|
setViewMode('selection');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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="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 */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<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" />
|
<GraduationCap className="w-8 h-8 text-white" />
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-black text-white mb-2">Experiencia OpenCCB</h1>
|
<h1 className="text-3xl font-black text-white mb-2 tracking-tight">Experiencia OpenCCB</h1>
|
||||||
Portal de Aprendizaje para Estudiantes
|
<p className="text-indigo-200/60 font-medium">Portal de Aprendizaje para Estudiantes</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login/Register Form */}
|
{/* Main Content Card */}
|
||||||
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-3xl p-8">
|
<div className="bg-white/5 backdrop-blur-xl border border-white/10 rounded-3xl overflow-hidden relative">
|
||||||
|
|
||||||
|
{/* 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">
|
<div className="flex gap-2 mb-6 bg-white/5 rounded-xl p-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setIsLogin(true)}
|
||||||
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"}`}
|
||||||
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
|
Iniciar Sesión
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => setIsLogin(false)}
|
||||||
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"}`}
|
||||||
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
|
Registrarse
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{!ssoMode ? (
|
|
||||||
<>
|
|
||||||
{!isLogin && (
|
{!isLogin && (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-bold text-gray-300 mb-2">
|
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Nombre Completo</label>
|
||||||
Nombre Completo
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
<input
|
<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" />
|
||||||
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
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-bold text-gray-300 mb-2">
|
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Correo Electrónico</label>
|
||||||
Correo Electrónico
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
<input
|
<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" />
|
||||||
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
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-bold text-gray-300 mb-2">
|
<label className="text-xs font-bold text-gray-400 uppercase tracking-wider">Contraseña</label>
|
||||||
Contraseña
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||||
<input
|
<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="••••••••" />
|
||||||
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
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{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>}
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
<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">
|
||||||
setSSOMode(!ssoMode);
|
{loading ? "Procesando..." : isLogin ? "Ingresar" : "Crear Cuenta"}
|
||||||
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-white/10 text-center">
|
{/* View: ENTERPRISE (Domain Login) */}
|
||||||
<p className="text-sm text-gray-400">
|
{viewMode === 'enterprise' && (
|
||||||
¿Eres un instructor?{" "}
|
<div className="p-8">
|
||||||
<a href="http://192.168.0.254:3000/auth/login" className="text-indigo-400 hover:text-indigo-300 font-bold">
|
<button onClick={handleBack} className="flex items-center gap-2 text-xs font-bold text-gray-500 hover:text-white mb-6 transition-colors">
|
||||||
Ir al Portal de Instructores
|
<ChevronLeft size={14} /> Volver
|
||||||
</a>
|
</button>
|
||||||
</p>
|
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-xs text-gray-500 mt-6">
|
<div className="space-y-1">
|
||||||
Experiencia OpenCCB - Portal de Aprendizaje para Estudiantes
|
<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>
|
||||||
|
|
||||||
|
{/* 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { lmsApi } from "@/lib/api";
|
|
||||||
import { useAuth } from "@/context/AuthContext";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { UserPlus, Mail, Lock, User, Building2 } from "lucide-react";
|
|
||||||
|
|
||||||
export default function RegisterPage() {
|
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 router = useRouter();
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
useEffect(() => {
|
||||||
e.preventDefault();
|
router.replace("/auth/login");
|
||||||
setLoading(true);
|
}, [router]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center p-6 bg-[#050505]">
|
<div className="min-h-screen bg-[#050505] flex items-center justify-center">
|
||||||
<div className="w-full max-w-md space-y-8 animate-in fade-in zoom-in duration-500">
|
<div className="animate-pulse text-gray-500 font-mono text-sm">Redirecting to login...</div>
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle } fr
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
|
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import DiscussionBoard from "@/components/DiscussionBoard";
|
||||||
|
|
||||||
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
@@ -250,6 +251,11 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Discussions Section */}
|
||||||
|
<div className="mt-20">
|
||||||
|
<DiscussionBoard courseId={params.id} />
|
||||||
|
</div>
|
||||||
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">Sé 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -191,6 +191,87 @@ export interface Module {
|
|||||||
lessons: Lesson[];
|
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 getToken = () => typeof window !== 'undefined' ? localStorage.getItem('experience_token') : null;
|
||||||
|
|
||||||
const apiFetch = async (url: string, options: RequestInit = {}, isCMS: boolean = false) => {
|
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 }> {
|
async getLessonFeedback(lessonId: string): Promise<{ response: string, session_id: string }> {
|
||||||
return apiFetch(`/lessons/${lessonId}/feedback`);
|
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'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user