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:
Your Name 2026-01-19 10:05:39 +00:00
parent 8d0db6ddfe
commit c4ae815f4b
5 changed files with 398 additions and 69 deletions

View File

@ -1777,4 +1777,164 @@ body[data-theme="light"] .sw-video-modal-subtitle {
.sw-viewer-participants-strip { .sw-viewer-participants-strip {
right: 160px; 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;
} }

View File

@ -193,16 +193,32 @@ export default function LiveKitBroadcast({ coords, onClose, onStreamCreated }) {
}; };
}, [isLive, livePostId, refreshComments]); }, [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 handleAddComment = useCallback(async () => {
const body = commentInput.trim(); const body = commentInput.trim();
if (!body || !livePostId) return; if (!body || !livePostId) return;
setCommentInput(""); 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); const res = await createPostComment(livePostId, body);
if (res?.ok) { if (res?.ok) {
// Refresh to get the real comment with server ID
await refreshComments(); 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 () => { const handleGoLive = useCallback(async () => {
// Prevent double-clicks and duplicate calls // Prevent double-clicks and duplicate calls

View File

@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Room, RoomEvent } from "livekit-client"; import { Room, RoomEvent } from "livekit-client";
import { useAuth } from "../../auth/AuthContext"; import { useAuth } from "../../auth/AuthContext";
import { fetchPostComments, createPostComment } from "../../api/client";
import "./Live.css"; import "./Live.css";
const LIVEKIT_URL = "wss://stream.sociowire.com"; const LIVEKIT_URL = "wss://stream.sociowire.com";
@ -26,6 +27,13 @@ export default function LiveKitViewerModal({ post, onClose }) {
const roomRef = useRef(null); const roomRef = useRef(null);
const videoRef = 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 getToken = useCallback(async () => {
const res = await fetch(`${LIVEKIT_API}/api/livekit/token`, { const res = await fetch(`${LIVEKIT_API}/api/livekit/token`, {
method: "POST", method: "POST",
@ -108,6 +116,65 @@ export default function LiveKitViewerModal({ post, onClose }) {
}; };
}, [videoTrack]); }, [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(() => { const handleClose = useCallback(() => {
if (roomRef.current) { if (roomRef.current) {
roomRef.current.disconnect(); roomRef.current.disconnect();
@ -159,6 +226,63 @@ export default function LiveKitViewerModal({ post, onClose }) {
</div> </div>
)} )}
</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> </div>
</div> </div>

View File

@ -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 */} {/* Avatar */}
<motion.div <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 }} whileHover={{ scale: 1.05 }}
> >
{authorAvatar ? ( {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" 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"} {author ? author[0]?.toUpperCase() : "U"}
</div> </div>
)} )}
@ -568,11 +573,11 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
{/* Meta */} {/* Meta */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<motion.div <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} {author}
</motion.div> </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" />} {isLiveContent && <span className="live-dot" />}
<span>{timeAgo}</span> <span>{timeAgo}</span>
</div> </div>
@ -588,13 +593,14 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
{cat} {cat}
</motion.div> </motion.div>
</div> </div>
)}
{/* {/*
Image/Gallery - Content type specific rendering Image/Gallery - Content type specific rendering
*/} */}
{contentType === "camera" || contentType === "live" || contentType === "stream" ? ( {contentType === "camera" || contentType === "live" || contentType === "stream" ? (
/* LIVE/Camera: TV-style card with red pulsing dot */ /* 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 ? ( {hasGalleryImage ? (
<BlurImage <BlurImage
src={activeImage} src={activeImage}
@ -608,16 +614,21 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
</div> </div>
)} )}
{/* Red LIVE badge with pulsing dot */} {/* 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"> <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_8px_rgba(255,255,255,0.8)]" /> <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> <span className="text-xs font-black text-white tracking-wider">LIVE</span>
</div> </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 */} {/* 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> </div>
) : contentType === "video" ? ( ) : contentType === "video" ? (
/* VIDEO: Card with play button overlay */ /* 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 ? ( {hasGalleryImage ? (
<BlurImage <BlurImage
src={activeImage} 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"> <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> <span className="text-xs font-black text-white tracking-wider">VIDEO</span>
</div> </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> </div>
) : contentType === "photo" || contentType === "picture" || contentType === "image" ? ( ) : contentType === "photo" || contentType === "picture" || contentType === "image" ? (
/* POLAROID: White frame photo style */ /* POLAROID: White frame photo style - standalone card */
<div className="mx-4 mt-2 mb-4"> <div className="p-4">
<div className="bg-white rounded-sm shadow-lg p-2 pb-10 transform rotate-[-1deg] hover:rotate-0 transition-transform duration-300"> <div className="bg-white rounded-sm shadow-xl p-3 pb-4 transform rotate-[-1deg] hover:rotate-0 transition-transform duration-300">
{hasGalleryImage ? ( {hasGalleryImage ? (
<BlurImage <BlurImage
src={activeImage} src={activeImage}
alt={title} alt={title}
className="w-full aspect-square object-cover" className="w-full aspect-square object-cover rounded-sm"
onError={handleImageError} 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" /> <i className="fa-solid fa-image text-4xl text-gray-300" />
</div> </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>
</div> </div>
) : hasGalleryImage ? ( ) : hasGalleryImage ? (
@ -723,8 +743,10 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
</AnimatePresence> </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"> <div className="p-4">
<h3 className="text-lg font-bold text-surface-50 line-clamp-2 mb-2"> <h3 className="text-lg font-bold text-surface-50 line-clamp-2 mb-2">
{title} {title}
@ -741,6 +763,7 @@ export default function PostCardNew({ post, selected, onSelect, onPostDeleted, o
</div> </div>
)} )}
</div> </div>
)}
{/* {/*
Footer Footer

View File

@ -613,3 +613,9 @@ body[data-theme="light"] .text-high-contrast {
background: rgba(96, 165, 250, 0.3); background: rgba(96, 165, 250, 0.3);
border-radius: 2px; border-radius: 2px;
} }
/* Handwriting font for polaroid captions */
.font-handwriting {
font-family: 'Caveat', 'Segoe Script', 'Comic Sans MS', cursive;
font-weight: 500;
}