mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat(comment): add reply, edit, and delete functionality for comments and replies
This commit is contained in:
@@ -25,6 +25,11 @@ import {
|
|||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
GitCompareArrows,
|
GitCompareArrows,
|
||||||
|
Reply,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
ArrowUpRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -131,6 +136,15 @@ export default function VideoPage() {
|
|||||||
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
|
||||||
|
// Reply/Edit/Delete state
|
||||||
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||||
|
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
||||||
|
|
||||||
// New version dialog
|
// New version dialog
|
||||||
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
const [showVersionDialog, setShowVersionDialog] = useState(false);
|
||||||
const [newVersionUrl, setNewVersionUrl] = useState('');
|
const [newVersionUrl, setNewVersionUrl] = useState('');
|
||||||
@@ -393,6 +407,141 @@ export default function VideoPage() {
|
|||||||
[activeVersionId]
|
[activeVersionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reply to a comment
|
||||||
|
const handleReplyComment = useCallback(async (parentId: string) => {
|
||||||
|
if (!replyText.trim() || !activeVersion) return;
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: replyText,
|
||||||
|
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
|
||||||
|
parentId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const newReply = await res.json();
|
||||||
|
setVideo((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
versions: prev.versions.map((v) =>
|
||||||
|
v.id === activeVersionId
|
||||||
|
? {
|
||||||
|
...v,
|
||||||
|
comments: v.comments.map((c) =>
|
||||||
|
c.id === parentId
|
||||||
|
? { ...c, replies: [...c.replies, newReply] }
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: v
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setReplyText('');
|
||||||
|
setReplyingTo(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reply:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingReply(false);
|
||||||
|
}
|
||||||
|
}, [replyText, activeVersion, activeVersionId, comments, currentTime]);
|
||||||
|
|
||||||
|
// Edit a comment
|
||||||
|
const handleEditComment = useCallback(async (commentId: string) => {
|
||||||
|
if (!editText.trim()) return;
|
||||||
|
setIsSubmittingEdit(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${commentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: editText }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setVideo((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
versions: prev.versions.map((v) =>
|
||||||
|
v.id === activeVersionId
|
||||||
|
? {
|
||||||
|
...v,
|
||||||
|
comments: v.comments.map((c) => {
|
||||||
|
if (c.id === commentId) return { ...c, content: editText.trim() };
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
replies: c.replies.map((r) =>
|
||||||
|
r.id === commentId ? { ...r, content: editText.trim() } : r
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: v
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setEditText('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to edit comment:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingEdit(false);
|
||||||
|
}
|
||||||
|
}, [editText, activeVersionId]);
|
||||||
|
|
||||||
|
// Delete a comment
|
||||||
|
const handleDeleteComment = useCallback(async (commentId: string) => {
|
||||||
|
setDeletingCommentId(commentId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
setVideo((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
versions: prev.versions.map((v) =>
|
||||||
|
v.id === activeVersionId
|
||||||
|
? {
|
||||||
|
...v,
|
||||||
|
comments: v.comments
|
||||||
|
.filter((c) => c.id !== commentId)
|
||||||
|
.map((c) => ({
|
||||||
|
...c,
|
||||||
|
replies: c.replies.filter((r) => r.id !== commentId),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: v
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete comment:', err);
|
||||||
|
} finally {
|
||||||
|
setDeletingCommentId(null);
|
||||||
|
}
|
||||||
|
}, [activeVersionId]);
|
||||||
|
|
||||||
|
// Poll for new comments every 10 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeVersion) return;
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setVideo(data);
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [activeVersion, projectId, videoId]);
|
||||||
|
|
||||||
// New version URL handler
|
// New version URL handler
|
||||||
const handleNewVersionUrlChange = (url: string) => {
|
const handleNewVersionUrlChange = (url: string) => {
|
||||||
setNewVersionUrl(url);
|
setNewVersionUrl(url);
|
||||||
@@ -781,6 +930,8 @@ export default function VideoPage() {
|
|||||||
.map((comment) => {
|
.map((comment) => {
|
||||||
const authorName =
|
const authorName =
|
||||||
comment.author?.name || comment.guestName || 'Anonymous';
|
comment.author?.name || comment.guestName || 'Anonymous';
|
||||||
|
const isEditing = editingCommentId === comment.id;
|
||||||
|
const isReplying = replyingTo === comment.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
@@ -803,10 +954,12 @@ export default function VideoPage() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
||||||
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10"
|
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||||
|
title="Jump to this timestamp"
|
||||||
>
|
>
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{formatTime(comment.timestamp)}
|
{formatTime(comment.timestamp)}
|
||||||
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -833,9 +986,25 @@ export default function VideoPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>Reply</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => {
|
||||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
setReplyingTo(comment.id);
|
||||||
<DropdownMenuItem className="text-destructive">
|
setReplyText('');
|
||||||
|
}}>
|
||||||
|
<Reply className="h-4 w-4 mr-2" />
|
||||||
|
Reply
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => {
|
||||||
|
setEditingCommentId(comment.id);
|
||||||
|
setEditText(comment.content || '');
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteComment(comment.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -843,7 +1012,46 @@ export default function VideoPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{comment.content && <p className="text-sm mb-2">{comment.content}</p>}
|
{isEditing ? (
|
||||||
|
<div className="mb-2">
|
||||||
|
<Textarea
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm mb-1"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleEditComment(comment.id);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setEditText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditComment(comment.id)}
|
||||||
|
disabled={!editText.trim() || isSubmittingEdit}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setEditingCommentId(null); setEditText(''); }}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comment.content && <p className="text-sm mb-2">{comment.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{comment.voiceUrl && (
|
{comment.voiceUrl && (
|
||||||
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
|
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
|
||||||
@@ -863,14 +1071,17 @@ export default function VideoPage() {
|
|||||||
{new Date(comment.createdAt).toLocaleDateString()}
|
{new Date(comment.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
{comment.replies.length > 0 && (
|
{comment.replies.length > 0 && (
|
||||||
<div className="mt-3 pl-3 border-l-2 space-y-2">
|
<div className="mt-3 pl-3 border-l-2 space-y-2">
|
||||||
{comment.replies.map((reply) => {
|
{comment.replies.map((reply) => {
|
||||||
const replyAuthor =
|
const replyAuthor =
|
||||||
reply.author?.name || reply.guestName || 'Anonymous';
|
reply.author?.name || reply.guestName || 'Anonymous';
|
||||||
|
const isEditingReply = editingCommentId === reply.id;
|
||||||
return (
|
return (
|
||||||
<div key={reply.id} className="text-sm">
|
<div key={reply.id} className="group/reply text-sm">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Avatar className="h-5 w-5">
|
<Avatar className="h-5 w-5">
|
||||||
<AvatarFallback className="text-xs">
|
<AvatarFallback className="text-xs">
|
||||||
{replyAuthor.charAt(0)}
|
{replyAuthor.charAt(0)}
|
||||||
@@ -881,12 +1092,131 @@ export default function VideoPage() {
|
|||||||
{new Date(reply.createdAt).toLocaleDateString()}
|
{new Date(reply.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 opacity-0 group-hover/reply:opacity-100 shrink-0"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => {
|
||||||
|
setEditingCommentId(reply.id);
|
||||||
|
setEditText(reply.content || '');
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteComment(reply.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
{isEditingReply ? (
|
||||||
|
<div className="mb-1">
|
||||||
|
<Textarea
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm mb-1"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleEditComment(reply.id);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setEditText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditComment(reply.id)}
|
||||||
|
disabled={!editText.trim() || isSubmittingEdit}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setEditingCommentId(null); setEditText(''); }}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<p className="text-sm">{reply.content}</p>
|
<p className="text-sm">{reply.content}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline reply form */}
|
||||||
|
{isReplying && (
|
||||||
|
<div className="mt-3 pl-3 border-l-2">
|
||||||
|
<Textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm mb-1"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleReplyComment(comment.id);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setReplyingTo(null);
|
||||||
|
setReplyText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReplyComment(comment.id)}
|
||||||
|
disabled={!replyText.trim() || isSubmittingReply}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setReplyingTo(null); setReplyText(''); }}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick reply button */}
|
||||||
|
{!isReplying && !isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setReplyingTo(comment.id); setReplyText(''); }}
|
||||||
|
className="mt-2 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Reply className="h-3 w-3" />
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,10 +20,15 @@ import {
|
|||||||
SkipBack,
|
SkipBack,
|
||||||
SkipForward,
|
SkipForward,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Reply,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
ArrowUpRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
import {
|
import {
|
||||||
@@ -111,6 +116,16 @@ export default function WatchPage() {
|
|||||||
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
|
||||||
|
// Reply/Edit/Delete state
|
||||||
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
|
const [replyText, setReplyText] = useState('');
|
||||||
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState<string | null>(null);
|
||||||
|
const [editText, setEditText] = useState('');
|
||||||
|
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||||
|
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
||||||
|
const [guestName, setGuestName] = useState('');
|
||||||
|
|
||||||
// Fetch video data
|
// Fetch video data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVideo() {
|
async function fetchVideo() {
|
||||||
@@ -330,6 +345,141 @@ export default function WatchPage() {
|
|||||||
[activeVersionId]
|
[activeVersionId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reply to a comment
|
||||||
|
const handleReplyComment = useCallback(async (parentId: string) => {
|
||||||
|
if (!replyText.trim() || !activeVersion) return;
|
||||||
|
setIsSubmittingReply(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/versions/${activeVersion.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: replyText,
|
||||||
|
timestamp: comments.find((c) => c.id === parentId)?.timestamp ?? currentTime,
|
||||||
|
parentId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const newReply = await res.json();
|
||||||
|
setVideo((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
versions: prev.versions.map((v) =>
|
||||||
|
v.id === activeVersionId
|
||||||
|
? {
|
||||||
|
...v,
|
||||||
|
comments: v.comments.map((c) =>
|
||||||
|
c.id === parentId
|
||||||
|
? { ...c, replies: [...c.replies, newReply] }
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: v
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setReplyText('');
|
||||||
|
setReplyingTo(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reply:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingReply(false);
|
||||||
|
}
|
||||||
|
}, [replyText, activeVersion, activeVersionId, comments, currentTime]);
|
||||||
|
|
||||||
|
// Edit a comment
|
||||||
|
const handleEditComment = useCallback(async (commentId: string) => {
|
||||||
|
if (!editText.trim()) return;
|
||||||
|
setIsSubmittingEdit(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${commentId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ content: editText }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setVideo((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
versions: prev.versions.map((v) =>
|
||||||
|
v.id === activeVersionId
|
||||||
|
? {
|
||||||
|
...v,
|
||||||
|
comments: v.comments.map((c) => {
|
||||||
|
if (c.id === commentId) return { ...c, content: editText.trim() };
|
||||||
|
return {
|
||||||
|
...c,
|
||||||
|
replies: c.replies.map((r) =>
|
||||||
|
r.id === commentId ? { ...r, content: editText.trim() } : r
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
: v
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setEditText('');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to edit comment:', err);
|
||||||
|
} finally {
|
||||||
|
setIsSubmittingEdit(false);
|
||||||
|
}
|
||||||
|
}, [editText, activeVersionId]);
|
||||||
|
|
||||||
|
// Delete a comment
|
||||||
|
const handleDeleteComment = useCallback(async (commentId: string) => {
|
||||||
|
setDeletingCommentId(commentId);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/comments/${commentId}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
setVideo((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
versions: prev.versions.map((v) =>
|
||||||
|
v.id === activeVersionId
|
||||||
|
? {
|
||||||
|
...v,
|
||||||
|
comments: v.comments
|
||||||
|
.filter((c) => c.id !== commentId)
|
||||||
|
.map((c) => ({
|
||||||
|
...c,
|
||||||
|
replies: c.replies.filter((r) => r.id !== commentId),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: v
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete comment:', err);
|
||||||
|
} finally {
|
||||||
|
setDeletingCommentId(null);
|
||||||
|
}
|
||||||
|
}, [activeVersionId]);
|
||||||
|
|
||||||
|
// Poll for new comments every 10 seconds
|
||||||
|
useEffect(() => {
|
||||||
|
if (!video) return;
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/watch/${videoId}`);
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
setVideo(data);
|
||||||
|
}
|
||||||
|
} catch { /* silent */ }
|
||||||
|
}, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [video, videoId]);
|
||||||
|
|
||||||
const getEmbedUrl = (version: Version) => {
|
const getEmbedUrl = (version: Version) => {
|
||||||
if (version.providerId === 'youtube') {
|
if (version.providerId === 'youtube') {
|
||||||
return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`;
|
||||||
@@ -555,6 +705,8 @@ export default function WatchPage() {
|
|||||||
.map((comment) => {
|
.map((comment) => {
|
||||||
const authorName =
|
const authorName =
|
||||||
comment.author?.name || comment.guestName || 'Anonymous';
|
comment.author?.name || comment.guestName || 'Anonymous';
|
||||||
|
const isEditing = editingCommentId === comment.id;
|
||||||
|
const isReplying = replyingTo === comment.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
@@ -577,10 +729,12 @@ export default function WatchPage() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
onClick={() => handleSeekToTimestamp(comment.timestamp)}
|
||||||
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10"
|
className="flex items-center gap-1 text-xs text-primary hover:underline px-1.5 py-0.5 rounded bg-primary/10 hover:bg-primary/20 transition-colors"
|
||||||
|
title="Jump to this timestamp"
|
||||||
>
|
>
|
||||||
<Clock className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
{formatTime(comment.timestamp)}
|
{formatTime(comment.timestamp)}
|
||||||
|
<ArrowUpRight className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -607,9 +761,25 @@ export default function WatchPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem>Reply</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => {
|
||||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
setReplyingTo(comment.id);
|
||||||
<DropdownMenuItem className="text-destructive">
|
setReplyText('');
|
||||||
|
}}>
|
||||||
|
<Reply className="h-4 w-4 mr-2" />
|
||||||
|
Reply
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => {
|
||||||
|
setEditingCommentId(comment.id);
|
||||||
|
setEditText(comment.content || '');
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteComment(comment.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -617,7 +787,46 @@ export default function WatchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{comment.content && <p className="text-sm mb-2">{comment.content}</p>}
|
{isEditing ? (
|
||||||
|
<div className="mb-2">
|
||||||
|
<Textarea
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm mb-1"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleEditComment(comment.id);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setEditText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditComment(comment.id)}
|
||||||
|
disabled={!editText.trim() || isSubmittingEdit}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setEditingCommentId(null); setEditText(''); }}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
comment.content && <p className="text-sm mb-2">{comment.content}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{comment.voiceUrl && (
|
{comment.voiceUrl && (
|
||||||
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
|
<div className="flex items-center gap-2 p-2 bg-muted rounded mb-2">
|
||||||
@@ -637,14 +846,17 @@ export default function WatchPage() {
|
|||||||
{new Date(comment.createdAt).toLocaleDateString()}
|
{new Date(comment.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* Replies */}
|
||||||
{comment.replies.length > 0 && (
|
{comment.replies.length > 0 && (
|
||||||
<div className="mt-3 pl-3 border-l-2 space-y-2">
|
<div className="mt-3 pl-3 border-l-2 space-y-2">
|
||||||
{comment.replies.map((reply) => {
|
{comment.replies.map((reply) => {
|
||||||
const replyAuthor =
|
const replyAuthor =
|
||||||
reply.author?.name || reply.guestName || 'Anonymous';
|
reply.author?.name || reply.guestName || 'Anonymous';
|
||||||
|
const isEditingReply = editingCommentId === reply.id;
|
||||||
return (
|
return (
|
||||||
<div key={reply.id} className="text-sm">
|
<div key={reply.id} className="group/reply text-sm">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<Avatar className="h-5 w-5">
|
<Avatar className="h-5 w-5">
|
||||||
<AvatarFallback className="text-xs">
|
<AvatarFallback className="text-xs">
|
||||||
{replyAuthor.charAt(0)}
|
{replyAuthor.charAt(0)}
|
||||||
@@ -655,12 +867,131 @@ export default function WatchPage() {
|
|||||||
{new Date(reply.createdAt).toLocaleDateString()}
|
{new Date(reply.createdAt).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5 opacity-0 group-hover/reply:opacity-100 shrink-0"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => {
|
||||||
|
setEditingCommentId(reply.id);
|
||||||
|
setEditText(reply.content || '');
|
||||||
|
}}>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive"
|
||||||
|
onClick={() => handleDeleteComment(reply.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
{isEditingReply ? (
|
||||||
|
<div className="mb-1">
|
||||||
|
<Textarea
|
||||||
|
value={editText}
|
||||||
|
onChange={(e) => setEditText(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm mb-1"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleEditComment(reply.id);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setEditText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleEditComment(reply.id)}
|
||||||
|
disabled={!editText.trim() || isSubmittingEdit}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{isSubmittingEdit ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setEditingCommentId(null); setEditText(''); }}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<p className="text-sm">{reply.content}</p>
|
<p className="text-sm">{reply.content}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Inline reply form */}
|
||||||
|
{isReplying && (
|
||||||
|
<div className="mt-3 pl-3 border-l-2">
|
||||||
|
<Textarea
|
||||||
|
value={replyText}
|
||||||
|
onChange={(e) => setReplyText(e.target.value)}
|
||||||
|
placeholder="Write a reply..."
|
||||||
|
rows={2}
|
||||||
|
className="resize-none text-sm mb-1"
|
||||||
|
autoFocus
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||||
|
handleReplyComment(comment.id);
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setReplyingTo(null);
|
||||||
|
setReplyText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleReplyComment(comment.id)}
|
||||||
|
disabled={!replyText.trim() || isSubmittingReply}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
{isSubmittingReply ? <Loader2 className="h-3 w-3 animate-spin" /> : 'Reply'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => { setReplyingTo(null); setReplyText(''); }}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick reply button */}
|
||||||
|
{!isReplying && !isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setReplyingTo(comment.id); setReplyText(''); }}
|
||||||
|
className="mt-2 text-xs text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Reply className="h-3 w-3" />
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user