Initial commit

This commit is contained in:
twotalesanimation
2026-05-19 22:20:29 +02:00
commit 0fbe856dce
173 changed files with 38316 additions and 0 deletions
+394
View File
@@ -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 &quot;Add Comment&quot;
</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>
);
}