From c4ae815f4b37dca11efda9bb88d5963d62188a64 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 19 Jan 2026 10:05:39 +0000 Subject: [PATCH] feat: Distinct card layouts + live comments PostCardNew: - Photo/image: Polaroid-only layout (no duplicate card) - Video: Play button overlay with title at bottom - Camera/Live: TV style with LIVE badge and title overlay - Default: Full card with header/body/footer - Hidden header/body for media content types Live Comments: - Added comments panel to LiveKitViewerModal - Optimistic UI update for instant feedback - Auto-scroll and polling every 3 seconds - Styled chat interface with animations Co-Authored-By: Claude Opus 4.5 --- src/components/Live/Live.css | 160 +++++++++++++++++++++ src/components/Live/LiveKitBroadcast.jsx | 20 ++- src/components/Live/LiveKitViewerModal.jsx | 124 ++++++++++++++++ src/components/Posts/PostCardNew.jsx | 157 +++++++++++--------- src/styles/tailwind.css | 6 + 5 files changed, 398 insertions(+), 69 deletions(-) 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 */} +
+
+ + Live Chat + {comments.length} +
+ +
+ {comments.length === 0 ? ( +
+ + No comments yet. Be the first! +
+ ) : ( + comments.map((c) => ( +
+ @{c.author} + {c.body} +
+ )) + )} +
+
+ + {/* Comment Input */} +
+ setCommentInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendComment(); + } + }} + disabled={!authToken || commentSending} + /> + +
+
diff --git a/src/components/Posts/PostCardNew.jsx b/src/components/Posts/PostCardNew.jsx index b72e8d0..72aaace 100644 --- a/src/components/Posts/PostCardNew.jsx +++ b/src/components/Posts/PostCardNew.jsx @@ -544,57 +544,63 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o }} > {/* ───────────────────────────────────────────────────────────────────────── - Header + Header - Hidden for polaroid, minimal for video/live ───────────────────────────────────────────────────────────────────────── */} -
- {/* Avatar */} - - {authorAvatar ? ( - {author} - ) : ( -
- {author ? author[0]?.toUpperCase() : "U"} -
- )} -
- - {/* Meta */} -
+ {!(contentType === "photo" || contentType === "picture" || contentType === "image") && ( +
+ {/* Avatar */} - {author} + {authorAvatar ? ( + {author} + ) : ( +
+ {author ? author[0]?.toUpperCase() : "U"} +
+ )}
-
- {isLiveContent && } - {timeAgo} -
-
- {/* Badge */} - - {cat} - -
+ {/* Meta */} +
+ + {author} + +
+ {isLiveContent && } + {timeAgo} +
+
+ + {/* Badge */} + + {cat} + +
+ )} {/* ───────────────────────────────────────────────────────────────────────── Image/Gallery - Content type specific rendering ───────────────────────────────────────────────────────────────────────── */} {contentType === "camera" || contentType === "live" || contentType === "stream" ? ( /* LIVE/Camera: TV-style card with red pulsing dot */ -
+
{hasGalleryImage ? ( )} {/* Red LIVE badge with pulsing dot */} -
- +
+ LIVE
+ {/* Title overlay at bottom */} +
+

{title}

+

@{author}

+
{/* Scanlines effect */} -
+
) : contentType === "video" ? ( /* VIDEO: Card with play button overlay */ -
+
{hasGalleryImage ? ( VIDEO
+ {/* Title overlay at bottom */} +
+

{title}

+

@{author} • {timeAgo}

+
) : contentType === "photo" || contentType === "picture" || contentType === "image" ? ( - /* POLAROID: White frame photo style */ -
-
+ /* POLAROID: White frame photo style - standalone card */ +
+
{hasGalleryImage ? ( ) : ( -
+
)} - {/* Polaroid caption area - already white, title below */} + {/* Polaroid caption */} +
+

{title}

+

@{author} • {timeAgo}

+
) : hasGalleryImage ? ( @@ -723,24 +743,27 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o {/* ───────────────────────────────────────────────────────────────────────── - Body + Body - Hidden for photo/video/camera, shown for news/default ───────────────────────────────────────────────────────────────────────── */} -
-

- {title} -

- {snippet && ( -

- {snippet} -

- )} - {metaLine && ( -
- - {metaLine} -
- )} -
+ {!(contentType === "photo" || contentType === "picture" || contentType === "image" || + contentType === "video" || contentType === "camera" || contentType === "live" || contentType === "stream") && ( +
+

+ {title} +

+ {snippet && ( +

+ {snippet} +

+ )} + {metaLine && ( +
+ + {metaLine} +
+ )} +
+ )} {/* ───────────────────────────────────────────────────────────────────────── Footer diff --git a/src/styles/tailwind.css b/src/styles/tailwind.css index ee20b72..262964c 100644 --- a/src/styles/tailwind.css +++ b/src/styles/tailwind.css @@ -613,3 +613,9 @@ body[data-theme="light"] .text-high-contrast { background: rgba(96, 165, 250, 0.3); border-radius: 2px; } + +/* Handwriting font for polaroid captions */ +.font-handwriting { + font-family: 'Caveat', 'Segoe Script', 'Comic Sans MS', cursive; + font-weight: 500; +}