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 (
+
+
+
+
+ Scheduled Posts
+ {scheduledPosts.length === 0 ? (
+
+ No posts scheduled yet.
+
+ ) : (
+
+ {scheduledPosts.map(post => (
+
+
+
+
+ Content: {post.content}
+
+
+ Scheduled Time:{' '}
+ {new Date(post.scheduleTime).toLocaleString()}
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ );
+}
diff --git a/src/utils/URL.js b/src/utils/URL.js
index 7bc141383e..c7a9f59199 100644
--- a/src/utils/URL.js
+++ b/src/utils/URL.js
@@ -573,6 +573,11 @@ export const ENDPOINTS = {
PROMOTION_ELIGIBILITY: `${APIEndpoint}/promotion-eligibility`,
PROMOTE_MEMBERS: `${APIEndpoint}/promote-members`,
+ // LinkedIn autoposter endpoints
+ LINKEDIN_POST: `${APIEndpoint}/postToLinkedIn`,
+ LINKEDIN_SCHEDULED_POSTS: `${APIEndpoint}/scheduledPosts`,
+ LINKEDIN_SCHEDULED_POST_BY_ID: postId => `${APIEndpoint}/scheduledPosts/${postId}`,
+
// actual cost endpoints
ACTUAL_COST_BREAKDOWN: projectId => `${APIEndpoint}/projects/${projectId}/actual-cost-breakdown`,
MATERIAL_UTILIZATION: () => `${APIEndpoint}/materials/utilization`,
@@ -615,7 +620,6 @@ export const ENDPOINTS = {
if (roles && roles.length > 0) url += `roles=${encodeURIComponent(roles.join(','))}&`;
return url.slice(0, -1);
},
-
};
export const ApiEndpoint = APIEndpoint;