feat: add comprehensive discussion forums with threads, nested replies, voting, and moderation, alongside updates to authentication flows.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user