diff --git a/src/components/Live/Live.css b/src/components/Live/Live.css index bab3f11..09459f5 100644 --- a/src/components/Live/Live.css +++ b/src/components/Live/Live.css @@ -1777,4 +1777,164 @@ body[data-theme="light"] .sw-video-modal-subtitle { .sw-viewer-participants-strip { right: 160px; } + + .sw-viewer-comments { + border-left: none; + border-top: 1px solid rgba(56, 189, 248, 0.15); + max-height: 280px; + } +} + +/* ===== VIEWER COMMENTS SECTION ===== */ + +.sw-viewer-comments { + width: 320px; + display: flex; + flex-direction: column; + background: rgba(15, 23, 42, 0.98); + border-left: 1px solid rgba(56, 189, 248, 0.15); +} + +.sw-viewer-comments-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + font-size: 0.85rem; + font-weight: 700; + color: #e2e8f0; + background: rgba(30, 41, 59, 0.6); + border-bottom: 1px solid rgba(56, 189, 248, 0.1); +} + +.sw-viewer-comments-header i { + color: #60a5fa; +} + +.sw-viewer-comments-count { + margin-left: auto; + padding: 0.15rem 0.5rem; + border-radius: 999px; + font-size: 0.7rem; + background: rgba(96, 165, 250, 0.2); + color: #93c5fd; +} + +.sw-viewer-comments-list { + flex: 1; + overflow-y: auto; + padding: 0.75rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.sw-viewer-comments-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem 1rem; + color: #64748b; + font-size: 0.85rem; + text-align: center; +} + +.sw-viewer-comments-empty i { + font-size: 1.5rem; + opacity: 0.5; +} + +.sw-viewer-comment { + padding: 0.5rem 0.75rem; + background: rgba(30, 41, 59, 0.5); + border-radius: 12px; + border: 1px solid rgba(71, 85, 105, 0.2); + animation: commentSlideIn 0.3s ease; +} + +.sw-viewer-comment.optimistic { + opacity: 0.7; + border-style: dashed; +} + +@keyframes commentSlideIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.sw-viewer-comment-author { + display: block; + font-size: 0.7rem; + font-weight: 700; + color: #60a5fa; + margin-bottom: 0.2rem; +} + +.sw-viewer-comment-body { + display: block; + font-size: 0.85rem; + color: #e2e8f0; + line-height: 1.4; + word-break: break-word; +} + +.sw-viewer-comment-input { + display: flex; + gap: 0.5rem; + padding: 0.75rem; + background: rgba(30, 41, 59, 0.6); + border-top: 1px solid rgba(56, 189, 248, 0.1); +} + +.sw-viewer-comment-input input { + flex: 1; + padding: 0.6rem 0.9rem; + background: rgba(15, 23, 42, 0.8); + border: 1px solid rgba(71, 85, 105, 0.3); + border-radius: 999px; + color: #e2e8f0; + font-size: 0.85rem; +} + +.sw-viewer-comment-input input:focus { + outline: none; + border-color: rgba(96, 165, 250, 0.5); +} + +.sw-viewer-comment-input input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.sw-viewer-comment-input button { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #3b82f6, #6366f1); + border: none; + color: white; + font-size: 0.9rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.sw-viewer-comment-input button:hover:not(:disabled) { + transform: scale(1.05); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.4); +} + +.sw-viewer-comment-input button:disabled { + opacity: 0.5; + cursor: not-allowed; } diff --git a/src/components/Live/LiveKitBroadcast.jsx b/src/components/Live/LiveKitBroadcast.jsx index cd110a0..a0ef365 100644 --- a/src/components/Live/LiveKitBroadcast.jsx +++ b/src/components/Live/LiveKitBroadcast.jsx @@ -193,16 +193,32 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) { }; }, [isLive, livePostId, refreshComments]); - // Add a comment (persist to post immediately) + // Add a comment (persist to post immediately) with optimistic UI update const handleAddComment = useCallback(async () => { const body = commentInput.trim(); if (!body || !livePostId) return; setCommentInput(""); + + // Optimistic update - show comment immediately + const optimisticComment = { + id: `temp_${Date.now()}`, + body, + author: username || "You", + created_at: new Date().toISOString(), + _optimistic: true, + }; + setComments(prev => [...prev, optimisticComment]); + + // Send to server const res = await createPostComment(livePostId, body); if (res?.ok) { + // Refresh to get the real comment with server ID await refreshComments(); + } else { + // Remove optimistic comment on failure + setComments(prev => prev.filter(c => c.id !== optimisticComment.id)); } - }, [commentInput, livePostId, refreshComments]); + }, [commentInput, livePostId, refreshComments, username]); const handleGoLive = useCallback(async () => { // Prevent double-clicks and duplicate calls diff --git a/src/components/Live/LiveKitViewerModal.jsx b/src/components/Live/LiveKitViewerModal.jsx index 1604f37..ca642eb 100644 --- a/src/components/Live/LiveKitViewerModal.jsx +++ b/src/components/Live/LiveKitViewerModal.jsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Room, RoomEvent } from "livekit-client"; import { useAuth } from "../../auth/AuthContext"; +import { fetchPostComments, createPostComment } from "../../api/client"; import "./Live.css"; const LIVEKIT_URL = "wss://stream.sociowire.com"; @@ -26,6 +27,13 @@ export default function LiveKitViewerModal({ post, onClose }) { const roomRef = useRef(null); const videoRef = useRef(null); + // Comments state + const [comments, setComments] = useState([]); + const [commentInput, setCommentInput] = useState(""); + const [commentSending, setCommentSending] = useState(false); + const commentsEndRef = useRef(null); + const postId = post?.id || post?._id; + const getToken = useCallback(async () => { const res = await fetch(`${LIVEKIT_API}/api/livekit/token`, { method: "POST", @@ -108,6 +116,65 @@ export default function LiveKitViewerModal({ post, onClose }) { }; }, [videoTrack]); + // Fetch comments + const refreshComments = useCallback(async () => { + if (!postId) return; + const items = await fetchPostComments(postId, 50); + if (Array.isArray(items)) { + setComments(items); + } + }, [postId]); + + // Poll for new comments + useEffect(() => { + if (!postId) return; + let active = true; + const poll = async () => { + if (!active) return; + await refreshComments(); + }; + poll(); + const timer = setInterval(poll, 3000); // Poll every 3 seconds for live + return () => { + active = false; + clearInterval(timer); + }; + }, [postId, refreshComments]); + + // Auto-scroll comments + useEffect(() => { + commentsEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [comments]); + + // Send comment with optimistic update + const handleSendComment = useCallback(async () => { + const body = commentInput.trim(); + if (!body || !postId || commentSending) return; + + setCommentInput(""); + setCommentSending(true); + + // Optimistic update + const optimisticComment = { + id: `temp_${Date.now()}`, + body, + author: username || "You", + created_at: new Date().toISOString(), + _optimistic: true, + }; + setComments(prev => [...prev, optimisticComment]); + + const res = await createPostComment(postId, body); + setCommentSending(false); + + if (res?.ok) { + await refreshComments(); + } else { + // Remove optimistic comment on failure + setComments(prev => prev.filter(c => c.id !== optimisticComment.id)); + } + }, [commentInput, postId, commentSending, username, refreshComments]); + const handleClose = useCallback(() => { if (roomRef.current) { roomRef.current.disconnect(); @@ -159,6 +226,63 @@ export default function LiveKitViewerModal({ post, onClose }) { )} + + {/* Live Comments Section */} +
{title}
+@{author}
+{title}
+@{author} • {timeAgo}
+{title}
+@{author} • {timeAgo}
+- {snippet} -
- )} - {metaLine && ( -+ {snippet} +
+ )} + {metaLine && ( +