395 lines
13 KiB
TypeScript
395 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useRef, useEffect } from "react";
|
|
import { useReviewStore } from "@/hooks/use-review-player";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { cn } from "@/lib/utils";
|
|
import { getInitials, formatRelativeDate } from "@/lib/utils";
|
|
import { frameToTimecode } from "@/lib/frame-utils";
|
|
import {
|
|
CheckCircle2,
|
|
Circle,
|
|
MessageSquare,
|
|
Send,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Filter,
|
|
} from "lucide-react";
|
|
import { addComment, addReply, resolveComment } from "@/actions/comments";
|
|
import { useToast } from "@/components/ui/use-toast";
|
|
import type { CommentWithReplies } from "@/types";
|
|
|
|
interface CommentPanelProps {
|
|
versionId: string;
|
|
fps: number;
|
|
comments: CommentWithReplies[];
|
|
onCommentsChange?: () => void;
|
|
pendingFrame?: number | null;
|
|
onPendingFrameCleared?: () => void;
|
|
onSeekToFrame?: (frame: number) => void;
|
|
}
|
|
|
|
export function CommentPanel({
|
|
versionId,
|
|
fps,
|
|
comments,
|
|
onCommentsChange,
|
|
pendingFrame,
|
|
onPendingFrameCleared,
|
|
onSeekToFrame,
|
|
}: CommentPanelProps) {
|
|
const { currentFrame, setCurrentFrame } = useReviewStore();
|
|
const [filterResolved, setFilterResolved] = useState<"all" | "unresolved" | "resolved">("all");
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
const [commentText, setCommentText] = useState("");
|
|
const [activeCommentFrame, setActiveCommentFrame] = useState<number | null>(null);
|
|
const { toast } = useToast();
|
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
// Handle pending frame from "Add Comment" button click
|
|
useEffect(() => {
|
|
if (pendingFrame !== null && pendingFrame !== undefined) {
|
|
setActiveCommentFrame(pendingFrame);
|
|
setTimeout(() => inputRef.current?.focus(), 50);
|
|
onPendingFrameCleared?.();
|
|
}
|
|
}, [pendingFrame, onPendingFrameCleared]);
|
|
|
|
const filteredComments = comments.filter((c) => {
|
|
if (filterResolved === "unresolved") return !c.isResolved;
|
|
if (filterResolved === "resolved") return c.isResolved;
|
|
return true;
|
|
});
|
|
|
|
const handleSubmitComment = async () => {
|
|
if (!commentText.trim() || activeCommentFrame === null) return;
|
|
setIsSubmitting(true);
|
|
try {
|
|
const timestamp = activeCommentFrame / fps;
|
|
await addComment({
|
|
versionId,
|
|
frameNumber: activeCommentFrame,
|
|
timestamp,
|
|
text: commentText.trim(),
|
|
});
|
|
setCommentText("");
|
|
setActiveCommentFrame(null);
|
|
onCommentsChange?.();
|
|
toast({ title: "Comment added", variant: "default" });
|
|
} catch (err) {
|
|
toast({ title: "Failed to add comment", variant: "destructive" });
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
handleSubmitComment();
|
|
}
|
|
if (e.key === "Escape") {
|
|
setActiveCommentFrame(null);
|
|
setCommentText("");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col h-full bg-card border-l border-border">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">
|
|
Comments
|
|
{comments.length > 0 && (
|
|
<span className="ml-1.5 text-xs text-muted-foreground">
|
|
({comments.length})
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="flex gap-1">
|
|
{(["all", "unresolved", "resolved"] as const).map((f) => (
|
|
<button
|
|
key={f}
|
|
onClick={() => setFilterResolved(f)}
|
|
className={cn(
|
|
"px-2 py-0.5 rounded text-xs transition-colors",
|
|
filterResolved === f
|
|
? "bg-primary/20 text-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
{f === "all" ? "All" : f === "unresolved" ? "Open" : "Resolved"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Comment Input */}
|
|
{activeCommentFrame !== null ? (
|
|
<div className="shrink-0 border-b border-border p-3 bg-primary/5">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<Badge variant="pending" className="text-xs font-mono">
|
|
Frame {activeCommentFrame}
|
|
</Badge>
|
|
<span className="text-xs text-muted-foreground">
|
|
{frameToTimecode(activeCommentFrame, fps)}
|
|
</span>
|
|
</div>
|
|
<Textarea
|
|
ref={inputRef}
|
|
value={commentText}
|
|
onChange={(e) => setCommentText(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="Add your feedback... (Ctrl+Enter to submit)"
|
|
className="min-h-[80px] text-sm bg-background/50"
|
|
autoFocus
|
|
/>
|
|
<div className="flex items-center justify-between mt-2">
|
|
<button
|
|
className="text-xs text-muted-foreground hover:text-foreground"
|
|
onClick={() => { setActiveCommentFrame(null); setCommentText(""); }}
|
|
>
|
|
Cancel (Esc)
|
|
</button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSubmitComment}
|
|
disabled={isSubmitting || !commentText.trim()}
|
|
>
|
|
<Send className="h-3 w-3 mr-1" />
|
|
{isSubmitting ? "Posting..." : "Post"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="shrink-0 px-3 py-2 border-b border-border">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full text-xs"
|
|
onClick={() => setActiveCommentFrame(currentFrame)}
|
|
>
|
|
<MessageSquare className="h-3 w-3 mr-2" />
|
|
Comment at frame {currentFrame}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Comment List */}
|
|
<ScrollArea className="flex-1">
|
|
<div className="p-3 space-y-3">
|
|
{filteredComments.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
|
<MessageSquare className="h-8 w-8 mb-3 opacity-30" />
|
|
<p className="text-sm">No comments yet</p>
|
|
<p className="text-xs mt-1">
|
|
Pause the video and click "Add Comment"
|
|
</p>
|
|
</div>
|
|
) : (
|
|
filteredComments.map((comment) => (
|
|
<CommentThread
|
|
key={comment.id}
|
|
comment={comment}
|
|
fps={fps}
|
|
isActive={comment.frameNumber === currentFrame}
|
|
onJumpToFrame={(frame) => {
|
|
setCurrentFrame(frame);
|
|
onSeekToFrame?.(frame);
|
|
}}
|
|
onResolved={onCommentsChange}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Individual Comment Thread ─────────────────────────────────────────────────
|
|
|
|
interface CommentThreadProps {
|
|
comment: CommentWithReplies;
|
|
fps: number;
|
|
isActive: boolean;
|
|
onJumpToFrame: (frame: number) => void;
|
|
onResolved?: () => void;
|
|
}
|
|
|
|
function CommentThread({
|
|
comment,
|
|
fps,
|
|
isActive,
|
|
onJumpToFrame,
|
|
onResolved,
|
|
}: CommentThreadProps) {
|
|
const [showReplies, setShowReplies] = useState(false);
|
|
const [replyText, setReplyText] = useState("");
|
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
|
const { toast } = useToast();
|
|
|
|
const handleReply = async () => {
|
|
if (!replyText.trim()) return;
|
|
setIsSubmittingReply(true);
|
|
try {
|
|
await addReply(comment.id, replyText.trim());
|
|
setReplyText("");
|
|
onResolved?.();
|
|
} catch {
|
|
toast({ title: "Failed to post reply", variant: "destructive" });
|
|
} finally {
|
|
setIsSubmittingReply(false);
|
|
}
|
|
};
|
|
|
|
const handleToggleResolve = async () => {
|
|
try {
|
|
await resolveComment(comment.id, !comment.isResolved);
|
|
onResolved?.();
|
|
} catch {
|
|
toast({ title: "Failed to update comment", variant: "destructive" });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
"rounded-lg border border-border/60 overflow-hidden transition-all",
|
|
isActive && "border-primary/40 bg-primary/5",
|
|
comment.isResolved && "opacity-60"
|
|
)}
|
|
>
|
|
{/* Comment body */}
|
|
<div className="p-3">
|
|
<div className="flex items-start gap-2">
|
|
<Avatar className="h-6 w-6 shrink-0 mt-0.5">
|
|
{comment.author?.image && (
|
|
<AvatarImage src={comment.author.image} alt={comment.author.name ?? ""} />
|
|
)}
|
|
<AvatarFallback className="text-[9px]">
|
|
{getInitials(comment.author?.name ?? null)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="text-xs font-medium">
|
|
{comment.author?.name ?? comment.author?.email ?? "Deleted User"}
|
|
</span>
|
|
<button
|
|
className="font-mono text-xs text-primary/80 hover:text-primary hover:underline"
|
|
onClick={() => onJumpToFrame(comment.frameNumber)}
|
|
>
|
|
F{String(comment.frameNumber).padStart(4, "0")}
|
|
</button>
|
|
<span className="text-xs text-muted-foreground ml-auto">
|
|
{formatRelativeDate(comment.createdAt)}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm mt-1.5 leading-relaxed whitespace-pre-wrap">
|
|
{comment.text}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-3 mt-2 ml-8">
|
|
<button
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={handleToggleResolve}
|
|
>
|
|
{comment.isResolved ? (
|
|
<>
|
|
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
|
<span className="text-emerald-500">Resolved</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Circle className="h-3 w-3" />
|
|
<span>Resolve</span>
|
|
</>
|
|
)}
|
|
</button>
|
|
|
|
<button
|
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
onClick={() => setShowReplies(!showReplies)}
|
|
>
|
|
{comment.replies.length > 0 && (
|
|
<>
|
|
{showReplies ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
|
{comment.replies.length} {comment.replies.length === 1 ? "reply" : "replies"}
|
|
</>
|
|
)}
|
|
{comment.replies.length === 0 && "Reply"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Replies */}
|
|
{showReplies && (
|
|
<div className="border-t border-border/50 bg-background/30">
|
|
{comment.replies.map((reply) => (
|
|
<div key={reply.id} className="flex gap-2 px-3 py-2.5 border-b border-border/30 last:border-0">
|
|
<Avatar className="h-5 w-5 shrink-0 mt-0.5">
|
|
{reply.author?.image && (
|
|
<AvatarImage src={reply.author.image} />
|
|
)}
|
|
<AvatarFallback className="text-[8px]">
|
|
{getInitials(reply.author?.name ?? null)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium">
|
|
{reply.author?.name ?? reply.author?.email ?? "Deleted User"}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatRelativeDate(reply.createdAt)}
|
|
</span>
|
|
</div>
|
|
<p className="text-xs mt-0.5 text-foreground/80 whitespace-pre-wrap">
|
|
{reply.text}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Reply input */}
|
|
<div className="p-2">
|
|
<div className="flex gap-2">
|
|
<Textarea
|
|
value={replyText}
|
|
onChange={(e) => setReplyText(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
handleReply();
|
|
}
|
|
}}
|
|
placeholder="Reply..."
|
|
className="min-h-[48px] text-xs py-2 bg-background/50"
|
|
/>
|
|
<Button
|
|
size="icon-sm"
|
|
onClick={handleReply}
|
|
disabled={isSubmittingReply || !replyText.trim()}
|
|
className="self-end"
|
|
>
|
|
<Send className="h-3 w-3" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|