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