📄 Comments.js
/home/palash/git/site/src/components/Comments.js
Language: js • Lines: 398
import React, { useState, useEffect, useCallback } from "react";
import {
  collection,
  query,
  where,
  orderBy,
  getDocs,
  getDoc,
  addDoc,
  setDoc,
  updateDoc,
  serverTimestamp,
  doc,
  arrayUnion,
  arrayRemove,
} from "firebase/firestore";
import { db } from "../firebase";
import { useAuth } from "../contexts/AuthContext";
import { Button, Form, Alert, Spinner } from "react-bootstrap";
import {
  FaUserCircle, FaGoogle, FaThumbsUp, FaRegThumbsUp,
  FaTrash, FaCheck, FaBan, FaReply,
} from "react-icons/fa";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import gravatarUrl from "../utils/gravatar";
import { col } from "../utils/firestorePath";

export default function Comments({ contentId }) {
  const { user, profile, signInWithGoogle } = useAuth();
  const navigate = useNavigate();
  const [searchParams] = useSearchParams();
  const [comments, setComments] = useState([]);
  const [loading, setLoading] = useState(true);
  const [text, setText] = useState("");
  const [replyText, setReplyText] = useState("");
  const [replyTo, setReplyTo] = useState(null);
  const [sending, setSending] = useState(false);
  const [error, setError] = useState("");
  const [clapCount, setClapCount] = useState(0);
  const [userClapped, setUserClapped] = useState(false);
  const [clapLoading, setClapLoading] = useState(false);

  const isModerator = profile?.role === "moderator";

  const loadComments = useCallback(async () => {
    if (!contentId) return;
    try {
      const q = query(
        collection(db, col("comments")),
        where("contentId", "==", contentId),
        orderBy("createdAt", "asc")
      );
      const snap = await getDocs(q);
      setComments(snap.docs.map((d) => ({ id: d.id, ...d.data() })));
    } catch (e) {
      console.error("Failed to load comments", e);
    } finally {
      setLoading(false);
    }
  }, [contentId, isModerator]);

  const loadClaps = useCallback(async () => {
    if (!contentId) return;
    try {
      const snap = await getDoc(doc(db, col("claps"), contentId));
      if (snap.exists()) {
        const data = snap.data();
        setClapCount(data.count || 0);
        if (user) setUserClapped(data.userIds?.includes(user.uid) || false);
      }
    } catch (e) {
      console.error("Failed to load claps", e);
    }
  }, [contentId, user]);

  useEffect(() => {
    loadComments();
    loadClaps();
  }, [loadComments, loadClaps]);

  // Auto-clap when returning from login with ?like=1
  useEffect(() => {
    if (user && searchParams.get("like") === "1") {
      const url = new URL(window.location);
      url.searchParams.delete("like");
      window.history.replaceState({}, "", url);
      autoClap();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [user, searchParams]);

  const autoClap = async () => {
    setClapLoading(true);
    try {
      const ref = doc(db, col("claps"), contentId);
      const snap = await getDoc(ref);
      if (snap.exists()) {
        const data = snap.data();
        if (data.userIds?.includes(user.uid)) return;
        await setDoc(ref, { userIds: arrayUnion(user.uid), count: (data.count || 0) + 1 }, { merge: true });
        setUserClapped(true);
        setClapCount(c => c + 1);
      } else {
        await setDoc(ref, { userIds: [user.uid], count: 1 });
        setUserClapped(true);
        setClapCount(1);
      }
    } catch (e) {
      console.error("Failed to clap", e);
    } finally {
      setClapLoading(false);
    }
  };

  const handleClap = () => {
    if (!user) {
      navigate("/login?redirect=" + encodeURIComponent(window.location.pathname) + "&like=1");
      return;
    }
    toggleClap();
  };

  const toggleClap = async () => {
    setClapLoading(true);
    try {
      const ref = doc(db, col("claps"), contentId);
      const snap = await getDoc(ref);
      if (snap.exists()) {
        const data = snap.data();
        if (data.userIds?.includes(user.uid)) {
          await setDoc(ref, { userIds: arrayRemove(user.uid), count: Math.max(0, (data.count || 1) - 1) }, { merge: true });
          setUserClapped(false);
          setClapCount(c => Math.max(0, c - 1));
        } else {
          await setDoc(ref, { userIds: arrayUnion(user.uid), count: (data.count || 0) + 1 }, { merge: true });
          setUserClapped(true);
          setClapCount(c => c + 1);
        }
      } else {
        await setDoc(ref, { userIds: [user.uid], count: 1 });
        setUserClapped(true);
        setClapCount(1);
      }
    } catch (e) {
      console.error("Failed to clap", e);
    } finally {
      setClapLoading(false);
    }
  };

  const handleSubmit = async (e, parentId) => {
    e.preventDefault();
    const content = parentId ? replyText : text;
    if (!content.trim()) return;
    setSending(true);
    setError("");
    try {
      await addDoc(collection(db, col("comments")), {
        contentId,
        parentId: parentId || null,
        userId: user.uid,
        userName: user.displayName || user.email?.split("@")[0] || "Anonymous",
        userPhoto: user.photoURL || gravatarUrl(user.email) || null,
        text: content.trim(),
        status: "pending",
        createdAt: serverTimestamp(),
      });
      if (parentId) {
        setReplyText("");
        setReplyTo(null);
      } else {
        setText("");
      }
      await loadComments();
    } catch (e) {
      console.error("Failed to post comment", e.code, e.message);
      if (e.code === "permission-denied") {
        setError("Please verify your email before posting comments.");
      } else {
        setError("Failed to post comment.");
      }
    } finally {
      setSending(false);
    }
  };

  const handleApprove = async (commentId) => {
    try { await updateDoc(doc(db, col("comments"), commentId), { status: "approved" }); await loadComments(); }
    catch (e) { console.error(e); }
  };

  const handleReject = async (commentId) => {
    try { await updateDoc(doc(db, col("comments"), commentId), { status: "rejected" }); await loadComments(); }
    catch (e) { console.error(e); }
  };

  const canDelete = (c) => user && c.userId === user.uid;

  const handleDelete = async (commentId) => {
    if (!window.confirm("Remove this comment?")) return;
    try {
      await updateDoc(doc(db, col("comments"), commentId), {
        status: "deleted",
        text: "",
      });
      await loadComments();
    }
    catch (e) { console.error(e); }
  };

  const visible = (c) => {
    if (isModerator) return true;
    if (user && c.userId === user.uid) return true;
    if (c.status === "approved" || !c.status) return true;
    if (c.status === "rejected" || c.status === "deleted") return true;
    if (c.parentId) {
      const parent = comments.find(p => p.id === c.parentId);
      if (parent?.status === "rejected" || parent?.status === "deleted") return true;
    }
    return false;
  };

  const topLevel = comments.filter((c) => !c.parentId && visible(c));
  const replies = (parentId) => comments.filter((c) => c.parentId === parentId && visible(c));

  const renderComment = (c, depth = 0) => {
    if (!visible(c)) return null;
    const removed = c.status === "deleted" || c.status === "rejected";
    const removedText = c.status === "deleted"
      ? "[removed by the author]"
      : "[removed by a moderator]";
    return (
    <div key={c.id} className="d-flex gap-2 mb-2" style={{ marginLeft: depth > 0 ? `${depth * 24}px` : 0 }}>
      <Link to={`/profile/${c.userId}`} className="text-decoration-none flex-shrink-0" style={{ marginTop: "2px" }}>
        {removed ? (
          <FaUserCircle size={depth > 0 ? 24 : 36} className="text-secondary" style={{ opacity: 0.4 }} />
        ) : c.userPhoto ? (
          <img src={c.userPhoto} alt="" width={depth > 0 ? 24 : 36} height={depth > 0 ? 24 : 36} className="rounded-circle" />
        ) : (
          <FaUserCircle size={depth > 0 ? 24 : 36} className="text-secondary" />
        )}
      </Link>
      <div className="flex-grow-1 p-2 rounded" style={{ background: depth > 0 ? "#252525" : "#1e1e1e" }}>
        {removed ? (
          <p className="small mb-0" style={{ color: "#6c757d", fontStyle: "italic" }}>
            {removedText}
          </p>
        ) : (
        <>
        <div className="d-flex gap-2 align-items-baseline flex-wrap">
          <Link to={`/profile/${c.userId}`} className="text-decoration-none"><strong className="small text-light">{c.userName}</strong></Link>
          <span className="text-secondary" style={{ fontSize: "11px" }}>
            {c.createdAt?.toDate?.()?.toLocaleDateString() || ""}
          </span>
          {c.status === "pending" ? (
            <span className="badge bg-warning text-dark" style={{ fontSize: "9px" }}>Pending</span>
          ) : c.status === "rejected" ? (
            <span className="badge bg-danger" style={{ fontSize: "9px" }}>Rejected</span>
          ) : null}
        </div>
        <p className="mb-0 small mt-1" style={{ whiteSpace: "pre-wrap" }}>{c.text}</p>

        <div className="d-flex gap-2 mt-1 align-items-center">
          {user && (user.emailVerified || isModerator) && (
            <button
              className="btn btn-sm btn-link text-secondary p-0 text-decoration-none"
              style={{ fontSize: "11px" }}
              onClick={() => { setReplyTo(replyTo === c.id ? null : c.id); }}
              title="Reply"
            >
              <FaReply />
            </button>
          )}
          {canDelete(c) && (
            <button className="btn btn-sm btn-link text-danger p-0 text-decoration-none" style={{ fontSize: "11px", lineHeight: 1 }} onClick={() => handleDelete(c.id)} title="Delete">
              <FaTrash />
            </button>
          )}
          {isModerator && c.status !== "approved" && (
            <button className="btn btn-sm btn-link text-success p-0 text-decoration-none" style={{ fontSize: "11px" }} onClick={() => handleApprove(c.id)} title="Approve">
              <FaCheck />
            </button>
          )}
          {isModerator && c.status !== "rejected" && (
            <button className="btn btn-sm btn-link text-warning p-0 text-decoration-none" style={{ fontSize: "11px" }} onClick={() => handleReject(c.id)} title="Reject">
              <FaBan />
            </button>
          )}
        </div>

        {replyTo === c.id && (
          <Form onSubmit={(e) => handleSubmit(e, c.id)} className="mt-2">
            <div className="d-flex gap-2">
              <Form.Group className="flex-grow-1">
                <Form.Control
                  as="textarea"
                  rows={2}
                  placeholder={`Reply to ${c.userName}...`}
                  value={replyText}
                  onChange={(e) => setReplyText(e.target.value)}
                  className="text-light border-secondary"
                  style={{ background: '#2a2a2a', borderRadius: '6px', fontSize: "13px" }}
                  required
                />
              </Form.Group>
            </div>
            <div className="text-end mt-1">
              <Button type="submit" size="sm" disabled={sending || !replyText.trim()} style={{ fontSize: "12px" }}>
                {sending ? "Posting..." : "Reply"}
              </Button>
            </div>
          </Form>
        )}
        </>
        )}

        {replies(c.id).map((r) => renderComment(r, depth + 1))}
      </div>
    </div>
    );
  };

  return (
    <div className="mt-5" style={{ borderTop: "1px solid #333", paddingTop: "2rem" }}>
      <div className="d-flex align-items-center justify-content-between mb-4">
        <h5 className="mb-0">Comments</h5>
        <div className="d-flex align-items-center gap-3">
          {isModerator && (
            <span className="badge bg-warning text-dark" style={{ fontSize: "10px" }}>
              {comments.filter(c => c.status === "pending").length} pending
            </span>
          )}
          <button
            className={`btn btn-sm ${userClapped ? "btn-warning" : "btn-outline-secondary"}`}
            onClick={handleClap}
            disabled={clapLoading}
            title={user ? "Like this article" : "Sign in to like"}
          >
            {userClapped ? <FaThumbsUp className="me-1" /> : <FaRegThumbsUp className="me-1" />}
            {clapCount > 0 && <span>{clapCount}</span>}
          </button>
        </div>
      </div>

      {loading ? (
        <div className="text-center py-4"><Spinner animation="border" variant="light" size="sm" /></div>
      ) : topLevel.length === 0 ? (
        <p className="text-secondary small mb-4">No comments yet.</p>
      ) : (
        <div className="mb-4">
          {topLevel.map((c) => renderComment(c))}
        </div>
      )}

      {user ? (
        user.emailVerified ? (
          <Form onSubmit={(e) => handleSubmit(e, null)}>
            <div className="d-flex gap-2">
              <Link to={`/profile/${user.uid}`} className="text-decoration-none flex-shrink-0">
                {user.photoURL ? (
                  <img src={user.photoURL || gravatarUrl(user.email)} alt="" width={32} height={32} className="rounded-circle" />
                ) : (
                  <FaUserCircle size={32} className="text-secondary" />
                )}
              </Link>
              <Form.Group className="flex-grow-1 mb-2">
                <Form.Control
                  as="textarea"
                  rows={3}
                  placeholder="Write a comment..."
                  value={text}
                  onChange={(e) => setText(e.target.value)}
                  className="text-light border-secondary"
                  style={{ background: '#2a2a2a', borderRadius: '6px' }}
                  required
                />
              </Form.Group>
            </div>
            {error && <Alert variant="danger" className="py-1 small">{error}</Alert>}
            <div className="text-end">
              <Button type="submit" disabled={sending || !text.trim()}>
                {sending ? "Posting..." : "Post Comment"}
              </Button>
            </div>
          </Form>
        ) : (
          <Alert variant="warning" className="py-2 small mb-0">
            Please verify your email before posting comments.
          </Alert>
        )
      ) : (
        <div className="d-flex align-items-center gap-2 text-secondary small">
          <FaGoogle /> <Button variant="link" size="sm" onClick={signInWithGoogle} className="p-0">Sign in with Google</Button> to leave a comment.
        </div>
      )}
    </div>
  );
}