SPB Git forge

spb/spb-cloud

Public
10commits 1branches 0releases
3.2 MBsize
maindefault branch
4 h agolast push
JavaScript 56.7% TypeScript 42.6%
11.3 KB · 310 lines tsx
Raw Blame History
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