| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415 |
- 'use client';
- import { useState } from 'react';
- import { Comment } from '@/lib/api';
- import { Avatar } from '@/components/ui/avatar';
- import { Button } from '@/components/ui/button';
- interface Props {
- comments: Comment[];
- currentUserId: string;
- currentUserRole?: string;
- isProjectAdmin: boolean;
- currentTime: number;
- pendingAnnotation: unknown;
- onAddComment: (data: { content: string; timestamp?: number; annotation?: unknown; parentId?: string }) => Promise<void>;
- onResolve: (commentId: string, action: 'approve' | 'reject') => Promise<void>;
- onRequestResolve: (commentId: string) => Promise<void>;
- onDelete: (commentId: string) => Promise<void>;
- onCommentClick: (comment: Comment) => void;
- }
- export function CommentPanel({
- comments,
- currentUserId,
- currentUserRole,
- isProjectAdmin,
- currentTime,
- pendingAnnotation,
- onAddComment,
- onResolve,
- onRequestResolve,
- onDelete,
- onCommentClick,
- }: Props) {
- const [newComment, setNewComment] = useState('');
- const [submitting, setSubmitting] = useState(false);
- const [replyTo, setReplyTo] = useState<Comment | null>(null);
- const [replyText, setReplyText] = useState('');
- const [showResolved, setShowResolved] = useState(false);
- const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
- const visibleComments = comments.filter(c => c.resolveStatus !== 'RESOLVED' || showResolved);
- const timestampedComments = visibleComments.filter(c => c.timestamp != null);
- const generalComments = visibleComments.filter(c => c.timestamp == null);
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!newComment.trim()) return;
- setSubmitting(true);
- try {
- await onAddComment({
- content: newComment.trim(),
- timestamp: currentTime > 0 ? currentTime : undefined,
- annotation: pendingAnnotation ?? undefined,
- });
- setNewComment('');
- } finally {
- setSubmitting(false);
- }
- };
- const handleReply = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!replyText.trim() || !replyTo) return;
- setSubmitting(true);
- try {
- await onAddComment({
- content: replyText.trim(),
- parentId: replyTo.id,
- });
- setReplyText('');
- setReplyTo(null);
- } finally {
- setSubmitting(false);
- }
- };
- const openCount = comments.filter(c => c.resolveStatus !== 'RESOLVED').length;
- const pendingCount = comments.filter(c => c.resolveStatus === 'PENDING_APPROVAL').length;
- return (
- <div className="flex flex-col h-full">
- {/* Header */}
- <div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
- <h2 className="font-semibold text-gray-900">
- Comments
- <span className="ml-2 text-xs text-gray-400 font-normal">
- {openCount} open
- {pendingCount > 0 && <span className="ml-1 text-amber-500">({pendingCount} pending)</span>}
- </span>
- </h2>
- <button
- onClick={() => setShowResolved(v => !v)}
- className="text-xs text-gray-500 hover:text-gray-700"
- >
- {showResolved ? 'Hide resolved' : 'Show resolved'}
- </button>
- </div>
- {/* Comment list */}
- <div className="flex-1 overflow-y-auto">
- {visibleComments.length === 0 && (
- <div className="text-center py-12 text-gray-400">
- <svg className="w-10 h-10 mx-auto mb-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
- </svg>
- <p className="text-sm">No comments yet</p>
- <p className="text-xs mt-1">Be the first to leave feedback</p>
- </div>
- )}
- {/* Timestamped comments */}
- {timestampedComments.length > 0 && (
- <div className="border-b border-gray-100">
- <div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wide">
- Timeline
- </div>
- {timestampedComments.map(comment => (
- <CommentItem
- key={comment.id}
- comment={comment}
- currentUserId={currentUserId}
- canComment={canComment}
- isProjectAdmin={isProjectAdmin}
- onReply={() => setReplyTo(comment)}
- onResolve={(action) => onResolve(comment.id, action)}
- onRequestResolve={() => onRequestResolve(comment.id)}
- onDelete={() => onDelete(comment.id)}
- onTimestampClick={() => onCommentClick(comment)}
- />
- ))}
- </div>
- )}
- {/* General comments */}
- {generalComments.length > 0 && (
- <div>
- <div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wide">
- General
- </div>
- {generalComments.map(comment => (
- <CommentItem
- key={comment.id}
- comment={comment}
- currentUserId={currentUserId}
- canComment={canComment}
- isProjectAdmin={isProjectAdmin}
- onReply={() => setReplyTo(comment)}
- onResolve={(action) => onResolve(comment.id, action)}
- onRequestResolve={() => onRequestResolve(comment.id)}
- onDelete={() => onDelete(comment.id)}
- />
- ))}
- </div>
- )}
- </div>
- {/* Reply form */}
- {replyTo && (
- <div className="border-t border-gray-200 p-3 bg-blue-50">
- <p className="text-xs text-blue-600 mb-2 flex items-center gap-1">
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
- </svg>
- Replying to {replyTo.user?.name ?? 'Unknown'}
- <button
- onClick={() => setReplyTo(null)}
- className="ml-auto text-gray-400 hover:text-gray-600"
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- </button>
- </p>
- <form onSubmit={handleReply} className="flex gap-2">
- <textarea
- value={replyText}
- onChange={e => setReplyText(e.target.value)}
- placeholder="Write a reply..."
- className="flex-1 text-sm rounded-lg border border-blue-200 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
- rows={2}
- autoFocus
- />
- <div className="flex flex-col gap-1">
- <Button type="submit" size="sm" loading={submitting} disabled={!replyText.trim()}>Send</Button>
- <Button type="button" variant="ghost" size="sm" onClick={() => setReplyTo(null)}>Cancel</Button>
- </div>
- </form>
- </div>
- )}
- {/* New comment form */}
- <div className="border-t border-gray-200 p-3">
- {currentTime > 0 && (
- <div className="text-xs text-gray-400 mb-2 flex items-center gap-1">
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- Comment at {formatTime(currentTime)}
- {!!pendingAnnotation && (
- <span className="ml-1 text-blue-500">(with annotation)</span>
- )}
- </div>
- )}
- <form onSubmit={handleSubmit} className="flex gap-2">
- <textarea
- value={newComment}
- onChange={e => setNewComment(e.target.value)}
- placeholder={canComment ? 'Add a comment...' : 'Viewers cannot comment'}
- disabled={!canComment}
- className="flex-1 text-sm rounded-lg border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none disabled:bg-gray-50 disabled:text-gray-400"
- rows={2}
- />
- <Button type="submit" size="sm" loading={submitting} disabled={!newComment.trim() || !canComment}>
- Send
- </Button>
- </form>
- </div>
- </div>
- );
- }
- function CommentItem({
- comment,
- currentUserId,
- canComment,
- isProjectAdmin,
- onReply,
- onResolve,
- onRequestResolve,
- onDelete,
- onTimestampClick,
- }: {
- comment: Comment;
- currentUserId: string;
- canComment: boolean | undefined;
- isProjectAdmin: boolean;
- onReply: () => void;
- onResolve: (action: 'approve' | 'reject') => void;
- onRequestResolve: () => void;
- onDelete: () => void;
- onTimestampClick?: () => void;
- }) {
- const isOwner = comment.userId === currentUserId;
- const isCommentAuthor = comment.userId === currentUserId;
- const isResolved = comment.resolveStatus === 'RESOLVED';
- const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
- const canApprove = isCommentAuthor || isProjectAdmin;
- const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
- const canReopen = isResolved && canApprove;
- const itemClass = `px-4 py-3 hover:bg-gray-50 transition-colors ${isResolved ? 'opacity-60' : ''}`;
- return (
- <div className={itemClass}>
- <div className="flex items-start gap-2.5">
- <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="sm" />
- <div className="flex-1 min-w-0">
- {/* Meta */}
- <div className="flex items-center gap-2 flex-wrap">
- <span className="text-sm font-medium text-gray-900">{comment.user?.name ?? 'Unknown'}</span>
- {comment.timestamp != null && onTimestampClick && (
- <button
- onClick={onTimestampClick}
- className="text-xs text-blue-600 bg-blue-50 hover:bg-blue-100 px-1.5 py-0.5 rounded font-mono transition-colors"
- >
- {formatTime(comment.timestamp)}
- </button>
- )}
- {isPending && (
- <span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded flex items-center gap-1">
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- Pending approval
- {comment.requestedBy && <span className="text-amber-600">by {comment.requestedBy.name}</span>}
- </span>
- )}
- {isResolved && (
- <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded flex items-center gap-1">
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Approved
- {comment.resolvedBy && <span className="text-green-600">by {comment.resolvedBy.name}</span>}
- </span>
- )}
- </div>
- {/* Annotation preview */}
- {comment.annotations && comment.annotations.length > 0 && (
- <div className="mt-1 text-xs text-gray-500 italic flex items-center gap-1">
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
- </svg>
- Has drawing annotation
- </div>
- )}
- <p className="text-sm text-gray-700 mt-0.5">{comment.content}</p>
- {/* Replies */}
- {comment.replies && comment.replies.length > 0 && (
- <div className="mt-2 ml-2 border-l-2 border-gray-100 pl-3 space-y-2">
- {comment.replies.map(reply => (
- <div key={reply.id} className="flex items-start gap-2">
- <Avatar name={reply.user?.name ?? 'U'} src={reply.user?.avatarUrl} size="sm" />
- <div>
- <span className="text-xs font-medium text-gray-800">{reply.user?.name ?? 'Unknown'}</span>
- <p className="text-sm text-gray-600 mt-0.5">{reply.content}</p>
- </div>
- </div>
- ))}
- </div>
- )}
- {/* Actions */}
- <div className="flex items-center gap-3 mt-2">
- <button onClick={onReply} className="text-xs text-gray-400 hover:text-gray-600 transition-colors">
- Reply
- </button>
- {/* Request resolve */}
- {!isResolved && !isPending && (
- canRequest ? (
- <button
- onClick={onRequestResolve}
- className="text-xs text-indigo-600 hover:text-indigo-700 transition-colors flex items-center gap-1"
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Request resolve
- </button>
- ) : (
- <span className="text-xs text-gray-300 cursor-not-allowed flex items-center gap-1" title={
- !canComment ? 'Viewers cannot request resolve'
- : isCommentAuthor ? 'Cannot resolve your own comment'
- : undefined
- }>
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Request resolve
- </span>
- )
- )}
- {/* Approve / Reject when pending */}
- {isPending && canApprove && (
- <>
- <button
- onClick={() => onResolve('approve')}
- className="text-xs text-green-600 hover:text-green-700 transition-colors flex items-center gap-1"
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
- </svg>
- Approve
- </button>
- <button
- onClick={() => onResolve('reject')}
- className="text-xs text-red-400 hover:text-red-600 transition-colors flex items-center gap-1"
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
- </svg>
- Reject
- </button>
- </>
- )}
- {/* Awaiting approval badge when pending but user can't approve */}
- {isPending && !canApprove && (
- <span className="text-xs text-amber-500 flex items-center gap-1">
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
- </svg>
- Awaiting approval
- </span>
- )}
- {/* Reopen when resolved */}
- {canReopen && (
- <button
- onClick={() => onResolve('reject')}
- className="text-xs text-green-600 hover:text-green-700 transition-colors flex items-center gap-1"
- >
- <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
- <path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
- </svg>
- Reopen
- </button>
- )}
- {isOwner && (
- <button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-600 transition-colors ml-auto">
- Delete
- </button>
- )}
- </div>
- </div>
- </div>
- </div>
- );
- }
- function formatTime(s: number): string {
- if (!s || isNaN(s)) return '0:00';
- const m = Math.floor(s / 60);
- const sec = Math.floor(s % 60);
- return `${m}:${sec.toString().padStart(2, '0')}`;
- }
|