diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000000..31e47f6162 --- /dev/null +++ b/.yarnrc @@ -0,0 +1,2 @@ +# Avoid stale persisted package tarballs in CI/Netlify. +cache-folder "/tmp/hgn-yarn-cache" diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index 38d6a23376..b342bc9643 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -1,11 +1,8 @@ /* 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, @@ -16,8 +13,11 @@ import { } 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'; // ← new +import styles from './Announcements.module.css'; +import SocialMediaComposer from './SocialMediaComposer'; +import TruthSocialAutoPoster from '../AutoPoster/TruthSocialAutoPoster'; +import EmailPanel from './platforms/email'; +import LinkedInAutoPoster from './platforms/linkedin'; import SlashdotAutoPoster from './platforms/slashdot'; function Announcements({ title, email: initialEmail }) { @@ -146,6 +146,10 @@ function Announcements({ title, email: initialEmail }) { + + + + @@ -153,7 +157,6 @@ function Announcements({ title, email: initialEmail }) { {[ 'x', 'facebook', - 'linkedin', 'pinterest', 'instagram', 'threads', @@ -171,7 +174,6 @@ function Announcements({ title, email: initialEmail }) { 'livejournal', 'slashdot', 'blogger', - 'truthsocial', ].map(platform => { const PlatformComposer = platform === 'slashdot' ? SlashdotAutoPoster : SocialMediaComposer; diff --git a/src/components/Announcements/platforms/linkedin/index.jsx b/src/components/Announcements/platforms/linkedin/index.jsx new file mode 100644 index 0000000000..df06849edb --- /dev/null +++ b/src/components/Announcements/platforms/linkedin/index.jsx @@ -0,0 +1,527 @@ +import { useEffect, useState } from 'react'; +import axios from 'axios'; +import { toast } from 'react-toastify'; +import { Calendar, Edit, Loader, Trash2, Upload, X } from 'lucide-react'; +import { boxStyle, boxStyleDark } from '~/styles'; +import { ENDPOINTS } from '../../../../utils/URL'; +import styles from '../../Announcements.module.css'; + +const MAX_IMAGES = 9; +const MAX_VIDEOS = 1; +const MAX_IMAGE_SIZE = 5 * 1024 * 1024; +const MAX_VIDEO_SIZE = 200 * 1024 * 1024; + +const getPreviewId = file => + window.crypto?.randomUUID?.() || `${file.name}-${file.size}-${Date.now()}`; + +const getFieldStyle = darkMode => ({ + backgroundColor: darkMode ? '#1f2937' : '#fff', + border: `1px solid ${darkMode ? '#4b5563' : '#d1d5db'}`, + borderRadius: '0.5rem', + color: darkMode ? '#fff' : '#111827', + width: '100%', +}); + +const getCardStyle = darkMode => ({ + backgroundColor: darkMode ? '#0f1e33' : '#f8fafc', + border: `1px solid ${darkMode ? '#324563' : '#d9e2ec'}`, + borderRadius: '0.75rem', + padding: '1rem', +}); + +export default function LinkedInAutoPoster({ darkMode }) { + const [linkedinContent, setLinkedinContent] = useState(''); + const [mediaFiles, setMediaFiles] = useState([]); + const [previews, setPreviews] = useState([]); + const [scheduleDate, setScheduleDate] = useState(''); + const [scheduleTime, setScheduleTime] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [isCanceling, setIsCanceling] = useState(false); + const [scheduledPosts, setScheduledPosts] = useState([]); + const [editingJobId, setEditingJobId] = useState(null); + + useEffect(() => { + fetchScheduledPosts(); + }, []); + + const resetLinkedInForm = () => { + setLinkedinContent(''); + setMediaFiles([]); + setPreviews([]); + setScheduleDate(''); + setScheduleTime(''); + setEditingJobId(null); + }; + + const fetchScheduledPosts = async () => { + try { + const response = await axios.get(ENDPOINTS.LINKEDIN_SCHEDULED_POSTS); + if (response.data.success) { + setScheduledPosts(response.data.scheduledPosts); + return; + } + + throw new Error(response.data.message || 'Failed to fetch scheduled posts'); + } catch (error) { + toast.error(error.response?.data?.message || 'Failed to fetch scheduled posts'); + } + }; + + const handleMediaUpload = event => { + const files = Array.from(event.target.files || []); + if (files.length === 0) { + return; + } + + const existingVideoCount = mediaFiles.filter(file => file.type.includes('video')).length; + const existingImageCount = mediaFiles.filter(file => file.type.includes('image')).length; + const newVideoCount = files.filter(file => file.type.includes('video')).length; + const newImageCount = files.filter(file => file.type.includes('image')).length; + + if (existingVideoCount + newVideoCount > MAX_VIDEOS) { + toast.error('Only one video file is allowed per post'); + return; + } + + if (existingImageCount + newImageCount > MAX_IMAGES) { + toast.error('Maximum 9 images allowed per post'); + return; + } + + if ( + (newVideoCount > 0 && (existingImageCount > 0 || newImageCount > 0)) || + (existingVideoCount > 0 && newImageCount > 0) + ) { + toast.error('Cannot mix videos and images in the same post'); + return; + } + + const validFiles = files.filter(file => { + const maxSize = file.type.includes('video') ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE; + if (file.size > maxSize) { + toast.error(`File "${file.name}" exceeds the upload size limit`); + return false; + } + return true; + }); + + validFiles.forEach(file => { + const reader = new FileReader(); + reader.onload = () => { + setPreviews(previous => [ + ...previous, + { + id: getPreviewId(file), + url: reader.result, + type: file.type, + }, + ]); + }; + reader.readAsDataURL(file); + }); + + setMediaFiles(previous => [...previous, ...validFiles]); + event.target.value = ''; + }; + + const removeMedia = index => { + setMediaFiles(previous => previous.filter((_, previewIndex) => previewIndex !== index)); + setPreviews(previous => previous.filter((_, previewIndex) => previewIndex !== index)); + }; + + const handleScheduleDateChange = event => { + const nextDate = event.target.value; + setScheduleDate(nextDate); + + if (!nextDate) { + setScheduleTime(''); + return; + } + + const twoMinutesFromNow = new Date(Date.now() + 2 * 60 * 1000); + const hours = String(twoMinutesFromNow.getHours()).padStart(2, '0'); + const minutes = String(twoMinutesFromNow.getMinutes()).padStart(2, '0'); + setScheduleTime(`${hours}:${minutes}`); + }; + + const handleScheduleTimeChange = event => { + const nextTime = event.target.value; + const scheduledDateTime = new Date(`${scheduleDate}T${nextTime}`); + const twoMinutesFromNow = new Date(Date.now() + 2 * 60 * 1000); + + if (scheduledDateTime < twoMinutesFromNow) { + const hours = String(twoMinutesFromNow.getHours()).padStart(2, '0'); + const minutes = String(twoMinutesFromNow.getMinutes()).padStart(2, '0'); + setScheduleTime(`${hours}:${minutes}`); + toast.info('Time automatically adjusted to 2 minutes from now'); + return; + } + + setScheduleTime(nextTime); + }; + + const handlePostToLinkedIn = async () => { + if (!linkedinContent.trim()) { + toast.error('Post content cannot be empty'); + return; + } + + if (scheduleDate && scheduleTime) { + const scheduledDateTime = new Date(`${scheduleDate}T${scheduleTime}`); + if (scheduledDateTime <= new Date()) { + toast.error('Schedule time must be in the future'); + return; + } + } + + setIsLoading(true); + + try { + const formData = new FormData(); + formData.append('content', linkedinContent.trim()); + + if (scheduleDate && scheduleTime) { + const scheduledDateTime = new Date(`${scheduleDate}T${scheduleTime}`); + formData.append('scheduleTime', scheduledDateTime.toISOString()); + } + + mediaFiles.forEach(file => { + formData.append('media', file); + }); + + const response = await axios.post(ENDPOINTS.LINKEDIN_POST, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + maxContentLength: Infinity, + maxBodyLength: Infinity, + }); + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to post to LinkedIn'); + } + + if (editingJobId) { + await axios.delete(ENDPOINTS.LINKEDIN_SCHEDULED_POST_BY_ID(editingJobId)); + } + + toast.success( + scheduleDate && scheduleTime + ? `Post scheduled for ${new Date(`${scheduleDate}T${scheduleTime}`).toLocaleString()}` + : 'Posted successfully to LinkedIn', + ); + + resetLinkedInForm(); + fetchScheduledPosts(); + } catch (error) { + toast.error(error.response?.data?.message || 'Failed to post to LinkedIn'); + } finally { + setIsLoading(false); + } + }; + + const handleDeleteScheduledPost = async jobId => { + // Local confirm is acceptable here because the action is destructive and immediate. + // eslint-disable-next-line no-alert + if (!window.confirm('Are you sure you want to delete this post?')) { + return; + } + + setIsCanceling(true); + + try { + await axios.delete(ENDPOINTS.LINKEDIN_SCHEDULED_POST_BY_ID(jobId)); + toast.success('Post deleted successfully'); + fetchScheduledPosts(); + } catch (error) { + toast.error(error.response?.data?.message || 'Failed to delete post'); + } finally { + setIsCanceling(false); + } + }; + + const loadScheduledPostForEditing = post => { + const scheduledDateTime = new Date(post.scheduleTime); + setLinkedinContent(post.content); + setScheduleDate(scheduledDateTime.toISOString().split('T')[0]); + setScheduleTime(scheduledDateTime.toTimeString().slice(0, 5)); + setEditingJobId(post.jobId); + toast.info('Post loaded for editing'); + }; + + const todayDate = new Date().toLocaleDateString('en-CA'); + const fieldStyle = getFieldStyle(darkMode); + const cardStyle = getCardStyle(darkMode); + + return ( +
+
+

LinkedIn Post Editor

+