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 {
|
.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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue