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 <noreply@anthropic.com>
This commit is contained in:
parent
8d0db6ddfe
commit
c4ae815f4b
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Live Comments Section */}
|
||||
<div className="sw-viewer-comments">
|
||||
<div className="sw-viewer-comments-header">
|
||||
<i className="fa-solid fa-comments" />
|
||||
<span>Live Chat</span>
|
||||
<span className="sw-viewer-comments-count">{comments.length}</span>
|
||||
</div>
|
||||
|
||||
<div className="sw-viewer-comments-list">
|
||||
{comments.length === 0 ? (
|
||||
<div className="sw-viewer-comments-empty">
|
||||
<i className="fa-regular fa-comment-dots" />
|
||||
<span>No comments yet. Be the first!</span>
|
||||
</div>
|
||||
) : (
|
||||
comments.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`sw-viewer-comment ${c._optimistic ? "optimistic" : ""}`}
|
||||
>
|
||||
<span className="sw-viewer-comment-author">@{c.author}</span>
|
||||
<span className="sw-viewer-comment-body">{c.body}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={commentsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Comment Input */}
|
||||
<div className="sw-viewer-comment-input">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={authToken ? "Say something..." : "Log in to comment"}
|
||||
value={commentInput}
|
||||
onChange={(e) => setCommentInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendComment();
|
||||
}
|
||||
}}
|
||||
disabled={!authToken || commentSending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendComment}
|
||||
disabled={!authToken || !commentInput.trim() || commentSending}
|
||||
>
|
||||
{commentSending ? (
|
||||
<i className="fa-solid fa-spinner fa-spin" />
|
||||
) : (
|
||||
<i className="fa-solid fa-paper-plane" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -544,12 +544,17 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
}}
|
||||
>
|
||||
{/* ─────────────────────────────────────────────────────────────────────────
|
||||
Header
|
||||
Header - Hidden for polaroid, minimal for video/live
|
||||
───────────────────────────────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 p-4">
|
||||
{!(contentType === "photo" || contentType === "picture" || contentType === "image") && (
|
||||
<div className={`flex items-center gap-3 p-4 ${
|
||||
(contentType === "video" || contentType === "camera" || contentType === "live" || contentType === "stream")
|
||||
? "pb-2"
|
||||
: ""
|
||||
}`}>
|
||||
{/* Avatar */}
|
||||
<motion.div
|
||||
className="relative w-12 h-12 rounded-full p-0.5 bg-gradient-to-br from-accent-dark via-purple-500 to-pink-500 shadow-glow-sm"
|
||||
className="relative w-10 h-10 sm:w-12 sm:h-12 rounded-full p-0.5 bg-gradient-to-br from-accent-dark via-purple-500 to-pink-500 shadow-glow-sm flex-shrink-0"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
{authorAvatar ? (
|
||||
|
|
@ -559,7 +564,7 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
className="w-full h-full rounded-full object-cover bg-surface-900"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full rounded-full bg-surface-800 flex items-center justify-center text-white font-bold text-lg">
|
||||
<div className="w-full h-full rounded-full bg-surface-800 flex items-center justify-center text-white font-bold text-sm sm:text-lg">
|
||||
{author ? author[0]?.toUpperCase() : "U"}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -568,11 +573,11 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
{/* Meta */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<motion.div
|
||||
className="font-semibold text-surface-100 truncate group-hover:text-accent-light transition-colors"
|
||||
className="font-semibold text-surface-100 truncate group-hover:text-accent-light transition-colors text-sm sm:text-base"
|
||||
>
|
||||
{author}
|
||||
</motion.div>
|
||||
<div className={`flex items-center gap-2 text-sm ${isLiveContent ? "text-red-400 font-bold" : "text-surface-500"}`}>
|
||||
<div className={`flex items-center gap-2 text-xs sm:text-sm ${isLiveContent ? "text-red-400 font-bold" : "text-surface-500"}`}>
|
||||
{isLiveContent && <span className="live-dot" />}
|
||||
<span>{timeAgo}</span>
|
||||
</div>
|
||||
|
|
@ -588,13 +593,14 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
{cat}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────────
|
||||
Image/Gallery - Content type specific rendering
|
||||
───────────────────────────────────────────────────────────────────────── */}
|
||||
{contentType === "camera" || contentType === "live" || contentType === "stream" ? (
|
||||
/* LIVE/Camera: TV-style card with red pulsing dot */
|
||||
<div className="relative w-full aspect-video bg-slate-900 overflow-hidden">
|
||||
<div className="relative w-full aspect-video bg-slate-900 overflow-hidden rounded-t-2xl">
|
||||
{hasGalleryImage ? (
|
||||
<BlurImage
|
||||
src={activeImage}
|
||||
|
|
@ -608,16 +614,21 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
</div>
|
||||
)}
|
||||
{/* Red LIVE badge with pulsing dot */}
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-600/90 backdrop-blur-sm">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-white animate-pulse shadow-[0_0_8px_rgba(255,255,255,0.8)]" />
|
||||
<div className="absolute top-3 left-3 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-red-600/95 backdrop-blur-sm shadow-lg">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-white animate-pulse shadow-[0_0_10px_rgba(255,255,255,0.9)]" />
|
||||
<span className="text-xs font-black text-white tracking-wider">LIVE</span>
|
||||
</div>
|
||||
{/* Title overlay at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
|
||||
<p className="text-white font-semibold text-sm line-clamp-1">{title}</p>
|
||||
<p className="text-white/70 text-xs">@{author}</p>
|
||||
</div>
|
||||
{/* Scanlines effect */}
|
||||
<div className="absolute inset-0 pointer-events-none bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(0,0,0,0.1)_2px,rgba(0,0,0,0.1)_4px)]" />
|
||||
<div className="absolute inset-0 pointer-events-none bg-[repeating-linear-gradient(0deg,transparent,transparent_2px,rgba(0,0,0,0.05)_2px,rgba(0,0,0,0.05)_4px)]" />
|
||||
</div>
|
||||
) : contentType === "video" ? (
|
||||
/* VIDEO: Card with play button overlay */
|
||||
<div className="relative w-full aspect-video bg-slate-900 overflow-hidden group/video">
|
||||
<div className="relative w-full aspect-video bg-slate-900 overflow-hidden rounded-t-2xl group/video">
|
||||
{hasGalleryImage ? (
|
||||
<BlurImage
|
||||
src={activeImage}
|
||||
|
|
@ -644,24 +655,33 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
<div className="absolute top-3 left-3 px-3 py-1.5 rounded-lg bg-blue-600/90 backdrop-blur-sm">
|
||||
<span className="text-xs font-black text-white tracking-wider">VIDEO</span>
|
||||
</div>
|
||||
{/* Title overlay at bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-3 bg-gradient-to-t from-black/80 to-transparent">
|
||||
<p className="text-white font-semibold text-sm line-clamp-1">{title}</p>
|
||||
<p className="text-white/70 text-xs">@{author} • {timeAgo}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : contentType === "photo" || contentType === "picture" || contentType === "image" ? (
|
||||
/* POLAROID: White frame photo style */
|
||||
<div className="mx-4 mt-2 mb-4">
|
||||
<div className="bg-white rounded-sm shadow-lg p-2 pb-10 transform rotate-[-1deg] hover:rotate-0 transition-transform duration-300">
|
||||
/* POLAROID: White frame photo style - standalone card */
|
||||
<div className="p-4">
|
||||
<div className="bg-white rounded-sm shadow-xl p-3 pb-4 transform rotate-[-1deg] hover:rotate-0 transition-transform duration-300">
|
||||
{hasGalleryImage ? (
|
||||
<BlurImage
|
||||
src={activeImage}
|
||||
alt={title}
|
||||
className="w-full aspect-square object-cover"
|
||||
className="w-full aspect-square object-cover rounded-sm"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full aspect-square flex items-center justify-center bg-gray-100">
|
||||
<div className="w-full aspect-square flex items-center justify-center bg-gray-100 rounded-sm">
|
||||
<i className="fa-solid fa-image text-4xl text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
{/* Polaroid caption area - already white, title below */}
|
||||
{/* Polaroid caption */}
|
||||
<div className="mt-3 px-1">
|
||||
<p className="text-gray-800 font-handwriting text-sm line-clamp-2">{title}</p>
|
||||
<p className="text-gray-500 text-xs mt-1">@{author} • {timeAgo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : hasGalleryImage ? (
|
||||
|
|
@ -723,8 +743,10 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
</AnimatePresence>
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────────
|
||||
Body
|
||||
Body - Hidden for photo/video/camera, shown for news/default
|
||||
───────────────────────────────────────────────────────────────────────── */}
|
||||
{!(contentType === "photo" || contentType === "picture" || contentType === "image" ||
|
||||
contentType === "video" || contentType === "camera" || contentType === "live" || contentType === "stream") && (
|
||||
<div className="p-4">
|
||||
<h3 className="text-lg font-bold text-surface-50 line-clamp-2 mb-2">
|
||||
{title}
|
||||
|
|
@ -741,6 +763,7 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─────────────────────────────────────────────────────────────────────────
|
||||
Footer
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue