JavaScript 56.7%
TypeScript 42.6%
1"use client";23import { useState, useEffect, useCallback, useRef } from "react";4import { MessageCircle, Send, MoreVertical, Pencil, Trash2 } from "lucide-react";5import { motion, AnimatePresence } from "framer-motion";6import UserAvatar from "@/components/ui/UserAvatar";78interface CommentUser {9 id: string;10 name: string | null;11 username: string | null;12 avatarPath: string | null;13}1415interface Comment {16 id: string;17 content: string;18 createdAt: string;19 updatedAt: string;20 user: CommentUser;21}2223function timeAgo(date: string): string {24 const now = new Date();25 const d = new Date(date);26 const seconds = Math.floor((now.getTime() - d.getTime()) / 1000);2728 if (seconds < 60) return "a l'instant";29 const minutes = Math.floor(seconds / 60);30 if (minutes < 60) return `il y a ${minutes}min`;31 const hours = Math.floor(minutes / 60);32 if (hours < 24) return `il y a ${hours}h`;33 const days = Math.floor(hours / 24);34 if (days < 30) return `il y a ${days}j`;35 const months = Math.floor(days / 30);36 return `il y a ${months} mois`;37}3839interface FileCommentsProps {40 fileId: string;41 currentUserId?: string;42}4344export default function FileComments({ fileId, currentUserId }: FileCommentsProps) {45 const [comments, setComments] = useState<Comment[]>([]);46 const [content, setContent] = useState("");47 const [loading, setLoading] = useState(false);48 const [submitting, setSubmitting] = useState(false);49 const [editingId, setEditingId] = useState<string | null>(null);50 const [editContent, setEditContent] = useState("");51 const [menuOpen, setMenuOpen] = useState<string | null>(null);52 const listRef = useRef<HTMLDivElement>(null);53 const menuRef = useRef<HTMLDivElement>(null);5455 const fetchComments = useCallback(async () => {56 try {57 const res = await fetch(`/api/files/${fileId}/comments`);58 if (res.ok) {59 setComments(await res.json());60 }61 } catch {62 // silent63 }64 }, [fileId]);6566 useEffect(() => {67 setLoading(true);68 fetchComments().finally(() => setLoading(false));69 const interval = setInterval(fetchComments, 10000);70 return () => clearInterval(interval);71 }, [fetchComments]);7273 // Close menu on outside click74 useEffect(() => {75 function handleClick(e: MouseEvent) {76 if (menuRef.current && !menuRef.current.contains(e.target as Node)) {77 setMenuOpen(null);78 }79 }80 document.addEventListener("mousedown", handleClick);81 return () => document.removeEventListener("mousedown", handleClick);82 }, []);8384 const handleSubmit = async () => {85 if (!content.trim() || submitting) return;86 setSubmitting(true);87 try {88 const res = await fetch(`/api/files/${fileId}/comments`, {89 method: "POST",90 headers: { "Content-Type": "application/json" },91 body: JSON.stringify({ content: content.trim() }),92 });93 if (res.ok) {94 const newComment = await res.json();95 setComments((prev) => [...prev, newComment]);96 setContent("");97 // Scroll to bottom98 setTimeout(() => {99 listRef.current?.scrollTo({ top: listRef.current.scrollHeight, behavior: "smooth" });100 }, 100);101 }102 } catch {103 // silent104 } finally {105 setSubmitting(false);106 }107 };108109 const handleEdit = async (commentId: string) => {110 if (!editContent.trim()) return;111 try {112 const res = await fetch(`/api/files/${fileId}/comments/${commentId}`, {113 method: "PATCH",114 headers: { "Content-Type": "application/json" },115 body: JSON.stringify({ content: editContent.trim() }),116 });117 if (res.ok) {118 const updated = await res.json();119 setComments((prev) =>120 prev.map((c) => (c.id === commentId ? updated : c))121 );122 setEditingId(null);123 setEditContent("");124 }125 } catch {126 // silent127 }128 };129130 const handleDelete = async (commentId: string) => {131 try {132 const res = await fetch(`/api/files/${fileId}/comments/${commentId}`, {133 method: "DELETE",134 });135 if (res.ok) {136 setComments((prev) => prev.filter((c) => c.id !== commentId));137 }138 } catch {139 // silent140 }141 setMenuOpen(null);142 };143144 const startEdit = (comment: Comment) => {145 setEditingId(comment.id);146 setEditContent(comment.content);147 setMenuOpen(null);148 };149150 return (151 <div className="flex flex-col h-full">152 <div className="flex items-center gap-2 px-4 py-3 border-b border-white/10">153 <MessageCircle className="w-4 h-4 text-gray-400" />154 <h3 className="text-sm font-medium text-white">155 Commentaires ({comments.length})156 </h3>157 </div>158159 <div ref={listRef} className="flex-1 overflow-y-auto px-4 py-2 space-y-3">160 {loading && comments.length === 0 && (161 <div className="flex justify-center py-8">162 <div className="w-5 h-5 border-2 border-white/20 border-t-white/60 rounded-full animate-spin" />163 </div>164 )}165166 {!loading && comments.length === 0 && (167 <p className="text-center text-gray-500 text-sm py-8">168 Aucun commentaire169 </p>170 )}171172 <AnimatePresence initial={false}>173 {comments.map((comment) => (174 <motion.div175 key={comment.id}176 initial={{ opacity: 0, y: 10 }}177 animate={{ opacity: 1, y: 0 }}178 exit={{ opacity: 0, y: -10 }}179 className="group relative"180 >181 <div className="flex gap-2.5">182 <div className="pt-0.5">183 <UserAvatar184 userId={comment.user.id}185 name={comment.user.name || comment.user.username}186 avatarPath={comment.user.avatarPath}187 size="sm"188 />189 </div>190 <div className="flex-1 min-w-0">191 <div className="flex items-center gap-2">192 <span className="text-xs font-medium text-white truncate">193 {comment.user.name || comment.user.username || "Anonyme"}194 </span>195 <span className="text-[10px] text-gray-500 flex-shrink-0">196 {timeAgo(comment.createdAt)}197 </span>198 {comment.updatedAt !== comment.createdAt && (199 <span className="text-[10px] text-gray-600 italic">(modifie)</span>200 )}201 </div>202203 {editingId === comment.id ? (204 <div className="mt-1 space-y-1.5">205 <textarea206 value={editContent}207 onChange={(e) => setEditContent(e.target.value)}208 className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500"209 rows={2}210 autoFocus211 />212 <div className="flex gap-2">213 <button214 onClick={() => handleEdit(comment.id)}215 className="px-3 py-1 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded-md transition-colors"216 >217 Sauvegarder218 </button>219 <button220 onClick={() => {221 setEditingId(null);222 setEditContent("");223 }}224 className="px-3 py-1 bg-white/5 hover:bg-white/10 text-gray-400 text-xs rounded-md transition-colors"225 >226 Annuler227 </button>228 </div>229 </div>230 ) : (231 <p className="text-sm text-gray-300 mt-0.5 break-words whitespace-pre-wrap">232 {comment.content}233 </p>234 )}235 </div>236237 {/* Menu for own comments */}238 {currentUserId === comment.user.id && editingId !== comment.id && (239 <div className="relative" ref={menuOpen === comment.id ? menuRef : undefined}>240 <button241 onClick={() =>242 setMenuOpen(menuOpen === comment.id ? null : comment.id)243 }244 className="opacity-0 group-hover:opacity-100 p-1 text-gray-500 hover:text-gray-300 transition-all min-w-[28px] min-h-[28px] flex items-center justify-center"245 >246 <MoreVertical className="w-3.5 h-3.5" />247 </button>248249 <AnimatePresence>250 {menuOpen === comment.id && (251 <motion.div252 initial={{ opacity: 0, scale: 0.95 }}253 animate={{ opacity: 1, scale: 1 }}254 exit={{ opacity: 0, scale: 0.95 }}255 className="absolute right-0 top-full mt-1 bg-gray-800 border border-white/10 rounded-lg shadow-xl z-10 overflow-hidden min-w-[140px]"256 >257 <button258 onClick={() => startEdit(comment)}259 className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-300 hover:bg-white/5 transition-colors"260 >261 <Pencil className="w-3.5 h-3.5" />262 Modifier263 </button>264 <button265 onClick={() => handleDelete(comment.id)}266 className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-400 hover:bg-red-500/10 transition-colors"267 >268 <Trash2 className="w-3.5 h-3.5" />269 Supprimer270 </button>271 </motion.div>272 )}273 </AnimatePresence>274 </div>275 )}276 </div>277 </motion.div>278 ))}279 </AnimatePresence>280 </div>281282 {/* Input area */}283 <div className="border-t border-white/10 px-4 py-3">284 <div className="flex gap-2">285 <textarea286 value={content}287 onChange={(e) => setContent(e.target.value)}288 placeholder="Ecrire un commentaire..."289 className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 min-h-[40px] max-h-[120px]"290 rows={1}291 onKeyDown={(e) => {292 if (e.key === "Enter" && !e.shiftKey) {293 e.preventDefault();294 handleSubmit();295 }296 }}297 />298 <button299 onClick={handleSubmit}300 disabled={!content.trim() || submitting}301 className="self-end p-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg transition-colors min-w-[40px] min-h-[40px] flex items-center justify-center"302 >303 <Send className="w-4 h-4" />304 </button>305 </div>306 </div>307 </div>308 );309}310