@@ -18,8 +18,12 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Filter,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Check,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { addComment, addReply, resolveComment } from "@/actions/comments";
|
||||
import { addComment, addReply, resolveComment, deleteComment, editComment } from "@/actions/comments";
|
||||
import { useToast } from "@/components/ui/use-toast";
|
||||
import type { CommentWithReplies } from "@/types";
|
||||
|
||||
@@ -28,9 +32,12 @@ interface CommentPanelProps {
|
||||
fps: number;
|
||||
comments: CommentWithReplies[];
|
||||
onCommentsChange?: () => void;
|
||||
onAnnotationDeleted?: () => void;
|
||||
pendingFrame?: number | null;
|
||||
onPendingFrameCleared?: () => void;
|
||||
onSeekToFrame?: (frame: number) => void;
|
||||
currentUserId?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export function CommentPanel({
|
||||
@@ -38,9 +45,12 @@ export function CommentPanel({
|
||||
fps,
|
||||
comments,
|
||||
onCommentsChange,
|
||||
onAnnotationDeleted,
|
||||
pendingFrame,
|
||||
onPendingFrameCleared,
|
||||
onSeekToFrame,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: CommentPanelProps) {
|
||||
const { currentFrame, setCurrentFrame } = useReviewStore();
|
||||
const [filterResolved, setFilterResolved] = useState<"all" | "unresolved" | "resolved">("all");
|
||||
@@ -205,6 +215,9 @@ export function CommentPanel({
|
||||
onSeekToFrame?.(frame);
|
||||
}}
|
||||
onResolved={onCommentsChange}
|
||||
onAnnotationDeleted={onAnnotationDeleted}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -222,6 +235,9 @@ interface CommentThreadProps {
|
||||
isActive: boolean;
|
||||
onJumpToFrame: (frame: number) => void;
|
||||
onResolved?: () => void;
|
||||
onAnnotationDeleted?: () => void;
|
||||
currentUserId?: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
function CommentThread({
|
||||
@@ -230,12 +246,21 @@ function CommentThread({
|
||||
isActive,
|
||||
onJumpToFrame,
|
||||
onResolved,
|
||||
onAnnotationDeleted,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: CommentThreadProps) {
|
||||
const [showReplies, setShowReplies] = useState(false);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState("");
|
||||
const [isSavingEdit, setIsSavingEdit] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const canModify = !!currentUserId && (comment.authorId === currentUserId || !!isAdmin);
|
||||
|
||||
const handleReply = async () => {
|
||||
if (!replyText.trim()) return;
|
||||
setIsSubmittingReply(true);
|
||||
@@ -259,6 +284,40 @@ function CommentThread({
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditText(comment.text);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editText.trim()) return;
|
||||
setIsSavingEdit(true);
|
||||
try {
|
||||
await editComment(comment.id, editText.trim());
|
||||
setIsEditing(false);
|
||||
onResolved?.();
|
||||
} catch {
|
||||
toast({ title: "Failed to update comment", variant: "destructive" });
|
||||
} finally {
|
||||
setIsSavingEdit(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("Delete this comment? This cannot be undone.")) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const isAnnotationComment = comment.text.startsWith("✏️ Annotation at frame");
|
||||
await deleteComment(comment.id);
|
||||
if (isAnnotationComment) onAnnotationDeleted?.();
|
||||
onResolved?.();
|
||||
} catch {
|
||||
toast({ title: "Failed to delete comment", variant: "destructive" });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -292,10 +351,53 @@ function CommentThread({
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{formatRelativeDate(comment.createdAt)}
|
||||
</span>
|
||||
{canModify && !isEditing && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<button
|
||||
className="text-muted-foreground/50 hover:text-foreground transition-colors p-0.5 rounded"
|
||||
onClick={handleStartEdit}
|
||||
title="Edit comment"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
className="text-muted-foreground/50 hover:text-red-400 transition-colors p-0.5 rounded"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
title="Delete comment"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap">
|
||||
{comment.text}
|
||||
</p>
|
||||
{isEditing ? (
|
||||
<div className="mt-1.5 space-y-1.5">
|
||||
<Textarea
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); handleSaveEdit(); }
|
||||
if (e.key === "Escape") { e.preventDefault(); setIsEditing(false); }
|
||||
}}
|
||||
className="min-h-[60px] text-sm bg-background/50"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-6 text-xs gap-1" onClick={handleSaveEdit} disabled={isSavingEdit || !editText.trim()}>
|
||||
<Check className="h-3 w-3" />
|
||||
{isSavingEdit ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
<button className="text-xs text-muted-foreground hover:text-foreground" onClick={() => setIsEditing(false)}>
|
||||
<X className="h-3 w-3 inline mr-0.5" />Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap">
|
||||
{comment.text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user