feat: mensajes
This commit is contained in:
@@ -501,6 +501,29 @@ curl -X POST "http://localhost:3002/posts/{post_id}/vote" \
|
||||
|
||||
---
|
||||
|
||||
### 5. Course Announcements (Anuncios)
|
||||
|
||||
| Action | Method | Endpoint | Description |
|
||||
|--------|--------|----------|-------------|
|
||||
| List | GET | `/courses/{id}/announcements` | Get all announcements for a course |
|
||||
| Create | POST | `/courses/{id}/announcements` | Create a new announcement (Instructor/Admin only) |
|
||||
| Update | PUT | `/announcements/{id}` | Update an announcement (Instructor/Admin only) |
|
||||
| Delete | DELETE| `/announcements/{id}` | Delete an announcement (Instructor/Admin only) |
|
||||
|
||||
#### Create Announcement Example
|
||||
```bash
|
||||
curl -X POST http://localhost:3002/courses/{course_id}/announcements \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "Bienvenida al curso",
|
||||
"content": "Bienvenidos a todos a este emocionante curso.",
|
||||
"is_pinned": true
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Multi-tenancy and Global Management (Super Admin)
|
||||
OpenCCB is built for multi-tenancy. Organizations are isolated, but a **Super Admin** can manage everything.
|
||||
|
||||
@@ -556,6 +579,7 @@ Obtiene una lista de todas las organizaciones registradas.
|
||||
- **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).
|
||||
- **Discussion Forums**: Complete forum system with threaded replies, voting, instructor moderation, and subscriptions.
|
||||
- **Course Announcements**: Instructor-to-student communication system with automatic notifications and pinning functionality.
|
||||
- **Split Authentication**: Separate login flows for personal users and enterprise organizations with SSO support.
|
||||
|
||||
## 📄 Licencia
|
||||
|
||||
+7
-7
@@ -177,9 +177,9 @@
|
||||
- [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.
|
||||
- [x] Frontend (componentes React)
|
||||
- [x] Integración con notificaciones
|
||||
- [x] **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.
|
||||
@@ -194,9 +194,9 @@
|
||||
|
||||
---
|
||||
|
||||
**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)**.
|
||||
**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, **sistema de foros de discusión funcional** y **gestión de anuncios del curso con notificaciones automáticas**.
|
||||
|
||||
**Próximas Prioridades**:
|
||||
1. **Discussion Forums Frontend**: Componentes React para interfaz de foros.
|
||||
2. **Course Announcements**: Comunicación instructor-estudiante.
|
||||
3. **Student Notes**: Anotaciones personales exportables.
|
||||
1. **Course Wiki**: Espacio colaborativo para documentación de cursos.
|
||||
2. **Student Notes**: Anotaciones personales exportables.
|
||||
3. **Course Teams**: Permisos granulares para múltiples instructores.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Course Announcements System
|
||||
-- Allows instructors to post announcements to students with automatic notifications
|
||||
|
||||
CREATE TABLE IF NOT EXISTS course_announcements (
|
||||
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,
|
||||
author_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
is_pinned BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX idx_announcements_course ON course_announcements(course_id);
|
||||
CREATE INDEX idx_announcements_org ON course_announcements(organization_id);
|
||||
CREATE INDEX idx_announcements_pinned ON course_announcements(is_pinned, created_at DESC);
|
||||
|
||||
-- Trigger to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_announcement_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER announcement_updated_at
|
||||
BEFORE UPDATE ON course_announcements
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_announcement_timestamp();
|
||||
@@ -0,0 +1,203 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use common::auth::Claims;
|
||||
use common::middleware::Org;
|
||||
use common::models::{CourseAnnouncement, AnnouncementWithAuthor};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
// ========== Request/Response DTOs ==========
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAnnouncementPayload {
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub is_pinned: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAnnouncementPayload {
|
||||
pub title: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub is_pinned: Option<bool>,
|
||||
}
|
||||
|
||||
// ========== HANDLERS ==========
|
||||
|
||||
pub async fn list_announcements(
|
||||
Org(org_ctx): Org,
|
||||
Path(course_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<Json<Vec<AnnouncementWithAuthor>>, (StatusCode, String)> {
|
||||
let announcements = sqlx::query_as::<_, AnnouncementWithAuthor>(
|
||||
"SELECT
|
||||
a.*,
|
||||
u.full_name as author_name,
|
||||
u.avatar_url as author_avatar
|
||||
FROM course_announcements a
|
||||
LEFT JOIN users u ON a.author_id = u.id
|
||||
WHERE a.course_id = $1 AND a.organization_id = $2
|
||||
ORDER BY a.is_pinned DESC, a.created_at DESC"
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(announcements))
|
||||
}
|
||||
|
||||
pub async fn create_announcement(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path(course_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<CreateAnnouncementPayload>,
|
||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||
// Check if user is instructor or admin
|
||||
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 create announcements".to_string()));
|
||||
}
|
||||
|
||||
// Create announcement
|
||||
let announcement = sqlx::query_as::<_, CourseAnnouncement>(
|
||||
"INSERT INTO course_announcements (organization_id, course_id, author_id, title, content, is_pinned)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(course_id)
|
||||
.bind(claims.sub)
|
||||
.bind(&payload.title)
|
||||
.bind(&payload.content)
|
||||
.bind(payload.is_pinned.unwrap_or(false))
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Get all enrolled students for notifications
|
||||
let enrolled_students = sqlx::query_as::<_, (Uuid,)>(
|
||||
"SELECT user_id FROM enrollments WHERE course_id = $1 AND user_id != $2"
|
||||
)
|
||||
.bind(course_id)
|
||||
.bind(claims.sub) // Exclude the announcement author
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Create notification for each enrolled student
|
||||
for (student_id,) in enrolled_students {
|
||||
let notification_title = format!("Nuevo Anuncio: {}", payload.title);
|
||||
let notification_message = if payload.content.len() > 100 {
|
||||
format!("{}...", &payload.content[..100])
|
||||
} else {
|
||||
payload.content.clone()
|
||||
};
|
||||
|
||||
let _ = sqlx::query(
|
||||
"INSERT INTO notifications (organization_id, user_id, title, message, notification_type, link_url)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)"
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(student_id)
|
||||
.bind(notification_title)
|
||||
.bind(notification_message)
|
||||
.bind("announcement")
|
||||
.bind(format!("/courses/{}#announcements", course_id))
|
||||
.execute(&pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(Json(announcement))
|
||||
}
|
||||
|
||||
pub async fn update_announcement(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path(announcement_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
Json(payload): Json<UpdateAnnouncementPayload>,
|
||||
) -> Result<Json<CourseAnnouncement>, (StatusCode, String)> {
|
||||
// Check if user is instructor or admin
|
||||
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 update announcements".to_string()));
|
||||
}
|
||||
|
||||
// Get current announcement to verify ownership
|
||||
let current = sqlx::query_as::<_, CourseAnnouncement>(
|
||||
"SELECT * FROM course_announcements WHERE id = $1 AND organization_id = $2"
|
||||
)
|
||||
.bind(announcement_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|_| (StatusCode::NOT_FOUND, "Announcement not found".to_string()))?;
|
||||
|
||||
let title = payload.title.unwrap_or(current.title);
|
||||
let content = payload.content.unwrap_or(current.content);
|
||||
let is_pinned = payload.is_pinned.unwrap_or(current.is_pinned);
|
||||
|
||||
let announcement = sqlx::query_as::<_, CourseAnnouncement>(
|
||||
"UPDATE course_announcements
|
||||
SET title = $1, content = $2, is_pinned = $3
|
||||
WHERE id = $4 AND organization_id = $5
|
||||
RETURNING *"
|
||||
)
|
||||
.bind(title)
|
||||
.bind(content)
|
||||
.bind(is_pinned)
|
||||
.bind(announcement_id)
|
||||
.bind(org_ctx.id)
|
||||
.fetch_one(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(announcement))
|
||||
}
|
||||
|
||||
pub async fn delete_announcement(
|
||||
Org(org_ctx): Org,
|
||||
claims: Claims,
|
||||
Path(announcement_id): Path<Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Check if user is instructor or admin
|
||||
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 delete announcements".to_string()));
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"DELETE FROM course_announcements
|
||||
WHERE id = $1 AND organization_id = $2"
|
||||
)
|
||||
.bind(announcement_id)
|
||||
.bind(org_ctx.id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(StatusCode::OK)
|
||||
}
|
||||
@@ -6,10 +6,10 @@ use axum::{
|
||||
use common::auth::Claims;
|
||||
use common::middleware::Org;
|
||||
use common::models::{
|
||||
DiscussionThread, DiscussionPost, DiscussionVote, DiscussionSubscription,
|
||||
DiscussionThread, DiscussionPost,
|
||||
ThreadWithAuthor, PostWithAuthor,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::Deserialize;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
mod db_util;
|
||||
mod handlers;
|
||||
mod handlers_discussions;
|
||||
mod handlers_announcements;
|
||||
|
||||
use axum::{
|
||||
Router, middleware,
|
||||
routing::{get, post},
|
||||
routing::{get, post, put, delete},
|
||||
};
|
||||
use dotenvy::dotenv;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
@@ -100,6 +101,11 @@ async fn main() {
|
||||
.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))
|
||||
// Announcements
|
||||
.route("/courses/{id}/announcements", get(handlers_announcements::list_announcements))
|
||||
.route("/courses/{id}/announcements", post(handlers_announcements::create_announcement))
|
||||
.route("/announcements/{id}", put(handlers_announcements::update_announcement))
|
||||
.route("/announcements/{id}", delete(handlers_announcements::delete_announcement))
|
||||
.route_layer(middleware::from_fn(
|
||||
common::middleware::org_extractor_middleware,
|
||||
));
|
||||
|
||||
@@ -388,6 +388,38 @@ pub struct PostWithAuthor {
|
||||
pub replies: Vec<PostWithAuthor>,
|
||||
}
|
||||
|
||||
// Course Announcements
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct CourseAnnouncement {
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub author_id: Uuid,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub is_pinned: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow, Clone)]
|
||||
pub struct AnnouncementWithAuthor {
|
||||
// Announcement fields
|
||||
pub id: Uuid,
|
||||
pub organization_id: Uuid,
|
||||
pub course_id: Uuid,
|
||||
pub author_id: Uuid,
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub is_pinned: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
// Author info
|
||||
pub author_name: String,
|
||||
pub author_avatar: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
||||
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info } from "lucide-react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import DiscussionBoard from "@/components/DiscussionBoard";
|
||||
import { AnnouncementsList } from "@/components/AnnouncementsList";
|
||||
|
||||
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
|
||||
const { user } = useAuth();
|
||||
@@ -199,6 +200,11 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Announcements Section */}
|
||||
<div className="mb-16">
|
||||
<AnnouncementsList courseId={params.id} isInstructor={user?.role === 'instructor' || user?.role === 'admin'} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
{courseData.modules.map((module, idx) => (
|
||||
<div key={module.id} className="relative">
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
import { AnnouncementWithAuthor, lmsApi } from '../lib/api';
|
||||
import { Pin, Trash2, Edit2, MoreVertical } from 'lucide-react';
|
||||
|
||||
interface AnnouncementCardProps {
|
||||
announcement: AnnouncementWithAuthor;
|
||||
isInstructor: boolean;
|
||||
onUpdate: () => void;
|
||||
}
|
||||
|
||||
export const AnnouncementCard: React.FC<AnnouncementCardProps> = ({
|
||||
announcement,
|
||||
isInstructor,
|
||||
onUpdate
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('¿Estás seguro de que deseas eliminar este anuncio?')) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await lmsApi.deleteAnnouncement(announcement.id);
|
||||
onUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error deleting announcement:', error);
|
||||
alert('Error al eliminar el anuncio');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative p-6 rounded-2xl border transition-all duration-300 ${announcement.is_pinned
|
||||
? 'bg-primary-500/10 border-primary-500/30'
|
||||
: 'bg-white/5 border-white/10 hover:border-white/20'
|
||||
}`}>
|
||||
{announcement.is_pinned && (
|
||||
<div className="absolute top-4 right-4 text-primary-400">
|
||||
<Pin className="w-4 h-4 fill-current" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-primary-500 to-secondary-500 flex items-center justify-center text-white font-bold overflow-hidden">
|
||||
{announcement.author_avatar ? (
|
||||
<img src={announcement.author_avatar} alt={announcement.author_name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
announcement.author_name.charAt(0)
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-white">{announcement.author_name}</h4>
|
||||
<p className="text-sm text-gray-400">
|
||||
{formatDistanceToNow(new Date(announcement.created_at), { addSuffix: true, locale: es })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstructor && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-2 rounded-lg hover:bg-red-500/20 text-gray-400 hover:text-red-400 transition-colors"
|
||||
title="Eliminar anuncio"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-white mb-2">{announcement.title}</h3>
|
||||
<div className="text-gray-300 whitespace-pre-wrap leading-relaxed">
|
||||
{announcement.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AnnouncementWithAuthor, lmsApi } from '../lib/api';
|
||||
import { AnnouncementCard } from './AnnouncementCard';
|
||||
import { NewAnnouncementModal } from './NewAnnouncementModal';
|
||||
import { Megaphone, Plus, Search, Loader2 } from 'lucide-react';
|
||||
|
||||
interface AnnouncementsListProps {
|
||||
courseId: string;
|
||||
isInstructor: boolean;
|
||||
}
|
||||
|
||||
export const AnnouncementsList: React.FC<AnnouncementsListProps> = ({ courseId, isInstructor }) => {
|
||||
const [announcements, setAnnouncements] = useState<AnnouncementWithAuthor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showNewModal, setShowNewModal] = useState(false);
|
||||
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await lmsApi.getAnnouncements(courseId);
|
||||
setAnnouncements(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching announcements:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnnouncements();
|
||||
}, [courseId]);
|
||||
|
||||
const filteredAnnouncements = announcements.filter(a =>
|
||||
a.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
a.content.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 rounded-xl bg-orange-500/20 text-orange-400">
|
||||
<Megaphone className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-white">Anuncios del Curso</h2>
|
||||
<p className="text-gray-400 italic">Mantente al día con las últimas noticias</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar anuncios..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10 pr-4 py-2 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
{isInstructor && (
|
||||
<button
|
||||
onClick={() => setShowNewModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-500 text-white rounded-xl font-semibold transition-all shadow-lg shadow-primary-900/20"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nuevo Anuncio</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||
<Loader2 className="w-10 h-10 text-primary-500 animate-spin" />
|
||||
<p className="text-gray-400">Cargando anuncios...</p>
|
||||
</div>
|
||||
) : filteredAnnouncements.length > 0 ? (
|
||||
<div className="grid gap-6">
|
||||
{filteredAnnouncements.map((announcement) => (
|
||||
<AnnouncementCard
|
||||
key={announcement.id}
|
||||
announcement={announcement}
|
||||
isInstructor={isInstructor}
|
||||
onUpdate={fetchAnnouncements}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-12 text-center">
|
||||
<div className="w-16 h-16 bg-white/5 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Megaphone className="w-8 h-8 text-gray-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-white mb-2">No hay anuncios</h3>
|
||||
<p className="text-gray-400 max-w-md mx-auto">
|
||||
{searchTerm
|
||||
? `No se encontraron anuncios que coincidan con "${searchTerm}"`
|
||||
: "Aún no se han publicado anuncios en este curso."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNewModal && (
|
||||
<NewAnnouncementModal
|
||||
courseId={courseId}
|
||||
onClose={() => setShowNewModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowNewModal(false);
|
||||
fetchAnnouncements();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,131 @@
|
||||
import React, { useState } from 'react';
|
||||
import { lmsApi } from '../lib/api';
|
||||
import { X, Send, AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface NewAnnouncementModalProps {
|
||||
courseId: string;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export const NewAnnouncementModal: React.FC<NewAnnouncementModalProps> = ({
|
||||
courseId,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!title.trim() || !content.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await lmsApi.createAnnouncement(courseId, {
|
||||
title: title.trim(),
|
||||
content: content.trim(),
|
||||
is_pinned: isPinned,
|
||||
});
|
||||
onSuccess();
|
||||
} catch (err: any) {
|
||||
console.error('Error creating announcement:', err);
|
||||
setError('Error al crear el anuncio. Por favor, inténtalo de nuevo.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-[#1a1c1e] border border-white/10 rounded-3xl w-full max-w-2xl overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/5">
|
||||
<h2 className="text-xl font-bold text-white flex items-center gap-2">
|
||||
<Send className="w-5 h-5 text-primary-500" />
|
||||
Publicar Nuevo Anuncio
|
||||
</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-white/5 rounded-xl text-gray-400 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{error && (
|
||||
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-3 text-red-400">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-400 ml-1">Título del Anuncio</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Ej: Nuevos materiales disponibles"
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-gray-400 ml-1">Contenido</label>
|
||||
<textarea
|
||||
required
|
||||
rows={6}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="Escribe el mensaje para los estudiantes..."
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder:text-gray-500 focus:outline-none focus:border-primary-500/50 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-4 bg-primary-500/5 border border-primary-500/10 rounded-2xl">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPinned"
|
||||
checked={isPinned}
|
||||
onChange={(e) => setIsPinned(e.target.checked)}
|
||||
className="w-5 h-5 rounded border-gray-300 text-primary-600 focus:ring-primary-500 bg-white/5"
|
||||
/>
|
||||
<label htmlFor="isPinned" className="text-sm text-gray-300 font-medium cursor-pointer">
|
||||
Fijar este anuncio al principio de la lista
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-6 py-2.5 rounded-xl font-semibold text-gray-400 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-8 py-2.5 bg-primary-600 hover:bg-primary-500 text-white rounded-xl font-bold transition-all shadow-lg shadow-primary-900/20 flex items-center gap-2 disabled:opacity-50"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
|
||||
<span>Publicando...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Publicar Anuncio</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -271,6 +271,36 @@ export interface VotePayload {
|
||||
vote_type: 'upvote' | 'downvote';
|
||||
}
|
||||
|
||||
export interface CourseAnnouncement {
|
||||
id: string;
|
||||
organization_id: string;
|
||||
course_id: string;
|
||||
author_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
is_pinned: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AnnouncementWithAuthor extends CourseAnnouncement {
|
||||
author_name: string;
|
||||
author_avatar?: string;
|
||||
}
|
||||
|
||||
export interface CreateAnnouncementPayload {
|
||||
title: string;
|
||||
content: string;
|
||||
is_pinned?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateAnnouncementPayload {
|
||||
title?: string;
|
||||
content?: string;
|
||||
is_pinned?: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const getToken = () => typeof window !== 'undefined' ? localStorage.getItem('experience_token') : null;
|
||||
|
||||
@@ -508,5 +538,30 @@ export const lmsApi = {
|
||||
return apiFetch(`/discussions/${threadId}/unsubscribe`, {
|
||||
method: 'POST'
|
||||
});
|
||||
},
|
||||
|
||||
// Announcements API
|
||||
async getAnnouncements(courseId: string): Promise<AnnouncementWithAuthor[]> {
|
||||
return apiFetch(`/courses/${courseId}/announcements`);
|
||||
},
|
||||
|
||||
async createAnnouncement(courseId: string, payload: CreateAnnouncementPayload): Promise<CourseAnnouncement> {
|
||||
return apiFetch(`/courses/${courseId}/announcements`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
|
||||
async updateAnnouncement(id: string, payload: UpdateAnnouncementPayload): Promise<CourseAnnouncement> {
|
||||
return apiFetch(`/announcements/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
},
|
||||
|
||||
async deleteAnnouncement(id: string): Promise<void> {
|
||||
return apiFetch(`/announcements/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user