| // src/components/authoring/CommentReviewPanel.tsx | |
| import { useState } from "react"; | |
| import { MessageSquare, Check, X, Plus, Eye, EyeOff, User, Clock } from "lucide-react"; | |
| interface Comment { | |
| id: string; | |
| author: string; | |
| body: string; | |
| createdAt: string; | |
| status: "open" | "resolved"; | |
| selection?: string; | |
| replies: CommentReply[]; | |
| } | |
| interface CommentReply { | |
| id: string; | |
| author: string; | |
| body: string; | |
| createdAt: string; | |
| } | |
| interface TrackedChange { | |
| id: string; | |
| type: "insertion" | "deletion"; | |
| oldText?: string; | |
| newText?: string; | |
| author: string; | |
| accepted: boolean | null; | |
| } | |
| interface CommentReviewPanelProps { | |
| comments: Comment[]; | |
| changes: TrackedChange[]; | |
| reviewMode: string; | |
| setReviewMode: (m: string) => void; | |
| activeCommentId: string | null; | |
| setActiveCommentId: (id: string | null) => void; | |
| showChanges: boolean; | |
| setShowChanges: (v: boolean) => void; | |
| addComment: (body: string) => void; | |
| addReply: (commentId: string, body: string) => void; | |
| resolveComment: (id: string) => void; | |
| reopenComment: (id: string) => void; | |
| acceptChange: (id: string) => void; | |
| rejectChange: (id: string) => void; | |
| acceptAllChanges: () => void; | |
| rejectAllChanges: () => void; | |
| openCount: number; | |
| pendingChanges: number; | |
| } | |
| export function CommentReviewPanel({ | |
| comments, | |
| changes, | |
| reviewMode: _reviewMode, // unused | |
| setReviewMode: _setReviewMode, // unused | |
| activeCommentId, | |
| setActiveCommentId: _setActiveCommentId, // unused | |
| showChanges, | |
| setShowChanges, | |
| addComment, | |
| addReply, | |
| resolveComment, | |
| reopenComment, | |
| acceptChange, | |
| rejectChange, | |
| acceptAllChanges, | |
| rejectAllChanges, | |
| openCount, | |
| pendingChanges, | |
| }: CommentReviewPanelProps) { | |
| const [newComment, setNewComment] = useState(""); | |
| const [replyText, setReplyText] = useState(""); | |
| return ( | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 h-full"> | |
| {/* Comments section */} | |
| <div className="space-y-2 overflow-y-auto"> | |
| <div className="flex items-center justify-between"> | |
| <h4 className="text-sm font-semibold flex items-center gap-2"> | |
| <MessageSquare className="w-4 h-4 text-blue-500" /> Comments ({comments.length}) | |
| {openCount > 0 && <span className="text-[10px] text-blue-500">({openCount} open)</span>} | |
| </h4> | |
| </div> | |
| {/* New comment input */} | |
| <div className="flex gap-1.5"> | |
| <input | |
| type="text" | |
| value={newComment} | |
| onChange={(e) => setNewComment(e.target.value)} | |
| placeholder="Add a comment..." | |
| className="flex-1 text-xs px-3 py-1.5 bg-white/10 rounded-lg" | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && newComment.trim()) { | |
| addComment(newComment.trim()); | |
| setNewComment(""); | |
| } | |
| }} | |
| /> | |
| <button | |
| onClick={() => { | |
| if (newComment.trim()) { | |
| addComment(newComment.trim()); | |
| setNewComment(""); | |
| } | |
| }} | |
| disabled={!newComment.trim()} | |
| className="btn btn-primary btn-xs" | |
| title="Add comment" | |
| > | |
| <Plus className="w-3.5 h-3.5" /> | |
| </button> | |
| </div> | |
| {/* Comments list */} | |
| <div className="space-y-2"> | |
| {comments.map((comment) => ( | |
| <div | |
| key={comment.id} | |
| className={`rounded-xl border p-3 transition ${ | |
| comment.status === "resolved" | |
| ? "opacity-60 border-green-200 dark:border-green-800" | |
| : activeCommentId === comment.id | |
| ? "border-primary bg-primary/5" | |
| : "border-white/20" | |
| }`} | |
| > | |
| <div className="flex items-center justify-between mb-1"> | |
| <div className="flex items-center gap-2 text-xs text-gray-500"> | |
| <User className="w-3 h-3" /> {comment.author} | |
| <Clock className="w-3 h-3" /> {new Date(comment.createdAt).toLocaleDateString()} | |
| </div> | |
| <div className="flex gap-1"> | |
| {comment.status !== "resolved" ? ( | |
| <button | |
| onClick={() => resolveComment(comment.id)} | |
| className="p-0.5 text-green-500 hover:bg-green-50 rounded" | |
| title="Resolve comment" | |
| > | |
| <Check className="w-3.5 h-3.5" /> | |
| </button> | |
| ) : ( | |
| <button | |
| onClick={() => reopenComment(comment.id)} | |
| className="p-0.5 text-amber-500 hover:bg-amber-50 rounded" | |
| title="Reopen comment" | |
| > | |
| <X className="w-3.5 h-3.5" /> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| <p className="text-sm">{comment.body}</p> | |
| {comment.selection && ( | |
| <p className="text-xs text-gray-400 italic mt-1 bg-white/10 p-1.5 rounded"> | |
| "{comment.selection.substring(0, 80)}..." | |
| </p> | |
| )} | |
| {/* Replies */} | |
| {comment.replies.map((reply) => ( | |
| <div | |
| key={reply.id} | |
| className="ml-4 mt-2 pl-3 border-l-2 border-primary/20" | |
| > | |
| <p className="text-xs font-semibold">{reply.author}</p> | |
| <p className="text-xs">{reply.body}</p> | |
| </div> | |
| ))} | |
| {/* Reply input */} | |
| <div className="flex gap-1 mt-2"> | |
| <input | |
| type="text" | |
| value={replyText} | |
| onChange={(e) => setReplyText(e.target.value)} | |
| placeholder="Reply..." | |
| className="flex-1 text-xs px-2 py-1 bg-white/10 rounded" | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && replyText.trim()) { | |
| addReply(comment.id, replyText.trim()); | |
| setReplyText(""); | |
| } | |
| }} | |
| /> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Track changes section */} | |
| <div className="space-y-2 overflow-y-auto"> | |
| <div className="flex items-center justify-between"> | |
| <h4 className="text-sm font-semibold flex items-center gap-2"> | |
| <Eye className="w-4 h-4 text-amber-500" /> Track Changes ({pendingChanges} pending) | |
| </h4> | |
| <div className="flex gap-1"> | |
| <button | |
| onClick={() => setShowChanges(!showChanges)} | |
| className="btn btn-ghost btn-xs gap-1" | |
| title="Toggle change visibility" | |
| > | |
| {showChanges ? <EyeOff className="w-3 h-3" /> : <Eye className="w-3 h-3" />} | |
| </button> | |
| {pendingChanges > 0 && ( | |
| <> | |
| <button | |
| onClick={acceptAllChanges} | |
| className="btn btn-ghost btn-xs text-green-600" | |
| title="Accept all changes" | |
| > | |
| Accept All | |
| </button> | |
| <button | |
| onClick={rejectAllChanges} | |
| className="btn btn-ghost btn-xs text-red-500" | |
| title="Reject all changes" | |
| > | |
| Reject All | |
| </button> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| {changes.length === 0 ? ( | |
| <div className="text-center py-8 text-gray-400 text-sm"> | |
| No tracked changes yet. Switch to "Suggesting" mode to track edits. | |
| </div> | |
| ) : ( | |
| <div className="space-y-1.5"> | |
| {changes.map((change) => ( | |
| <div | |
| key={change.id} | |
| className={`rounded-lg border p-2.5 text-sm ${ | |
| change.accepted === true | |
| ? "border-green-200 dark:border-green-800 bg-green-50/20" | |
| : change.accepted === false | |
| ? "border-red-200 dark:border-red-800 bg-red-50/20 opacity-60" | |
| : "border-white/20" | |
| }`} | |
| > | |
| <div className="flex items-center justify-between mb-1"> | |
| <span className="text-[9px] px-1.5 py-0.5 rounded-full bg-white/10 capitalize"> | |
| {change.type} | |
| </span> | |
| <span className="text-[9px] text-gray-400">{change.author}</span> | |
| </div> | |
| {change.type === "deletion" && change.oldText && ( | |
| <p className="text-red-600 dark:text-red-400 line-through"> | |
| {change.oldText.substring(0, 100)} | |
| </p> | |
| )} | |
| {change.type === "insertion" && change.newText && ( | |
| <p className="text-green-600 dark:text-green-400"> | |
| {change.newText.substring(0, 100)} | |
| </p> | |
| )} | |
| {change.accepted === null && ( | |
| <div className="flex gap-1 mt-1.5"> | |
| <button | |
| onClick={() => acceptChange(change.id)} | |
| className="btn btn-ghost btn-xs text-green-600" | |
| title="Accept change" | |
| > | |
| <Check className="w-3 h-3" /> | |
| </button> | |
| <button | |
| onClick={() => rejectChange(change.id)} | |
| className="btn btn-ghost btn-xs text-red-500" | |
| title="Reject change" | |
| > | |
| <X className="w-3 h-3" /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 10 kB
- Xet hash:
- fd8731b652f3bba41805f94fc9a4c806519a5ffeb303b0fe0b74ee0d6f38dedc
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.