| // src/components/async/ThreadedFigureComment.tsx | |
| import { useState, useRef, useCallback, useEffect } from "react"; | |
| import { MessageCircle, Plus, X, Send, Clock, User, Pin } from "lucide-react"; | |
| interface FigureComment { | |
| id: string; | |
| x: number; // % position on the image | |
| y: number; // % position on the image | |
| body: string; | |
| author: string; | |
| timestamp: string; | |
| replies: FigureComment[]; | |
| } | |
| interface ThreadedFigureCommentProps { | |
| imageUrl: string; | |
| imageAlt: string; | |
| comments: FigureComment[]; | |
| onAddComment: (x: number, y: number, body: string) => void; | |
| onReply: (commentId: string, body: string) => void; | |
| } | |
| export function ThreadedFigureComment({ imageUrl, imageAlt, comments, onAddComment, onReply }: ThreadedFigureCommentProps) { | |
| const [activeCommentId, setActiveCommentId] = useState<string | null>(null); | |
| const [newCommentMode, setNewCommentMode] = useState(false); | |
| const [newCommentPos, setNewCommentPos] = useState<{ x: number; y: number } | null>(null); | |
| const [input, setInput] = useState(""); | |
| const [replyInput, setReplyInput] = useState(""); | |
| const imageRef = useRef<HTMLDivElement>(null); | |
| const handleImageClick = (e: React.MouseEvent<HTMLDivElement>) => { | |
| if (!newCommentMode) return; | |
| const rect = imageRef.current?.getBoundingClientRect(); | |
| if (!rect) return; | |
| const x = ((e.clientX - rect.left) / rect.width) * 100; | |
| const y = ((e.clientY - rect.top) / rect.height) * 100; | |
| setNewCommentPos({ x, y }); | |
| }; | |
| const handleAddComment = () => { | |
| if (!input.trim() || !newCommentPos) return; | |
| onAddComment(newCommentPos.x, newCommentPos.y, input.trim()); | |
| setInput(""); | |
| setNewCommentPos(null); | |
| setNewCommentMode(false); | |
| }; | |
| return ( | |
| <div className="relative"> | |
| {/* Toggle comment mode */} | |
| <div className="flex items-center justify-between mb-2"> | |
| <h4 className="text-sm font-semibold flex items-center gap-2"> | |
| <MessageCircle className="w-4 h-4" /> Figure Discussion ({comments.length}) | |
| </h4> | |
| <button | |
| onClick={() => setNewCommentMode(!newCommentMode)} | |
| className={`text-xs px-3 py-1 rounded-full transition ${ | |
| newCommentMode ? "bg-primary text-white" : "bg-gray-100 dark:bg-gray-700 text-gray-600" | |
| }`} | |
| > | |
| {newCommentMode ? "Done" : "Add Comment"} | |
| </button> | |
| </div> | |
| {/* Image with pins */} | |
| <div | |
| ref={imageRef} | |
| className={`relative rounded-xl overflow-hidden border border-gray-200 dark:border-gray-700 ${newCommentMode ? "cursor-crosshair" : ""}`} | |
| onClick={handleImageClick} | |
| > | |
| <img src={imageUrl} alt={imageAlt} className="w-full" /> | |
| {/* Comment pins */} | |
| {comments.map(comment => ( | |
| <button | |
| key={comment.id} | |
| onClick={(e) => { e.stopPropagation(); setActiveCommentId(activeCommentId === comment.id ? null : comment.id); }} | |
| className="absolute w-6 h-6 -ml-3 -mt-3 rounded-full bg-primary text-white flex items-center justify-center text-xs font-bold shadow-lg hover:scale-110 transition z-10" | |
| style={{ left: `${comment.x}%`, top: `${comment.y}%` }} | |
| title={comment.body.substring(0, 50)} | |
| > | |
| <Pin className="w-3.5 h-3.5" /> | |
| </button> | |
| ))} | |
| {/* New comment marker */} | |
| {newCommentPos && ( | |
| <div className="absolute w-6 h-6 -ml-3 -mt-3 rounded-full bg-red-500 text-white flex items-center justify-center text-xs font-bold shadow-lg animate-pulse z-20" | |
| style={{ left: `${newCommentPos.x}%`, top: `${newCommentPos.y}%` }}> | |
| <Plus className="w-3.5 h-3.5" /> | |
| </div> | |
| )} | |
| </div> | |
| {/* New comment input */} | |
| {newCommentPos && ( | |
| <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded-xl border flex gap-2"> | |
| <input | |
| type="text" | |
| value={input} | |
| onChange={e => setInput(e.target.value)} | |
| placeholder="Add a comment at this position..." | |
| className="flex-1 text-sm px-3 py-1.5 border rounded-lg dark:bg-gray-700" | |
| autoFocus | |
| onKeyDown={e => e.key === "Enter" && handleAddComment()} | |
| /> | |
| <button onClick={handleAddComment} disabled={!input.trim()} className="btn btn-primary btn-sm">Add</button> | |
| </div> | |
| )} | |
| {/* Active comment thread */} | |
| {activeCommentId && (() => { | |
| const comment = comments.find(c => c.id === activeCommentId); | |
| if (!comment) return null; | |
| return ( | |
| <div className="mt-3 p-4 bg-white dark:bg-gray-800 rounded-xl border space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-2 text-xs text-gray-500"> | |
| <User className="w-3.5 h-3.5" /> {comment.author} | |
| <Clock className="w-3.5 h-3.5" /> {new Date(comment.timestamp).toLocaleDateString()} | |
| </div> | |
| <button onClick={() => setActiveCommentId(null)} className="text-gray-400 hover:text-gray-600"><X className="w-4 h-4"/></button> | |
| </div> | |
| <p className="text-sm">{comment.body}</p> | |
| {comment.replies.map(reply => ( | |
| <div key={reply.id} className="ml-6 pl-4 border-l-2 border-primary/20"> | |
| <div className="flex items-center gap-2 text-xs text-gray-500 mb-1"> | |
| <User className="w-3 h-3" /> {reply.author} | |
| </div> | |
| <p className="text-sm">{reply.body}</p> | |
| </div> | |
| ))} | |
| <div className="flex gap-2"> | |
| <input type="text" value={replyInput} onChange={e => setReplyInput(e.target.value)} | |
| placeholder="Reply..." className="flex-1 text-xs px-3 py-1.5 border rounded-lg dark:bg-gray-700" | |
| onKeyDown={e => e.key === "Enter" && replyInput.trim() && (onReply(comment.id, replyInput.trim()), setReplyInput(""))} /> | |
| <button onClick={() => { if (replyInput.trim()) { onReply(comment.id, replyInput.trim()); setReplyInput(""); } }} | |
| className="btn btn-primary btn-xs">Reply</button> | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| </div> | |
| ); | |
| } |
Xet Storage Details
- Size:
- 6.28 kB
- Xet hash:
- 094205f21e66fa55fd61dbeebb9ada0e2f04818fe1c8df49897b6486e688261c
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.