Initial commit
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user