Files
Vault/components/comments-section.tsx
twotalesanimation 81ad7e4ea9 Initial commit
2026-06-11 10:46:09 +02:00

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>
);
}