tahamajs/IE / Projects /front /src /components /async /ThreadedFigureComment.tsx
tahamajs's picture
download
raw
6.28 kB
// 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.