feat: mensajes

This commit is contained in:
2026-01-27 11:13:08 -03:00
parent 26f4283d0e
commit 23d761bada
12 changed files with 700 additions and 10 deletions
@@ -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>
);
};
+55
View File
@@ -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'
});
}
};