437 lines
16 KiB
TypeScript
437 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useSession } from 'next-auth/react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog';
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
import { Trash2 } from 'lucide-react';
|
|
|
|
type User = {
|
|
id: string;
|
|
name: string | null;
|
|
email: string;
|
|
image: string | null;
|
|
};
|
|
|
|
type CommentReply = {
|
|
id: string;
|
|
userId: string;
|
|
commentId: string;
|
|
content: string;
|
|
createdAt: string;
|
|
user: User;
|
|
};
|
|
|
|
type Comment = {
|
|
id: string;
|
|
userId: string;
|
|
videoId: string;
|
|
content: string;
|
|
createdAt: string;
|
|
user: User;
|
|
replies: CommentReply[];
|
|
};
|
|
|
|
interface CommentsSectionProps {
|
|
videoId: string;
|
|
}
|
|
|
|
export function CommentsSection({ videoId }: CommentsSectionProps) {
|
|
const { data: session } = useSession();
|
|
const [comments, setComments] = useState<Comment[]>([]);
|
|
const [newCommentContent, setNewCommentContent] = useState('');
|
|
const [replyingToId, setReplyingToId] = useState<string | null>(null);
|
|
const [replyContent, setReplyContent] = useState('');
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
|
const [deleteReplyDialogOpen, setDeleteReplyDialogOpen] = useState(false);
|
|
const [deletingReplyId, setDeletingReplyId] = useState<string | null>(null);
|
|
const [deletingReplyCommentId, setDeletingReplyCommentId] = useState<string | null>(null);
|
|
const isAdmin = (session?.user as any)?.role === 'admin' || (session?.user as any)?.role === 'superadmin';
|
|
|
|
const handleDeleteComment = async (commentId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/comments/${commentId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (res.ok) {
|
|
setComments(comments.filter((c) => c.id !== commentId));
|
|
setDeleteDialogOpen(false);
|
|
setDeletingCommentId(null);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete comment:', err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteReply = async (replyId: string, commentId: string) => {
|
|
try {
|
|
const res = await fetch(`/api/comments/reply/${replyId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
|
|
if (res.ok) {
|
|
setComments(
|
|
comments.map((c) =>
|
|
c.id === commentId
|
|
? {
|
|
...c,
|
|
replies: c.replies.filter((r) => r.id !== replyId),
|
|
}
|
|
: c
|
|
)
|
|
);
|
|
setDeleteReplyDialogOpen(false);
|
|
setDeletingReplyId(null);
|
|
setDeletingReplyCommentId(null);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to delete reply:', err);
|
|
}
|
|
};
|
|
|
|
// Fetch comments on mount and when videoId changes
|
|
useEffect(() => {
|
|
const fetchComments = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/comments?videoId=${videoId}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setComments(data);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to fetch comments:', err);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchComments();
|
|
}, [videoId]);
|
|
|
|
const handleCommentSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!session?.user || !newCommentContent.trim()) return;
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
const res = await fetch('/api/comments', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ videoId, content: newCommentContent }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const newComment = await res.json();
|
|
setComments([newComment, ...comments]);
|
|
setNewCommentContent('');
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to post comment:', err);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleReplySubmit = async (e: React.FormEvent, commentId: string) => {
|
|
e.preventDefault();
|
|
if (!session?.user || !replyContent.trim()) return;
|
|
|
|
setIsSubmitting(true);
|
|
try {
|
|
const res = await fetch(`/api/comments/${commentId}/reply`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content: replyContent }),
|
|
});
|
|
|
|
if (res.ok) {
|
|
const newReply = await res.json();
|
|
setComments(
|
|
comments.map((c) =>
|
|
c.id === commentId
|
|
? { ...c, replies: [...c.replies, newReply] }
|
|
: c
|
|
)
|
|
);
|
|
setReplyContent('');
|
|
setReplyingToId(null);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to post reply:', err);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const getDisplayName = (user: User) => user.name || user.email.split('@')[0];
|
|
const getInitials = (user: User) => {
|
|
const name = getDisplayName(user);
|
|
return name
|
|
.split(' ')
|
|
.map((n) => n[0])
|
|
.join('')
|
|
.toUpperCase();
|
|
};
|
|
|
|
if (isLoading) {
|
|
return <div className="py-4 text-sm text-muted-foreground">Loading comments...</div>;
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<h3 className="text-lg font-semibold mb-4">Comments ({comments.length})</h3>
|
|
|
|
{/* Add Comment Form */}
|
|
{session?.user ? (
|
|
<form onSubmit={handleCommentSubmit} className="mb-6 pb-4 border-b">
|
|
<div className="flex gap-3">
|
|
<Avatar className="w-8 h-8 shrink-0">
|
|
<AvatarImage src={(session.user as any).image} alt={(session.user as any).name} />
|
|
<AvatarFallback>
|
|
{getInitials({
|
|
id: (session.user as any).id,
|
|
name: (session.user as any).name,
|
|
email: (session.user as any).email,
|
|
image: (session.user as any).image,
|
|
})}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1">
|
|
<Textarea
|
|
placeholder="Add a comment..."
|
|
value={newCommentContent}
|
|
onChange={(e) => setNewCommentContent(e.target.value)}
|
|
rows={2}
|
|
className="mb-2"
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setNewCommentContent('')}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={!newCommentContent.trim() || isSubmitting}
|
|
>
|
|
{isSubmitting ? 'Posting...' : 'Comment'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
) : (
|
|
<div className="mb-6 pb-4 border-b text-sm text-muted-foreground">
|
|
Sign in to comment
|
|
</div>
|
|
)}
|
|
|
|
{/* Comments List */}
|
|
<div className="space-y-4">
|
|
{comments.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">No comments yet. Be the first to comment!</p>
|
|
) : (
|
|
comments.map((comment) => (
|
|
<div key={comment.id} className="space-y-3">
|
|
{/* Comment */}
|
|
<div className="flex gap-3">
|
|
<Avatar className="w-8 h-8 shrink-0">
|
|
<AvatarImage src={comment.user.image || undefined} alt={getDisplayName(comment.user)} />
|
|
<AvatarFallback>{getInitials(comment.user)}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1">
|
|
<div className="bg-muted rounded-lg p-3">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-sm font-medium">{getDisplayName(comment.user)}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDistanceToNow(new Date(comment.createdAt), { addSuffix: true })}
|
|
</span>
|
|
{isAdmin && (
|
|
<AlertDialog open={deleteDialogOpen && deletingCommentId === comment.id} onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteDialogOpen(false);
|
|
setDeletingCommentId(null);
|
|
}
|
|
}}>
|
|
<button
|
|
onClick={() => {
|
|
setDeleteDialogOpen(true);
|
|
setDeletingCommentId(comment.id);
|
|
}}
|
|
className="ml-auto p-1 hover:bg-destructive/20 rounded"
|
|
title="Delete comment"
|
|
>
|
|
<Trash2 className="w-3 h-3 text-destructive" />
|
|
</button>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Comment</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete this comment?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="flex gap-2 justify-end">
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => handleDeleteComment(comment.id)}
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</div>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</div>
|
|
<p className="text-sm">{comment.content}</p>
|
|
</div>
|
|
{session?.user && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-1 text-xs h-auto p-0"
|
|
onClick={() => setReplyingToId(replyingToId === comment.id ? null : comment.id)}
|
|
>
|
|
{replyingToId === comment.id ? 'Cancel' : 'Reply'}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reply Form */}
|
|
{replyingToId === comment.id && session?.user && (
|
|
<form
|
|
onSubmit={(e) => handleReplySubmit(e, comment.id)}
|
|
className="ml-8 flex gap-3 mb-3"
|
|
>
|
|
<Avatar className="w-8 h-8 shrink-0">
|
|
<AvatarImage src={(session.user as any).image} alt={(session.user as any).name} />
|
|
<AvatarFallback>
|
|
{getInitials({
|
|
id: (session.user as any).id,
|
|
name: (session.user as any).name,
|
|
email: (session.user as any).email,
|
|
image: (session.user as any).image,
|
|
})}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1">
|
|
<Textarea
|
|
placeholder="Add a reply..."
|
|
value={replyContent}
|
|
onChange={(e) => setReplyContent(e.target.value)}
|
|
rows={2}
|
|
className="mb-2"
|
|
/>
|
|
<div className="flex gap-2 justify-end">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
setReplyContent('');
|
|
setReplyingToId(null);
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={!replyContent.trim() || isSubmitting}
|
|
>
|
|
{isSubmitting ? 'Replying...' : 'Reply'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Replies */}
|
|
{comment.replies.length > 0 && (
|
|
<div className="ml-8 space-y-3 pt-2 border-l-2 border-muted pl-4">
|
|
{comment.replies.map((reply) => (
|
|
<div key={reply.id} className="flex gap-3">
|
|
<Avatar className="w-8 h-8 shrink-0">
|
|
<AvatarImage src={reply.user.image || undefined} alt={getDisplayName(reply.user)} />
|
|
<AvatarFallback>{getInitials(reply.user)}</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1">
|
|
<div className="bg-muted rounded-lg p-3">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className="text-sm font-medium">{getDisplayName(reply.user)}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDistanceToNow(new Date(reply.createdAt), { addSuffix: true })}
|
|
</span>
|
|
{isAdmin && (
|
|
<AlertDialog open={deleteReplyDialogOpen && deletingReplyId === reply.id} onOpenChange={(open) => {
|
|
if (!open) {
|
|
setDeleteReplyDialogOpen(false);
|
|
setDeletingReplyId(null);
|
|
setDeletingReplyCommentId(null);
|
|
}
|
|
}}>
|
|
<button
|
|
onClick={() => {
|
|
setDeleteReplyDialogOpen(true);
|
|
setDeletingReplyId(reply.id);
|
|
setDeletingReplyCommentId(comment.id);
|
|
}}
|
|
className="ml-auto p-1 hover:bg-destructive/20 rounded"
|
|
title="Delete reply"
|
|
>
|
|
<Trash2 className="w-3 h-3 text-destructive" />
|
|
</button>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Reply</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete this reply?
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<div className="flex gap-2 justify-end">
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => handleDeleteReply(reply.id, comment.id)}
|
|
>
|
|
Delete
|
|
</AlertDialogAction>
|
|
</div>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
)}
|
|
</div>
|
|
<p className="text-sm">{reply.content}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|