CommentPanel.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. 'use client';
  2. import { useState } from 'react';
  3. import { Comment } from '@/lib/api';
  4. import { Avatar } from '@/components/ui/avatar';
  5. import { Button } from '@/components/ui/button';
  6. interface Props {
  7. comments: Comment[];
  8. currentUserId: string;
  9. currentUserRole?: string;
  10. isProjectAdmin: boolean;
  11. currentTime: number;
  12. pendingAnnotation: unknown;
  13. onAddComment: (data: { content: string; timestamp?: number; annotation?: unknown; parentId?: string }) => Promise<void>;
  14. onResolve: (commentId: string, action: 'approve' | 'reject') => Promise<void>;
  15. onRequestResolve: (commentId: string) => Promise<void>;
  16. onDelete: (commentId: string) => Promise<void>;
  17. onCommentClick: (comment: Comment) => void;
  18. }
  19. export function CommentPanel({
  20. comments,
  21. currentUserId,
  22. currentUserRole,
  23. isProjectAdmin,
  24. currentTime,
  25. pendingAnnotation,
  26. onAddComment,
  27. onResolve,
  28. onRequestResolve,
  29. onDelete,
  30. onCommentClick,
  31. }: Props) {
  32. const [newComment, setNewComment] = useState('');
  33. const [submitting, setSubmitting] = useState(false);
  34. const [replyTo, setReplyTo] = useState<Comment | null>(null);
  35. const [replyText, setReplyText] = useState('');
  36. const [showResolved, setShowResolved] = useState(false);
  37. const canComment: boolean | undefined = !!(currentUserRole && currentUserRole !== 'VIEWER');
  38. const visibleComments = comments.filter(c => c.resolveStatus !== 'RESOLVED' || showResolved);
  39. const timestampedComments = visibleComments.filter(c => c.timestamp != null);
  40. const generalComments = visibleComments.filter(c => c.timestamp == null);
  41. const handleSubmit = async (e: React.FormEvent) => {
  42. e.preventDefault();
  43. if (!newComment.trim()) return;
  44. setSubmitting(true);
  45. try {
  46. await onAddComment({
  47. content: newComment.trim(),
  48. timestamp: currentTime > 0 ? currentTime : undefined,
  49. annotation: pendingAnnotation ?? undefined,
  50. });
  51. setNewComment('');
  52. } finally {
  53. setSubmitting(false);
  54. }
  55. };
  56. const handleReply = async (e: React.FormEvent) => {
  57. e.preventDefault();
  58. if (!replyText.trim() || !replyTo) return;
  59. setSubmitting(true);
  60. try {
  61. await onAddComment({
  62. content: replyText.trim(),
  63. parentId: replyTo.id,
  64. });
  65. setReplyText('');
  66. setReplyTo(null);
  67. } finally {
  68. setSubmitting(false);
  69. }
  70. };
  71. const openCount = comments.filter(c => c.resolveStatus !== 'RESOLVED').length;
  72. const pendingCount = comments.filter(c => c.resolveStatus === 'PENDING_APPROVAL').length;
  73. return (
  74. <div className="flex flex-col h-full">
  75. {/* Header */}
  76. <div className="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
  77. <h2 className="font-semibold text-gray-900">
  78. Comments
  79. <span className="ml-2 text-xs text-gray-400 font-normal">
  80. {openCount} open
  81. {pendingCount > 0 && <span className="ml-1 text-amber-500">({pendingCount} pending)</span>}
  82. </span>
  83. </h2>
  84. <button
  85. onClick={() => setShowResolved(v => !v)}
  86. className="text-xs text-gray-500 hover:text-gray-700"
  87. >
  88. {showResolved ? 'Hide resolved' : 'Show resolved'}
  89. </button>
  90. </div>
  91. {/* Comment list */}
  92. <div className="flex-1 overflow-y-auto">
  93. {visibleComments.length === 0 && (
  94. <div className="text-center py-12 text-gray-400">
  95. <svg className="w-10 h-10 mx-auto mb-3 opacity-40" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  96. <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" />
  97. </svg>
  98. <p className="text-sm">No comments yet</p>
  99. <p className="text-xs mt-1">Be the first to leave feedback</p>
  100. </div>
  101. )}
  102. {/* Timestamped comments */}
  103. {timestampedComments.length > 0 && (
  104. <div className="border-b border-gray-100">
  105. <div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wide">
  106. Timeline
  107. </div>
  108. {timestampedComments.map(comment => (
  109. <CommentItem
  110. key={comment.id}
  111. comment={comment}
  112. currentUserId={currentUserId}
  113. canComment={canComment}
  114. isProjectAdmin={isProjectAdmin}
  115. onReply={() => setReplyTo(comment)}
  116. onResolve={(action) => onResolve(comment.id, action)}
  117. onRequestResolve={() => onRequestResolve(comment.id)}
  118. onDelete={() => onDelete(comment.id)}
  119. onTimestampClick={() => onCommentClick(comment)}
  120. />
  121. ))}
  122. </div>
  123. )}
  124. {/* General comments */}
  125. {generalComments.length > 0 && (
  126. <div>
  127. <div className="px-3 py-1.5 text-xs font-medium text-gray-400 uppercase tracking-wide">
  128. General
  129. </div>
  130. {generalComments.map(comment => (
  131. <CommentItem
  132. key={comment.id}
  133. comment={comment}
  134. currentUserId={currentUserId}
  135. canComment={canComment}
  136. isProjectAdmin={isProjectAdmin}
  137. onReply={() => setReplyTo(comment)}
  138. onResolve={(action) => onResolve(comment.id, action)}
  139. onRequestResolve={() => onRequestResolve(comment.id)}
  140. onDelete={() => onDelete(comment.id)}
  141. />
  142. ))}
  143. </div>
  144. )}
  145. </div>
  146. {/* Reply form */}
  147. {replyTo && (
  148. <div className="border-t border-gray-200 p-3 bg-blue-50">
  149. <p className="text-xs text-blue-600 mb-2 flex items-center gap-1">
  150. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  151. <path strokeLinecap="round" strokeLinejoin="round" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
  152. </svg>
  153. Replying to {replyTo.user?.name ?? 'Unknown'}
  154. <button
  155. onClick={() => setReplyTo(null)}
  156. className="ml-auto text-gray-400 hover:text-gray-600"
  157. >
  158. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  159. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  160. </svg>
  161. </button>
  162. </p>
  163. <form onSubmit={handleReply} className="flex gap-2">
  164. <textarea
  165. value={replyText}
  166. onChange={e => setReplyText(e.target.value)}
  167. placeholder="Write a reply..."
  168. 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"
  169. rows={2}
  170. autoFocus
  171. />
  172. <div className="flex flex-col gap-1">
  173. <Button type="submit" size="sm" loading={submitting} disabled={!replyText.trim()}>Send</Button>
  174. <Button type="button" variant="ghost" size="sm" onClick={() => setReplyTo(null)}>Cancel</Button>
  175. </div>
  176. </form>
  177. </div>
  178. )}
  179. {/* New comment form */}
  180. <div className="border-t border-gray-200 p-3">
  181. {currentTime > 0 && (
  182. <div className="text-xs text-gray-400 mb-2 flex items-center gap-1">
  183. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
  184. <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  185. </svg>
  186. Comment at {formatTime(currentTime)}
  187. {!!pendingAnnotation && (
  188. <span className="ml-1 text-blue-500">(with annotation)</span>
  189. )}
  190. </div>
  191. )}
  192. <form onSubmit={handleSubmit} className="flex gap-2">
  193. <textarea
  194. value={newComment}
  195. onChange={e => setNewComment(e.target.value)}
  196. placeholder={canComment ? 'Add a comment...' : 'Viewers cannot comment'}
  197. disabled={!canComment}
  198. 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"
  199. rows={2}
  200. />
  201. <Button type="submit" size="sm" loading={submitting} disabled={!newComment.trim() || !canComment}>
  202. Send
  203. </Button>
  204. </form>
  205. </div>
  206. </div>
  207. );
  208. }
  209. function CommentItem({
  210. comment,
  211. currentUserId,
  212. canComment,
  213. isProjectAdmin,
  214. onReply,
  215. onResolve,
  216. onRequestResolve,
  217. onDelete,
  218. onTimestampClick,
  219. }: {
  220. comment: Comment;
  221. currentUserId: string;
  222. canComment: boolean | undefined;
  223. isProjectAdmin: boolean;
  224. onReply: () => void;
  225. onResolve: (action: 'approve' | 'reject') => void;
  226. onRequestResolve: () => void;
  227. onDelete: () => void;
  228. onTimestampClick?: () => void;
  229. }) {
  230. const isOwner = comment.userId === currentUserId;
  231. const isCommentAuthor = comment.userId === currentUserId;
  232. const isResolved = comment.resolveStatus === 'RESOLVED';
  233. const isPending = comment.resolveStatus === 'PENDING_APPROVAL';
  234. const canApprove = isCommentAuthor || isProjectAdmin;
  235. const canRequest = canComment && !isResolved && !isPending && !isCommentAuthor;
  236. const canReopen = isResolved && canApprove;
  237. const itemClass = `px-4 py-3 hover:bg-gray-50 transition-colors ${isResolved ? 'opacity-60' : ''}`;
  238. return (
  239. <div className={itemClass}>
  240. <div className="flex items-start gap-2.5">
  241. <Avatar name={comment.user?.name ?? 'U'} src={comment.user?.avatarUrl} size="sm" />
  242. <div className="flex-1 min-w-0">
  243. {/* Meta */}
  244. <div className="flex items-center gap-2 flex-wrap">
  245. <span className="text-sm font-medium text-gray-900">{comment.user?.name ?? 'Unknown'}</span>
  246. {comment.timestamp != null && onTimestampClick && (
  247. <button
  248. onClick={onTimestampClick}
  249. className="text-xs text-blue-600 bg-blue-50 hover:bg-blue-100 px-1.5 py-0.5 rounded font-mono transition-colors"
  250. >
  251. {formatTime(comment.timestamp)}
  252. </button>
  253. )}
  254. {isPending && (
  255. <span className="text-xs bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded flex items-center gap-1">
  256. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  257. <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  258. </svg>
  259. Pending approval
  260. {comment.requestedBy && <span className="text-amber-600">by {comment.requestedBy.name}</span>}
  261. </span>
  262. )}
  263. {isResolved && (
  264. <span className="text-xs bg-green-100 text-green-700 px-1.5 py-0.5 rounded flex items-center gap-1">
  265. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  266. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  267. </svg>
  268. Approved
  269. {comment.resolvedBy && <span className="text-green-600">by {comment.resolvedBy.name}</span>}
  270. </span>
  271. )}
  272. </div>
  273. {/* Annotation preview */}
  274. {comment.annotations && comment.annotations.length > 0 && (
  275. <div className="mt-1 text-xs text-gray-500 italic flex items-center gap-1">
  276. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  277. <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" />
  278. </svg>
  279. Has drawing annotation
  280. </div>
  281. )}
  282. <p className="text-sm text-gray-700 mt-0.5">{comment.content}</p>
  283. {/* Replies */}
  284. {comment.replies && comment.replies.length > 0 && (
  285. <div className="mt-2 ml-2 border-l-2 border-gray-100 pl-3 space-y-2">
  286. {comment.replies.map(reply => (
  287. <div key={reply.id} className="flex items-start gap-2">
  288. <Avatar name={reply.user?.name ?? 'U'} src={reply.user?.avatarUrl} size="sm" />
  289. <div>
  290. <span className="text-xs font-medium text-gray-800">{reply.user?.name ?? 'Unknown'}</span>
  291. <p className="text-sm text-gray-600 mt-0.5">{reply.content}</p>
  292. </div>
  293. </div>
  294. ))}
  295. </div>
  296. )}
  297. {/* Actions */}
  298. <div className="flex items-center gap-3 mt-2">
  299. <button onClick={onReply} className="text-xs text-gray-400 hover:text-gray-600 transition-colors">
  300. Reply
  301. </button>
  302. {/* Request resolve */}
  303. {!isResolved && !isPending && (
  304. canRequest ? (
  305. <button
  306. onClick={onRequestResolve}
  307. className="text-xs text-indigo-600 hover:text-indigo-700 transition-colors flex items-center gap-1"
  308. >
  309. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  310. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  311. </svg>
  312. Request resolve
  313. </button>
  314. ) : (
  315. <span className="text-xs text-gray-300 cursor-not-allowed flex items-center gap-1" title={
  316. !canComment ? 'Viewers cannot request resolve'
  317. : isCommentAuthor ? 'Cannot resolve your own comment'
  318. : undefined
  319. }>
  320. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  321. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  322. </svg>
  323. Request resolve
  324. </span>
  325. )
  326. )}
  327. {/* Approve / Reject when pending */}
  328. {isPending && canApprove && (
  329. <>
  330. <button
  331. onClick={() => onResolve('approve')}
  332. className="text-xs text-green-600 hover:text-green-700 transition-colors flex items-center gap-1"
  333. >
  334. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  335. <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
  336. </svg>
  337. Approve
  338. </button>
  339. <button
  340. onClick={() => onResolve('reject')}
  341. className="text-xs text-red-400 hover:text-red-600 transition-colors flex items-center gap-1"
  342. >
  343. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  344. <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
  345. </svg>
  346. Reject
  347. </button>
  348. </>
  349. )}
  350. {/* Awaiting approval badge when pending but user can't approve */}
  351. {isPending && !canApprove && (
  352. <span className="text-xs text-amber-500 flex items-center gap-1">
  353. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  354. <path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
  355. </svg>
  356. Awaiting approval
  357. </span>
  358. )}
  359. {/* Reopen when resolved */}
  360. {canReopen && (
  361. <button
  362. onClick={() => onResolve('reject')}
  363. className="text-xs text-green-600 hover:text-green-700 transition-colors flex items-center gap-1"
  364. >
  365. <svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
  366. <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" />
  367. </svg>
  368. Reopen
  369. </button>
  370. )}
  371. {isOwner && (
  372. <button onClick={onDelete} className="text-xs text-gray-400 hover:text-red-600 transition-colors ml-auto">
  373. Delete
  374. </button>
  375. )}
  376. </div>
  377. </div>
  378. </div>
  379. </div>
  380. );
  381. }
  382. function formatTime(s: number): string {
  383. if (!s || isNaN(s)) return '0:00';
  384. const m = Math.floor(s / 60);
  385. const sec = Math.floor(s % 60);
  386. return `${m}:${sec.toString().padStart(2, '0')}`;
  387. }