From 5b38f17a479916f2ea10eddc75319a3ae436c4ae Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Wed, 21 May 2025 23:59:12 -0700 Subject: [PATCH 01/34] Fix: added useRef import and defined boxStyle to fix rendering errors --- src/components/Announcements/index.jsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index 2a50b1e5b5..583a6edda7 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -1,8 +1,9 @@ /* global tinymce */ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import './Announcements.css'; import { useDispatch, useSelector } from 'react-redux'; import { Editor } from '@tinymce/tinymce-react'; +import { boxStyle, boxStyleDark } from 'styles'; import { toast } from 'react-toastify'; import { sendEmail, broadcastEmailsToAll } from '../../actions/sendEmails'; @@ -227,9 +228,8 @@ function Announcements({ title, email: initialEmail }) { value={emailTo} id="email-list-input" onChange={handleEmailListChange} - className={`input-text-for-announcement ${ - darkMode ? 'bg-darkmode-liblack text-light border-0' : '' - }`} + className={`input-text-for-announcement ${darkMode ? 'bg-darkmode-liblack text-light border-0' : '' + }`} /> + + ); + } + if (item.type === 'video') { + return ( +
+ + {item.title && {item.title}} +
+ ); + } + if (item.type === 'gif') { + return ( +
+
+ + {item.thumb && ( +
+ GIF +
+ )} +
+ {item.title && {item.title}} +
+ ); + } + return null; + })} + + ); +} + +function BlueskyPostDetails() { + const [handle, setHandle] = useState(''); + const [appPassword, setAppPassword] = useState(''); + const [isConnected, setIsConnected] = useState(false); + const [postText, setPostText] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [status, setStatus] = useState(''); + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [deletingPost, setDeletingPost] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [postToDelete, setPostToDelete] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const dropZoneRef = useRef(null); + + const checkSession = async () => { + try { + const response = await fetch('http://localhost:4500/api/bluesky/session', { + credentials: 'include', + }); + const result = await response.json(); + + if (result.success && result.isConnected) { + setIsConnected(true); + setHandle(result.handle || ''); + // Don't set password as it's not sent back from server + if (!status) setStatus('✅ Connected to Bluesky'); + } else { + setIsConnected(false); + if (isConnected) setStatus('Session expired. Please reconnect.'); + } + } catch (error) { + setIsConnected(false); + } + }; + + // Check session status on mount and periodically + useEffect(() => { + checkSession(); + // Check session every 5 minutes + const intervalId = setInterval(checkSession, 5 * 60 * 1000); + return () => clearInterval(intervalId); + }, []); + + const handleDragEnter = e => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = e => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDragOver = e => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = e => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const { files } = e.dataTransfer; + if (files && files.length > 0) { + const file = files[0]; + if (file.type.startsWith('image/')) { + const isGif = file.type === 'image/gif'; + if (file.size > (isGif ? 5000000 : 1000000)) { + // 5MB for GIFs, 1MB for other images + setStatus( + `❌ ${isGif ? 'GIF' : 'Image'} size must be less than ${isGif ? '5MB' : '1MB'}`, + ); + return; + } + setSelectedImage(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + } else { + setStatus('❌ Only image files are allowed'); + } + } + }; + + const handleImageSelect = e => { + const file = e.target.files[0]; + if (file) { + const isGif = file.type === 'image/gif'; + if (file.size > (isGif ? 5000000 : 1000000)) { + // 5MB for GIFs, 1MB for other images + setStatus(`❌ ${isGif ? 'GIF' : 'Image'} size must be less than ${isGif ? '5MB' : '1MB'}`); + return; + } + setSelectedImage(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + } + }; + + const connectToBluesky = async () => { + if (!handle || !appPassword) { + setStatus('❌ Handle and password are required'); + return; + } + + try { + setStatus('🔄 Connecting...'); + const response = await fetch('http://localhost:4500/api/bluesky/connect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Important: include credentials + body: JSON.stringify({ + handle, + password: appPassword, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + if (result.success) { + setIsConnected(true); + setStatus(`✅ Connected to Bluesky! DID: ${result.did}`); + } else { + setStatus(`${result.error || 'Failed to connect'}`); + } + } catch (error) { + setStatus(`${error.message}`); + } + }; + + const disconnectFromBluesky = async () => { + try { + setStatus('🔄 Disconnecting...'); + const response = await fetch('http://localhost:4500/api/bluesky/disconnect', { + method: 'POST', + credentials: 'include', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + setIsConnected(false); + setHandle(''); + setAppPassword(''); + setPostText(''); + setStatus('✅ Disconnected from Bluesky'); + } catch (error) { + setStatus(`${error.message}`); + } + }; + + const getPosts = async () => { + setIsLoading(true); + try { + const response = await fetch('http://localhost:4500/api/bluesky/posts', { + credentials: 'include', + }); + const result = await response.json(); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + if (result.success) { + setPosts(result.posts); + setStatus(result.posts.length ? '✅ Posts loaded!' : 'ℹ️ No posts found'); + } else { + setStatus(`${result.error || 'Failed to load posts'}`); + } + } catch (error) { + setStatus(`${error.message}`); + + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + setIsConnected(false); + setStatus('❌ Session expired. Please reconnect.'); + } + } finally { + setIsLoading(false); + } + }; + + const clearImage = () => { + setSelectedImage(null); + setImagePreview(null); + }; + + const createPost = async () => { + if (!isConnected) { + setStatus('❌ Please connect to Bluesky first'); + return; + } + + const trimmedText = postText.trim(); + if (!trimmedText && !selectedImage) { + setStatus('❌ Please provide text or an image for the post'); + return; + } + + try { + setStatus('🔄 Creating post...'); + const formData = new FormData(); + formData.append('text', trimmedText); // Will be empty string if no text + + if (selectedImage) { + formData.append('image', selectedImage); + } + + const response = await fetch('http://localhost:4500/api/bluesky/post', { + method: 'POST', + credentials: 'include', + body: formData, // Let browser set the Content-Type with boundary + }); + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Failed to create post'); + } + + setStatus('✅ Posted successfully!'); + setPostText(''); + clearImage(); + getPosts(); + } catch (error) { + setStatus(`${error.message}`); + + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + setIsConnected(false); + setStatus('❌ Session expired. Please reconnect.'); + } + } + }; + + const handleDelete = uri => { + setPostToDelete(uri); + setShowDeleteModal(true); + }; + + const confirmDelete = async () => { + if (!postToDelete) return; + + try { + setShowDeleteModal(false); + setDeletingPost(postToDelete); + setStatus('🔄 Deleting post...'); + const response = await fetch( + `http://localhost:4500/api/bluesky/post/${encodeURIComponent(postToDelete)}`, + { + method: 'DELETE', + credentials: 'include', + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result.success) { + setStatus('✅ Post deleted successfully!'); + // Remove the deleted post from the list + setPosts(posts.filter(post => post.uri !== postToDelete)); + } else { + setStatus(`${result.error || 'Failed to delete post'}`); + } + } catch (error) { + setStatus(`${error.message}`); + + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + setIsConnected(false); + setStatus('❌ Session expired. Please reconnect.'); + } + } finally { + setDeletingPost(null); + setPostToDelete(null); + } + }; + + const cancelDelete = () => { + setShowDeleteModal(false); + setPostToDelete(null); + }; + + const viewPost = uri => { + try { + if (!uri || !uri.startsWith('at://')) { + return; + } + + const parts = uri.split('/'); + const rkey = parts[parts.length - 1]; + + const didMatch = uri.match(/at:\/\/(did:[^/]+)/); + if (!didMatch) { + return; + } + + const did = didMatch[1]; + const url = `https://bsky.app/profile/${did}/post/${rkey}`; + window.open(url, '_blank'); + } catch (error) { + setStatus(`${error.message}`); + } + }; + + useEffect(() => { + if (isConnected) { + getPosts(); + } else { + setPosts([]); + } + }, [isConnected]); + + const getStatusVariant = () => { + if (status.includes('✅')) return 'success'; + if (status.includes('❌')) return 'danger'; + return 'info'; + }; + + return ( + +

🔵 Bluesky Manager

+ {!isConnected ? ( + +

🔐 Connect to Bluesky

+
+ + setHandle(e.target.value)} + /> + + + setAppPassword(e.target.value)} + /> + + +
+
+ ) : ( + <> + +
+

📝 Create a New Post

+ +
+ + setPostText(e.target.value)} + placeholder="What's on your mind?" + /> + + {/* Drag & Drop Image Upload */} +
+
+ +

Drag and drop an image here, or

+ + Max file size: 1MB (5MB for GIFs) +
+
+ {/* Image Preview */} + {imagePreview && ( +
+ Preview + +
+ )} + +
+ + +
+

📜 Your Posts

+ +
+ {posts.length > 0 ? ( +
+ {posts.map(post => ( + + + {post.text} + +
+
+ + ❤️ {post.likeCount} • 🔁 {post.repostCount} + + • {formatDate(post.createdAt)} +
+
+ + +
+
+
+
+ ))} +
+ ) : ( + + {isLoading ? '🔄 Loading posts...' : 'No posts yet'} + + )} +
+ + )} + {status && ( + + {status} + + )} + {/* Delete confirmation modal */} + + + Delete Post + + + Are you sure you want to delete this post? This action cannot be undone. + + + + + + +
+ ); +} + +export default BlueskyPostDetails; + +; diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index 583a6edda7..a5592a6f82 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -6,6 +6,8 @@ import { Editor } from '@tinymce/tinymce-react'; import { boxStyle, boxStyleDark } from 'styles'; import { toast } from 'react-toastify'; import { sendEmail, broadcastEmailsToAll } from '../../actions/sendEmails'; +import BlueskyPostDetails from './BlueskyPostDetails'; +import BlueskyIcon from '../../assets/images/BlueskyIcon.svg'; function Announcements({ title, email: initialEmail }) { const darkMode = useSelector(state => state.theme.darkMode); @@ -17,6 +19,8 @@ function Announcements({ title, email: initialEmail }) { const [showEditor, setShowEditor] = useState(true); const [isFileUploaded, setIsFileUploaded] = useState(false); const tinymce = useRef(null); + const [showBluesky, setShowBluesky] = useState(false); + const [showEmailSection, setShowEmailSection] = useState(true); useEffect(() => { setShowEditor(false); @@ -148,130 +152,150 @@ function Announcements({ title, email: initialEmail }) { const handleBroadcastEmails = () => { const htmlContent = ` -
- ${emailContent} -
- `; +
+ ${emailContent} +
+ `; dispatch(broadcastEmailsToAll('Weekly Update', htmlContent)); }; return (
-
-
- {title ?

{title}

:

Weekly Progress Editor

} + {/* Bluesky Header */} +
+ +
-
- {showEditor && ( - { - setEmailContent(content); - }} - /> - )} - {title ? ( - '' - ) : ( -
-
-
- -
-
- - -
-
- -
-
- - + {!showBluesky && ( +
+
+ {title ?

{title}

:

Weekly Progress Editor

} +
+ {showEditor && ( + { + setEmailContent(content); + }} + /> + )} + {title ? ( + '' + ) : ( +
+
+
+ +
+
+ + +
+
+ +
+
+ + +
-
- )} -
-
- {title ? ( -

Email

- ) : ( - - )} - - + {title ? ( +

Email

+ ) : ( + + )} + + -
- - + + - -
- - + /> + +
+ + +
-
+ )} + + {/* show BlueskyPostDetails */} + {showBluesky && ( + setShowBluesky(false)} /> + )}
); } diff --git a/src/config/api.js b/src/config/api.js new file mode 100644 index 0000000000..f563e1dd63 --- /dev/null +++ b/src/config/api.js @@ -0,0 +1,13 @@ +const API_BASE_URL = + process.env.NODE_ENV === 'production' + ? 'https://your-production-api.com' // Replace with your production API URL + : 'http://localhost:3000'; + +export const ENDPOINTS = { + BLUESKY: { + CONNECT: `${API_BASE_URL}/api/bluesky/connect`, + POST: `${API_BASE_URL}/api/bluesky/post`, + }, +}; + +export default API_BASE_URL; From 081752042f18d1e4a64445a4d85e29ec2a6d9832 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Wed, 21 May 2025 23:59:12 -0700 Subject: [PATCH 03/34] Fix: added useRef import and defined boxStyle to fix rendering errors --- src/components/Announcements/index.jsx | 265 ++++++++++--------------- 1 file changed, 102 insertions(+), 163 deletions(-) diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index d94cc5c6da..aff85c38b6 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -1,180 +1,119 @@ -/* Announcements/Announcements.jsx */ -import { useState } from 'react'; -import styles from './Announcements.module.css'; -import { useSelector } from 'react-redux'; -import { Nav, NavItem, NavLink, TabContent, TabPane } from 'reactstrap'; -import classnames from 'classnames'; -import SocialMediaComposer from './SocialMediaComposer'; -import TruthSocialAutoPoster from '../AutoPoster/TruthSocialAutoPoster'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - faEnvelope, - faVideo, - faNewspaper, - faImage, - faChartLine, -} from '@fortawesome/free-solid-svg-icons'; -import { faFacebook, faLinkedin, faMedium } from '@fortawesome/free-brands-svg-icons'; -import ReactTooltip from 'react-tooltip'; -import EmailPanel from './platforms/email'; +/* global tinymce */ +import { useState, useEffect, useRef } from 'react'; +import './Announcements.css'; +import { useDispatch, useSelector } from 'react-redux'; +import { Editor } from '@tinymce/tinymce-react'; +import { boxStyle, boxStyleDark } from 'styles'; +import { toast } from 'react-toastify'; +import { sendEmail, broadcastEmailsToAll } from '../../actions/sendEmails'; function Announcements({ title, email: initialEmail }) { - const [activeTab, setActiveTab] = useState('email'); const darkMode = useSelector(state => state.theme.darkMode); + const dispatch = useDispatch(); - const getIconColor = id => { - switch (id) { - case 'facebook': - return '#1877F2'; - case 'linkedin': - return '#0077B5'; - case 'medium': - return '#00ab6c'; - case 'video': - return '#FF0000'; - case 'article': - return '#4285f4'; - case 'photo': - return '#E91E63'; - case 'weeklyreport': - return '#00C853'; - default: - return null; + const [emailTo, setEmailTo] = useState(initialEmail || ''); + const [headerContent, setHeaderContent] = useState(''); + const editorRef = useRef(null); + + const handleEmailListChange = (e) => setEmailTo(e.target.value); + const handleHeaderContentChange = (e) => setHeaderContent(e.target.value); + + const handleSendEmails = () => { + if (!emailTo.trim()) { + toast.error('Please enter at least one email address.'); + return; + } + const emails = emailTo.split(',').map(email => email.trim()).filter(email => email); + if (emails.length === 0) { + toast.error('Please enter valid email addresses.'); + return; } + dispatch(sendEmail(emails, editorRef.current ? editorRef.current.getContent() : '')); + toast.success('Emails sent successfully!'); }; - const tabs = [ - { id: 'weeklyreport', icon: faChartLine, label: 'Weekly Report' }, - { id: 'photo', icon: faImage, label: 'Photo' }, - { id: 'video', icon: faVideo, label: 'Video' }, - { id: 'article', icon: faNewspaper, label: 'Article' }, - { id: 'x', label: 'X', customIconSrc: 'social-media-logos/x_icon.png' }, - { id: 'facebook', icon: faFacebook, label: 'Facebook' }, - { id: 'linkedin', icon: faLinkedin, label: 'LinkedIn' }, - { id: 'pinterest', label: 'Pinterest', customIconSrc: 'social-media-logos/pinterest_icon.png' }, - { id: 'instagram', label: 'Instagram', customIconSrc: 'social-media-logos/insta_icon.png' }, - { id: 'threads', label: 'Threads', customIconSrc: 'social-media-logos/threads_icon.png' }, - { id: 'mastodon', label: 'Mastodon', customIconSrc: 'social-media-logos/mastodon_icon.png' }, - { id: 'bluesky', label: 'BlueSky', customIconSrc: 'social-media-logos/bluesky_icon.png' }, - { id: 'youtube', label: 'Youtube', customIconSrc: 'social-media-logos/youtube_icon.png' }, - { id: 'reddit', label: 'Reddit', customIconSrc: 'social-media-logos/reddit_icon.png' }, - { id: 'tumblr', label: 'Tumblr', customIconSrc: 'social-media-logos/tumblr_icon.png' }, - { id: 'imgur', label: 'Imgur', customIconSrc: 'social-media-logos/imgur_icon.png' }, - { id: 'myspace', label: 'Myspace', customIconSrc: 'social-media-logos/myspace_icon.png' }, - { id: 'medium', icon: faMedium, label: 'Medium' }, - { id: 'plurk', label: 'Plurk', customIconSrc: 'social-media-logos/plurk_icon.png' }, - { - id: 'livejournal', - label: 'LiveJournal', - customIconSrc: 'social-media-logos/liveJournal_icon.png', - }, - { id: 'slashdot', label: 'Slashdot', customIconSrc: 'social-media-logos/slashdot_icon.png' }, - { id: 'blogger', label: 'Blogger', customIconSrc: 'social-media-logos/blogger_icon.png' }, - { - id: 'truthsocial', - label: 'Truth Social', - customIconSrc: 'social-media-logos/truthsocial_icon.png', - }, - { id: 'email', icon: faEnvelope, label: 'Email' }, - ]; + const addHeaderToEmailContent = () => { + if (editorRef.current && headerContent) { + const content = editorRef.current.getContent(); + editorRef.current.setContent(headerContent + content); + setHeaderContent(''); + } + }; - const columns = Math.ceil(tabs.length / 2); - const gridStyle = { - gridTemplateColumns: `repeat(${columns}, minmax(120px, 1fr))`, - padding: '1rem', - borderBottom: darkMode ? '1px solid #2b3b50' : '1px solid #ccc', + const addImageToEmailContent = (e) => { + const file = e.target.files[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + if (editorRef.current) { + const content = editorRef.current.getContent(); + editorRef.current.setContent(content + `Uploaded Image`); + } + }; + reader.readAsDataURL(file); + } }; return (
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - + {title ? ( +

Email

+ ) : ( + + )} + + - {[ - 'x', - 'facebook', - 'linkedin', - 'pinterest', - 'instagram', - 'threads', - 'mastodon', - 'bluesky', - 'youtube', - 'reddit', - 'tumblr', - 'imgur', - 'diigo', - 'myspace', - 'medium', - 'plurk', - 'bitily', - 'livejournal', - 'slashdot', - 'blogger', - ].map(platform => ( - - - - ))} -
+
+ + + +
+ +
); From fef6bdb58b3ddd6a344c9102fe9e1f75133f9058 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Fri, 13 Jun 2025 01:11:18 -0700 Subject: [PATCH 04/34] Final commit before schedule_post feature --- src/assets/images/BlueskyIcon.svg | 4 + .../Announcements/Announcements.css | 216 ++++++ .../Announcements/BlueskyPostDetails.css | 133 ++++ .../Announcements/BlueskyPostDetails.jsx | 665 ++++++++++++++++++ src/components/Announcements/index.jsx | 279 ++++++-- src/config/api.js | 13 + 6 files changed, 1241 insertions(+), 69 deletions(-) create mode 100644 src/assets/images/BlueskyIcon.svg create mode 100644 src/components/Announcements/Announcements.css create mode 100644 src/components/Announcements/BlueskyPostDetails.css create mode 100644 src/components/Announcements/BlueskyPostDetails.jsx create mode 100644 src/config/api.js diff --git a/src/assets/images/BlueskyIcon.svg b/src/assets/images/BlueskyIcon.svg new file mode 100644 index 0000000000..c71e2018a6 --- /dev/null +++ b/src/assets/images/BlueskyIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/Announcements/Announcements.css b/src/components/Announcements/Announcements.css new file mode 100644 index 0000000000..a9eb2b7134 --- /dev/null +++ b/src/components/Announcements/Announcements.css @@ -0,0 +1,216 @@ +/* EmailUpdateComponent.css */ + + +h2, +h3 { + color: #4285f4; +} + +label { + display: block; + margin-bottom: 5px; + font-weight: bold; + color: #333; +} + +.text-light { + color: #fff; + /* White text for dark mode */ +} + +.text-dark { + color: #333; + /* Default dark text for light mode */ +} + +.input-text-for-announcement, +textarea { + margin-top: 5px; + /* Space above input fields */ + width: 100%; + padding: 12px; + /* Increased padding */ + border: 1px solid #ccc; + border-radius: 4px; + box-sizing: border-box; + /* Ensures padding is included in width */ +} + +.input-file-upload { + margin-top: 5px; +} + +button.send-button { + background-color: #4285f4; + color: white; + border: none; + border-radius: 4px; + padding: 12px 24px; + /* Increased padding */ + cursor: pointer; + margin-top: 20px; + box-sizing: border-box; + /* Ensures padding is included in width */ +} + +button.send-button:hover { + background-color: #357ae8; +} + +.ck-editor__editable { + min-height: 500px; +} + +.email-update-container { + padding: 70px; + display: flex; + justify-content: space-between; + align-items: flex-start; + box-sizing: border-box; + /* Ensures padding is included in width */ +} + +.editor { + flex: 1; + /* Editor takes up remaining space */ + margin-right: 20px; + /* Add some spacing between editor and email inputs */ +} + +.editor h2 { + margin-top: 0; + /* Remove top margin for the heading */ +} + +.emails { + margin-top: 65px; + padding: 24px; + /* Increased padding */ + border: solid 1px #cacaca; + border-radius: 20px; + width: 300px; + box-sizing: border-box; + /* Ensures padding is included in width */ + /* Set a fixed width for the email inputs container */ +} + +.emails h3 { + margin-top: 0; + /* Remove top margin for the email input headings */ +} + +.email-content-preview-section { + margin: 2rem; + color: #333; + border: 1px solid #ccc; + border-radius: 5px; +} + +.email-content-preview { + padding: 20px; + margin-top: 20px; + position: relative; +} + +.email-content-preview-title { + background: #fff; + color: #000; + padding: 0 10px; + font-size: 18px; +} + +/* Responsive design: Adjusts the preview section on smaller screens. */ +@media (max-width: 768px) { + .email-content-preview { + margin-top: 10px; + /* Reduce margin top on smaller screens */ + } +} + +/* Responsive design: Adjust the layout for smaller screens */ +@media (max-width: 800px) { + .email-update-container { + display: flex; + flex-direction: column; + padding: 20px; + box-sizing: border-box; + /* Ensures padding is included in width */ + } + + .editor { + width: 100%; + margin-right: 0; + margin-bottom: 20px; + } + + .emails { + width: 100%; + margin-top: 20px; + padding: 20px 12px; + /* Adjust padding for smaller screens */ + box-sizing: border-box; + /* Ensures padding is included in width */ + } + + .send-button { + width: 100%; + margin-bottom: 10px; + padding: 12px; + /* Ensures padding is consistent */ + box-sizing: border-box; + /* Ensures padding is included in width */ + } + + .input-text-for-announcement, + textarea { + width: 100%; + padding: 12px; + /* Ensures padding is consistent */ + box-sizing: border-box; + /* Ensures padding is included in width */ + } +} + +/* ========================= */ +/* LINH - Bluesky Header CSS */ +/* ========================= */ + +/* Add to Announcements.css */ +/* ========================= */ +/* LINH - Bluesky Header CSS */ +/* ========================= */ + +.bluesky-header { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 50px; +} + +.bluesky-header-button { + display: flex; + align-items: center; + gap: 10px; + background: none; + border: none; + cursor: pointer; +} + +.bluesky-icon { + width: 36px; + height: 36px; + border-radius: 8px; + background-color: white; + padding: 4px; +} + +.bluesky-label { + font-weight: 600; + font-size: 16px; + color: #4285f4; +} + +/* Dark mode override (optional) */ +body.dark-mode .bluesky-label { + color: #ffffff; +} \ No newline at end of file diff --git a/src/components/Announcements/BlueskyPostDetails.css b/src/components/Announcements/BlueskyPostDetails.css new file mode 100644 index 0000000000..692fbbc6e3 --- /dev/null +++ b/src/components/Announcements/BlueskyPostDetails.css @@ -0,0 +1,133 @@ +/* BlueskyPostDetails.css */ + +.bluesky-post-details { + max-width: 700px; + margin: 40px auto; + padding: 30px; + background-color: #ffffff; + border-radius: 16px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.08); + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + transition: all 0.3s ease; +} + +.bluesky-post-details h2, +.bluesky-post-details h4 { + margin-bottom: 16px; + color: #2c3e50; +} + +.bluesky-post-details input, +.bluesky-post-details textarea { + width: 100%; + padding: 12px 14px; + margin-bottom: 16px; + border: 1px solid #dcdfe6; + border-radius: 8px; + font-size: 16px; + background-color: #f9f9f9; + transition: border-color 0.3s ease; +} + +.bluesky-post-details input:focus, +.bluesky-post-details textarea:focus { + outline: none; + border-color: #409eff; + background-color: #ffffff; +} + +.bluesky-post-details button { + padding: 12px 24px; + background-color: #409eff; + color: #fff; + border: none; + border-radius: 6px; + font-weight: bold; + font-size: 15px; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.bluesky-post-details button:hover { + background-color: #409eff; +} + +.post-media { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-top: 20px; +} + +.image-container, +.video-container, +.gif-container { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + overflow: hidden; + border-radius: 10px; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08); + margin-bottom: 20px; +} + +.media-button { + padding: 0; + margin: 0; + border: none; + background: none; + width: 100%; + height: 100%; + cursor: pointer; + transition: transform 0.2s ease; + border-radius: 10px; + overflow: hidden; +} + +.media-button:hover { + transform: scale(1.03); +} + +.media-button:focus { + outline: 2px solid #dcdfe6; + outline-offset: 2px; +} + +.media-button .media-item { + cursor: inherit; +} + +.media-button:hover .media-item { + transform: none; +} + +.media-item { + width: 100%; + height: 100%; + object-fit: cover; +} + +.video-container video { + width: 100%; + height: 100%; + object-fit: contain; + background: #000; +} + +.gif-overlay { + position: absolute; + top: 8px; + right: 8px; + z-index: 1; +} + +.gif-badge { + display: inline-block; + padding: 3px 8px; + background: rgba(0, 0, 0, 0.75); + color: #fff; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + letter-spacing: 0.5px; +} diff --git a/src/components/Announcements/BlueskyPostDetails.jsx b/src/components/Announcements/BlueskyPostDetails.jsx new file mode 100644 index 0000000000..c712a63604 --- /dev/null +++ b/src/components/Announcements/BlueskyPostDetails.jsx @@ -0,0 +1,665 @@ +// Bluesky Post Details Component +import { useState, useEffect, useRef } from 'react'; +import { Container, Form, Button, Card, Alert, Spinner, Modal } from 'react-bootstrap'; +import './BlueskyPostDetails.css'; + +function formatDate(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffInMinutes = Math.floor((now - date) / (1000 * 60)); + + if (diffInMinutes < 1) return 'Just now'; + if (diffInMinutes < 60) return `${diffInMinutes}m ago`; + + const diffInHours = Math.floor(diffInMinutes / 60); + if (diffInHours < 24) return `${diffInHours}h ago`; + + const diffInDays = Math.floor(diffInHours / 24); + if (diffInDays < 7) return `${diffInDays}d ago`; + + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined, + }); +} + +function MediaDisplay({ media }) { + if (!media || media.length === 0) return null; + + return ( +
+ {media.map(item => { + // Generate a unique key based on the media URL or thumb URL + const mediaKey = item.url || item.thumb; + + if (item.type === 'image') { + return ( +
+ +
+ ); + } + if (item.type === 'video') { + return ( +
+ + {item.title && {item.title}} +
+ ); + } + if (item.type === 'gif') { + return ( +
+
+ + {item.thumb && ( +
+ GIF +
+ )} +
+ {item.title && {item.title}} +
+ ); + } + return null; + })} +
+ ); +} + +function BlueskyPostDetails() { + const [handle, setHandle] = useState(''); + const [appPassword, setAppPassword] = useState(''); + const [isConnected, setIsConnected] = useState(false); + const [postText, setPostText] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const [status, setStatus] = useState(''); + const [posts, setPosts] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [deletingPost, setDeletingPost] = useState(null); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [postToDelete, setPostToDelete] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const dropZoneRef = useRef(null); + + const checkSession = async () => { + try { + const response = await fetch('http://localhost:4500/api/bluesky/session', { + credentials: 'include', + }); + const result = await response.json(); + + if (result.success && result.isConnected) { + setIsConnected(true); + setHandle(result.handle || ''); + // Don't set password as it's not sent back from server + if (!status) setStatus('✅ Connected to Bluesky'); + } else { + setIsConnected(false); + if (isConnected) setStatus('Session expired. Please reconnect.'); + } + } catch (error) { + setIsConnected(false); + } + }; + + // Check session status on mount and periodically + useEffect(() => { + checkSession(); + // Check session every 5 minutes + const intervalId = setInterval(checkSession, 5 * 60 * 1000); + return () => clearInterval(intervalId); + }, []); + + const handleDragEnter = e => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }; + + const handleDragLeave = e => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + }; + + const handleDragOver = e => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = e => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); + + const { files } = e.dataTransfer; + if (files && files.length > 0) { + const file = files[0]; + if (file.type.startsWith('image/')) { + const isGif = file.type === 'image/gif'; + if (file.size > (isGif ? 5000000 : 1000000)) { + // 5MB for GIFs, 1MB for other images + setStatus( + `❌ ${isGif ? 'GIF' : 'Image'} size must be less than ${isGif ? '5MB' : '1MB'}`, + ); + return; + } + setSelectedImage(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + } else { + setStatus('❌ Only image files are allowed'); + } + } + }; + + const handleImageSelect = e => { + const file = e.target.files[0]; + if (file) { + const isGif = file.type === 'image/gif'; + if (file.size > (isGif ? 5000000 : 1000000)) { + // 5MB for GIFs, 1MB for other images + setStatus(`❌ ${isGif ? 'GIF' : 'Image'} size must be less than ${isGif ? '5MB' : '1MB'}`); + return; + } + setSelectedImage(file); + const reader = new FileReader(); + reader.onloadend = () => { + setImagePreview(reader.result); + }; + reader.readAsDataURL(file); + } + }; + + const connectToBluesky = async () => { + if (!handle || !appPassword) { + setStatus('❌ Handle and password are required'); + return; + } + + try { + setStatus('🔄 Connecting...'); + const response = await fetch('http://localhost:4500/api/bluesky/connect', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', // Important: include credentials + body: JSON.stringify({ + handle, + password: appPassword, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + if (result.success) { + setIsConnected(true); + setStatus(`✅ Connected to Bluesky! DID: ${result.did}`); + } else { + setStatus(`${result.error || 'Failed to connect'}`); + } + } catch (error) { + setStatus(`${error.message}`); + } + }; + + const disconnectFromBluesky = async () => { + try { + setStatus('🔄 Disconnecting...'); + const response = await fetch('http://localhost:4500/api/bluesky/disconnect', { + method: 'POST', + credentials: 'include', + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + setIsConnected(false); + setHandle(''); + setAppPassword(''); + setPostText(''); + setStatus('✅ Disconnected from Bluesky'); + } catch (error) { + setStatus(`${error.message}`); + } + }; + + const getPosts = async () => { + setIsLoading(true); + try { + const response = await fetch('http://localhost:4500/api/bluesky/posts', { + credentials: 'include', + }); + const result = await response.json(); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + if (result.success) { + setPosts(result.posts); + setStatus(result.posts.length ? '✅ Posts loaded!' : 'ℹ️ No posts found'); + } else { + setStatus(`${result.error || 'Failed to load posts'}`); + } + } catch (error) { + setStatus(`${error.message}`); + + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + setIsConnected(false); + setStatus('❌ Session expired. Please reconnect.'); + } + } finally { + setIsLoading(false); + } + }; + + const clearImage = () => { + setSelectedImage(null); + setImagePreview(null); + }; + + const createPost = async () => { + if (!isConnected) { + setStatus('❌ Please connect to Bluesky first'); + return; + } + + const trimmedText = postText.trim(); + if (!trimmedText && !selectedImage) { + setStatus('❌ Please provide text or an image for the post'); + return; + } + + try { + setStatus('🔄 Creating post...'); + const formData = new FormData(); + formData.append('text', trimmedText); // Will be empty string if no text + + if (selectedImage) { + formData.append('image', selectedImage); + } + + const response = await fetch('http://localhost:4500/api/bluesky/post', { + method: 'POST', + credentials: 'include', + body: formData, // Let browser set the Content-Type with boundary + }); + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Failed to create post'); + } + + setStatus('✅ Posted successfully!'); + setPostText(''); + clearImage(); + getPosts(); + } catch (error) { + setStatus(`${error.message}`); + + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + setIsConnected(false); + setStatus('❌ Session expired. Please reconnect.'); + } + } + }; + + const handleDelete = uri => { + setPostToDelete(uri); + setShowDeleteModal(true); + }; + + const confirmDelete = async () => { + if (!postToDelete) return; + + try { + setShowDeleteModal(false); + setDeletingPost(postToDelete); + setStatus('🔄 Deleting post...'); + const response = await fetch( + `http://localhost:4500/api/bluesky/post/${encodeURIComponent(postToDelete)}`, + { + method: 'DELETE', + credentials: 'include', + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || `HTTP error! status: ${response.status}`); + } + + const result = await response.json(); + + if (result.success) { + setStatus('✅ Post deleted successfully!'); + // Remove the deleted post from the list + setPosts(posts.filter(post => post.uri !== postToDelete)); + } else { + setStatus(`${result.error || 'Failed to delete post'}`); + } + } catch (error) { + setStatus(`${error.message}`); + + if (error.message.includes('401') || error.message.includes('Unauthorized')) { + setIsConnected(false); + setStatus('❌ Session expired. Please reconnect.'); + } + } finally { + setDeletingPost(null); + setPostToDelete(null); + } + }; + + const cancelDelete = () => { + setShowDeleteModal(false); + setPostToDelete(null); + }; + + const viewPost = uri => { + try { + if (!uri || !uri.startsWith('at://')) { + return; + } + + const parts = uri.split('/'); + const rkey = parts[parts.length - 1]; + + const didMatch = uri.match(/at:\/\/(did:[^/]+)/); + if (!didMatch) { + return; + } + + const did = didMatch[1]; + const url = `https://bsky.app/profile/${did}/post/${rkey}`; + window.open(url, '_blank'); + } catch (error) { + setStatus(`${error.message}`); + } + }; + + useEffect(() => { + if (isConnected) { + getPosts(); + } else { + setPosts([]); + } + }, [isConnected]); + + const getStatusVariant = () => { + if (status.includes('✅')) return 'success'; + if (status.includes('❌')) return 'danger'; + return 'info'; + }; + + return ( + +

🔵 Bluesky Manager

+ {!isConnected ? ( + +

🔐 Connect to Bluesky

+
+ + setHandle(e.target.value)} + /> + + + setAppPassword(e.target.value)} + /> + + +
+
+ ) : ( + <> + +
+

📝 Create a New Post

+ +
+ + setPostText(e.target.value)} + placeholder="What's on your mind?" + /> + + {/* Drag & Drop Image Upload */} +
+
+ +

Drag and drop an image here, or

+ + Max file size: 1MB (5MB for GIFs) +
+
+ {/* Image Preview */} + {imagePreview && ( +
+ Preview + +
+ )} + +
+ + +
+

📜 Your Posts

+ +
+ {posts.length > 0 ? ( +
+ {posts.map(post => ( + + + {post.text} + +
+
+ + ❤️ {post.likeCount} • 🔁 {post.repostCount} + + • {formatDate(post.createdAt)} +
+
+ + +
+
+
+
+ ))} +
+ ) : ( + + {isLoading ? '🔄 Loading posts...' : 'No posts yet'} + + )} +
+ + )} + {status && ( + + {status} + + )} + {/* Delete confirmation modal */} + + + Delete Post + + + Are you sure you want to delete this post? This action cannot be undone. + + + + + + +
+ ); +} + +export default BlueskyPostDetails; + +; diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index aff85c38b6..5c73854580 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -6,6 +6,8 @@ import { Editor } from '@tinymce/tinymce-react'; import { boxStyle, boxStyleDark } from 'styles'; import { toast } from 'react-toastify'; import { sendEmail, broadcastEmailsToAll } from '../../actions/sendEmails'; +import BlueskyPostDetails from './BlueskyPostDetails'; +import BlueskyIcon from '../../assets/images/BlueskyIcon.svg'; function Announcements({ title, email: initialEmail }) { const darkMode = useSelector(state => state.theme.darkMode); @@ -13,29 +15,61 @@ function Announcements({ title, email: initialEmail }) { const [emailTo, setEmailTo] = useState(initialEmail || ''); const [headerContent, setHeaderContent] = useState(''); - const editorRef = useRef(null); + const [showEditor, setShowEditor] = useState(true); + const [isFileUploaded, setIsFileUploaded] = useState(false); + const tinymce = useRef(null); + const [showBluesky, setShowBluesky] = useState(false); + const [showEmailSection, setShowEmailSection] = useState(true); + const [emailContent, setEmailContent] = useState(''); + const [emailList, setEmailList] = useState([]); - const handleEmailListChange = (e) => setEmailTo(e.target.value); + const editorInit = { + height: 500, + menubar: false, + plugins: 'lists link image code', + toolbar: 'bold italic underline | bullist numlist | link image | code' + }; + + const validateEmail = (email) => { + const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return re.test(email); + }; + + const handleEmailListChange = (e) => { + const value = e.target.value; + setEmailTo(value); + setEmailList(value.split(',').map(email => email.trim()).filter(email => email)); + }; const handleHeaderContentChange = (e) => setHeaderContent(e.target.value); const handleSendEmails = () => { - if (!emailTo.trim()) { - toast.error('Please enter at least one email address.'); + const htmlContent = ` +
+ ${emailContent} +
+ `; + + if (!isFileUploaded) { + toast.error('Error: Please upload a file.'); return; } - const emails = emailTo.split(',').map(email => email.trim()).filter(email => email); - if (emails.length === 0) { - toast.error('Please enter valid email addresses.'); + + const invalidEmails = emailList.filter(address => !validateEmail(address.trim())); + + if (invalidEmails.length > 0) { + toast.error(`Error: Invalid email addresses: ${invalidEmails.join(', ')}`); return; } - dispatch(sendEmail(emails, editorRef.current ? editorRef.current.getContent() : '')); - toast.success('Emails sent successfully!'); + + dispatch( + sendEmail(emailList.join(','), title ? 'Anniversary congrats' : 'Weekly update', htmlContent), + ); }; const addHeaderToEmailContent = () => { - if (editorRef.current && headerContent) { - const content = editorRef.current.getContent(); - editorRef.current.setContent(headerContent + content); + if (tinymce.current && headerContent) { + const content = tinymce.current.getContent(); + tinymce.current.setContent(headerContent + content); setHeaderContent(''); } }; @@ -45,76 +79,183 @@ function Announcements({ title, email: initialEmail }) { if (file) { const reader = new FileReader(); reader.onload = (event) => { - if (editorRef.current) { - const content = editorRef.current.getContent(); - editorRef.current.setContent(content + `Uploaded Image`); + if (tinymce.current) { + const content = tinymce.current.getContent(); + tinymce.current.setContent(content + `Uploaded Image`); } + setIsFileUploaded(true); }; reader.readAsDataURL(file); } }; +<<<<<<< HEAD +======= + + if (!isFileUploaded) { + toast.error('Error: Please upload a file.'); + return; + } + + const invalidEmails = emailList.filter(address => !validateEmail(address.trim())); + + if (invalidEmails.length > 0) { + toast.error(`Error: Invalid email addresses: ${invalidEmails.join(', ')}`); + return; + } + + dispatch( + sendEmail(emailList.join(','), title ? 'Anniversary congrats' : 'Weekly update', htmlContent), + ); + }; + + const handleBroadcastEmails = () => { + const htmlContent = ` +
+ ${emailContent} +
+ `; + dispatch(broadcastEmailsToAll('Weekly Update', htmlContent)); +>>>>>>> 10b5d7951 (Final commit before schedule_post feature) + }; return (
-
- {title ? ( -

Email

- ) : ( - - )} - + {/* Bluesky Header */} +
- -
- - - -
- -
+ + {!showBluesky && ( +
+
+ {title ?

{title}

:

Weekly Progress Editor

} +
+ {showEditor && ( + { + setEmailContent(content); + }} + onInit={(evt, editor) => tinymce.current = editor} + /> + )} + {title ? ( + '' + ) : ( +
+
+
+ +
+
+ + +
+
+ +
+
+ + +
+
+
+ )} +
+
+ {title ? ( +

Email

+ ) : ( + + )} + + + +
+ + + +
+ + +
+
+ )} + + {/* show BlueskyPostDetails */} + {showBluesky && ( + setShowBluesky(false)} /> + )}
); } diff --git a/src/config/api.js b/src/config/api.js new file mode 100644 index 0000000000..f563e1dd63 --- /dev/null +++ b/src/config/api.js @@ -0,0 +1,13 @@ +const API_BASE_URL = + process.env.NODE_ENV === 'production' + ? 'https://your-production-api.com' // Replace with your production API URL + : 'http://localhost:3000'; + +export const ENDPOINTS = { + BLUESKY: { + CONNECT: `${API_BASE_URL}/api/bluesky/connect`, + POST: `${API_BASE_URL}/api/bluesky/post`, + }, +}; + +export default API_BASE_URL; From 3ef0e44847f79fb66a4e66e23140932500136893 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Tue, 17 Mar 2026 00:06:52 -0700 Subject: [PATCH 05/34] Fix import path for styles in Announcements component --- src/components/Announcements/index.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index 8be8e26e11..675b30716d 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef } from 'react'; import './Announcements.css'; import { useDispatch, useSelector } from 'react-redux'; import { Editor } from '@tinymce/tinymce-react'; -import { boxStyle, boxStyleDark } from 'styles'; +import { boxStyle, boxStyleDark } from '../../styles'; import { toast } from 'react-toastify'; import { sendEmail, broadcastEmailsToAll } from '../../actions/sendEmails'; import BlueskyPostDetails from './BlueskyPostDetails'; From 396ee377e6b3c0154bbfdbc94ac597be3e773a37 Mon Sep 17 00:00:00 2001 From: Linh Huynh Date: Tue, 17 Mar 2026 00:20:39 -0700 Subject: [PATCH 06/34] Fix linting errors: convert to CSS modules and remove styled-jsx --- .../Announcements/BlueskyPostDetails.jsx | 48 +++++++++---------- ...ails.css => BlueskyPostDetails.module.css} | 31 +++++++----- 2 files changed, 42 insertions(+), 37 deletions(-) rename src/components/Announcements/{BlueskyPostDetails.css => BlueskyPostDetails.module.css} (87%) diff --git a/src/components/Announcements/BlueskyPostDetails.jsx b/src/components/Announcements/BlueskyPostDetails.jsx index c712a63604..b3df269f72 100644 --- a/src/components/Announcements/BlueskyPostDetails.jsx +++ b/src/components/Announcements/BlueskyPostDetails.jsx @@ -1,7 +1,7 @@ // Bluesky Post Details Component import { useState, useEffect, useRef } from 'react'; import { Container, Form, Button, Card, Alert, Spinner, Modal } from 'react-bootstrap'; -import './BlueskyPostDetails.css'; +import styles from './BlueskyPostDetails.module.css'; function formatDate(dateString) { const date = new Date(dateString); @@ -28,24 +28,24 @@ function MediaDisplay({ media }) { if (!media || media.length === 0) return null; return ( -
+
{media.map(item => { // Generate a unique key based on the media URL or thumb URL const mediaKey = item.url || item.thumb; if (item.type === 'image') { return ( -
+
@@ -54,8 +54,13 @@ function MediaDisplay({ media }) { } if (item.type === 'video') { return ( -
-