diff --git a/package.json b/package.json index c27b922097..a4a2d13369 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "react-to-print": "^3.0.5", "react-toastify": "^5.3.1", "react-tooltip": "^4.5.1", + "react-transition-group": "^4.4.5", "react-use-websocket": "^3.0.0", "react-window": "^1.8.11", "reactjs-popup": "^2.0.5", @@ -127,7 +128,7 @@ "tinymce": "^7.2.0", "util": "^0.12.5", "uuid": "^9.0.1", - "validator": "^13.15.26", + "validator": "^13.15.35", "webpack": "^5.104.1" }, "resolutions": { @@ -177,6 +178,7 @@ "@types/react-router-dom": "^5.3.3", "@typescript-eslint/eslint-plugin": "^8.44.1", "@typescript-eslint/parser": "^8.44.1", + "@vitejs/plugin-basic-ssl": "^2.1.0", "@vitejs/plugin-react": "^4.5.0", "@vitest/ui": "3.2.2", "babel-jest": "^29.7.0", diff --git a/src/App.module.css b/src/App.module.css index acad13ac30..c8e4c508be 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -4,18 +4,20 @@ html { body { display: grid; - grid-template-columns: auto 0px; + grid-template-columns: auto 0; } + button { border: none; background-color: transparent; } + #root { height: 100%; - overflow-x: auto; - overflow-y: hidden; + overflow: auto hidden; + /* background-color: var(--background-color); */ - background-color: #ffffff; + background-color: #fff; } .App { @@ -46,14 +48,14 @@ button { width: 180px; } -.accordion-toggle:after { - font-family: 'FontAwesome', sans-serif; +.accordion-toggle::after { + font-family: FontAwesome, sans-serif; content: '\f054'; float: right; } -.accordion-toggle.collapsed:after { - /* symbol for down chevron*/ +.accordion-toggle.collapsed::after { + /* symbol for down chevron */ content: '\f078'; } @@ -63,27 +65,31 @@ button { } .hgn-leaderboard { - /*background-color:#FFCCBC */ + /* background-color:#FFCCBC */ background-color: black; } .bg-super { background-color: #0000cd !important; - /* medium blue #0000CD*/ + + /* medium blue #0000CD */ } .bg-awesome { - background-color: #990099 !important; + background-color: #909 !important; + /* violet */ } .bg-super-awesome { - background-color: #660099 !important; + background-color: #609 !important; + /* purple */ } .bg-orange { background-color: #ffa500 !important; + /* brighter orange */ } @@ -92,12 +98,14 @@ button { } .bg-bright-red { - background-color: #ff3300 !important; + background-color: #f30 !important; + /* bright-red */ } .bg-almost-red { - background-color: #cc3366 !important; + background-color: #c36 !important; + /* 90%to100%-red */ } @@ -142,7 +150,7 @@ button { } .bg-darkmode-black { - background-color: #000000 !important; + background-color: #000 !important; } .bg-azure { @@ -170,7 +178,7 @@ button { } .text-custom-grey { - color: rgb(192, 192, 192) !important; + color: rgb(192 192 192) !important; } .box-shadow-dark { @@ -194,7 +202,7 @@ button { } nav ul.navbar-nav li:last-child > div { - transform: translate3d(-26px, 40px, 0px) !important; + transform: translate3d(-26px, 40px, 0) !important; } .calendar-icon-dark { @@ -205,7 +213,9 @@ nav ul.navbar-nav li:last-child > div { text-decoration: none; padding: 10px; margin: 10px; + --offset: 100px; + margin-top: calc(100vh + var(--offset)); font-family: sans-serif; color: #fff; @@ -273,6 +283,7 @@ input[type='checkbox']:not([disabled]):hover { .pdrl-1 { padding: 0 1em !important; } + .user-card-container { display: flex; flex-wrap: wrap; diff --git a/src/actions/facebookActions.js b/src/actions/facebookActions.js new file mode 100644 index 0000000000..c3d30820a7 --- /dev/null +++ b/src/actions/facebookActions.js @@ -0,0 +1,196 @@ +import axios from 'axios'; +import { toast } from 'react-toastify'; +import { ENDPOINTS } from '~/utils/URL'; + +export const postFacebookContent = ({ message, link, imageUrl, pageId, requestor }) => async () => { + try { + const payload = { + message, + link, + imageUrl, + pageId, + requestor, + }; + + const { data } = await axios.post(ENDPOINTS.FACEBOOK_POST, payload); + toast.success('Facebook post created.'); + return data; + } catch (error) { + const detail = + error.response?.data?.details || + error.response?.data?.error || + error.response?.data || + error.message; + toast.error(`Failed to post to Facebook: ${detail}`); + throw error; + } +}; + +export const scheduleFacebookPost = + ({ message, scheduledFor, timezone = 'America/Los_Angeles', link, imageUrl, pageId, requestor }) => + async () => { + try { + const payload = { + message, + scheduledFor, + timezone, + link, + imageUrl, + pageId, + requestor, + }; + + const { data } = await axios.post(ENDPOINTS.FACEBOOK_SCHEDULE_POST, payload); + toast.success('Facebook post scheduled.'); + return data; + } catch (error) { + const detail = + error.response?.data?.details || + error.response?.data?.error || + error.response?.data || + error.message; + toast.error(`Failed to schedule Facebook post: ${detail}`); + throw error; + } + }; + +/** + * Schedules a Facebook post with direct image file upload + */ +export const scheduleFacebookPostWithImage = (formData) => async () => { + try { + const { data } = await axios.post(ENDPOINTS.FACEBOOK_SCHEDULE_POST_UPLOAD, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + timeout: 60000, + }); + toast.success('Facebook post scheduled.'); + return data; + } catch (error) { + const detail = + error.response?.data?.details || + error.response?.data?.error || + error.response?.data || + error.message; + toast.error(`Failed to schedule Facebook post: ${detail}`); + throw error; + } +}; + +// ============================================ +// NEW ACTIONS +// ============================================ + +/** + * Fetches pending/sending scheduled posts + */ +export const fetchScheduledPosts = + ({ requestor, status, limit = 50, skip = 0 }) => + async () => { + try { + const params = new URLSearchParams(); + if (status) params.append('status', status); + params.append('limit', limit); + params.append('skip', skip); + if (requestor) { + params.append('requestor', JSON.stringify(requestor)); + } + + const { data } = await axios.get(`${ENDPOINTS.FACEBOOK_SCHEDULED}?${params.toString()}`); + return data; + } catch (error) { + const detail = + error.response?.data?.details || error.response?.data?.error || error.message; + toast.error(`Failed to fetch scheduled posts: ${detail}`); + throw error; + } + }; + +/** + * Fetches post history (MongoDB sent posts + Facebook Graph API posts) + */ +export const fetchPostHistory = + ({ requestor, limit = 25, source = 'all', pageId, status, postMethod }) => + async () => { + try { + const params = new URLSearchParams(); + params.append('limit', limit); + params.append('source', source); + if (pageId) params.append('pageId', pageId); + if (status) params.append('status', status); + if (postMethod) params.append('postMethod', postMethod); + if (requestor) { + params.append('requestor', JSON.stringify(requestor)); + } + + const { data } = await axios.get(`${ENDPOINTS.FACEBOOK_HISTORY}?${params.toString()}`); + return data; + } catch (error) { + const detail = + error.response?.data?.details || error.response?.data?.error || error.message; + toast.error(`Failed to fetch post history: ${detail}`); + throw error; + } + }; + +/** + * Cancels a pending scheduled post + */ +export const cancelScheduledPost = + ({ postId, requestor }) => + async () => { + try { + const { data } = await axios.delete(`${ENDPOINTS.FACEBOOK_SCHEDULE}/${postId}`, { + data: { requestor }, + }); + toast.success('Scheduled post cancelled.'); + return data; + } catch (error) { + const detail = + error.response?.data?.details || error.response?.data?.error || error.message; + toast.error(`Failed to cancel scheduled post: ${detail}`); + throw error; + } + }; + +/** + * Updates a pending scheduled post + */ +export const updateScheduledPost = + ({ postId, message, scheduledFor, timezone, link, imageUrl, requestor }) => + async () => { + try { + const payload = { message, scheduledFor, timezone, link, imageUrl, requestor }; + const { data } = await axios.put(`${ENDPOINTS.FACEBOOK_SCHEDULE}/${postId}`, payload); + toast.success('Scheduled post updated.'); + return data; + } catch (error) { + const detail = + error.response?.data?.details || error.response?.data?.error || error.message; + toast.error(`Failed to update scheduled post: ${detail}`); + throw error; + } + }; + +export const postFacebookContentWithImage = (formData) => async () => { + try { + const { data } = await axios.post(ENDPOINTS.FACEBOOK_POST_UPLOAD, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + // Increase timeout for larger files + timeout: 60000, + }); + toast.success('Facebook post created.'); + return data; + } catch (error) { + const detail = + error.response?.data?.details || + error.response?.data?.error || + error.response?.data || + error.message; + toast.error(`Failed to post to Facebook: ${detail}`); + throw error; + } +}; diff --git a/src/actions/facebookAuthActions.js b/src/actions/facebookAuthActions.js new file mode 100644 index 0000000000..30212ee645 --- /dev/null +++ b/src/actions/facebookAuthActions.js @@ -0,0 +1,201 @@ +import axios from 'axios'; +import { toast } from 'react-toastify'; +import { ENDPOINTS } from '~/utils/URL'; +import { SET_FB_CONNECTION_STATUS, SET_FB_CONNECTION_LOADING } from '~/reducers/facebookReducer'; + +/** + * Loads the Facebook SDK dynamically + */ +export const loadFacebookSDK = () => { + return new Promise((resolve, reject) => { + if (window.FB && window.fbSDKInitialized) { + resolve(window.FB); + return; + } + + if (document.getElementById('facebook-jssdk')) { + const checkFB = setInterval(() => { + if (window.FB && window.fbSDKInitialized) { + clearInterval(checkFB); + resolve(window.FB); + } + }, 100); + + setTimeout(() => { + clearInterval(checkFB); + reject(new Error('Facebook SDK initialization timeout')); + }, 10000); + return; + } + + window.fbAsyncInit = function () { + window.FB.init({ + appId: process.env.REACT_APP_FACEBOOK_APP_ID, + cookie: true, + xfbml: true, + version: 'v19.0', + }); + window.fbSDKInitialized = true; + console.log('[FacebookSDK] Initialized successfully'); + resolve(window.FB); + }; + + const script = document.createElement('script'); + script.id = 'facebook-jssdk'; + script.src = 'https://connect.facebook.net/en_US/sdk.js'; + script.async = true; + script.defer = true; + script.onerror = () => reject(new Error('Failed to load Facebook SDK')); + + const firstScript = document.getElementsByTagName('script')[0]; + if (firstScript && firstScript.parentNode) { + firstScript.parentNode.insertBefore(script, firstScript); + } else { + document.head.appendChild(script); + } + }); +}; + +export const getFacebookConnectionStatus = () => async (dispatch) => { + dispatch({ type: SET_FB_CONNECTION_LOADING, payload: true }); + try { + const { data } = await axios.get(ENDPOINTS.FACEBOOK_AUTH_STATUS); + dispatch({ type: SET_FB_CONNECTION_STATUS, payload: data }); + return data; + } catch (error) { + console.error('[FacebookAuth] Failed to get connection status:', error.message); + const fallback = { connected: false, error: error.message }; + dispatch({ type: SET_FB_CONNECTION_STATUS, payload: fallback }); + return fallback; + } +}; + +/** + * Initiates Facebook OAuth login. + * Returns page metadata + selectionNonce (NO tokens). + */ +export const initiateFacebookLogin = + ({ requestor }) => + async () => { + try { + const FB = await loadFacebookSDK(); + + return new Promise((resolve, reject) => { + function onAuthCallbackSuccess({ data }) { + if (data.success && data.pages?.length > 0) { + resolve({ + success: true, + pages: data.pages, // No tokens in here + selectionNonce: data.selectionNonce, // Nonce to claim tokens later + }); + } else if (data.pages?.length === 0) { + toast.error( + 'No Facebook Pages found. Make sure you have admin access to a Page.', + ); + reject(new Error('No pages found')); + } else { + reject(new Error(data.error || 'Failed to authenticate')); + } + } + + function onAuthCallbackError(err) { + const detail = + err.response?.data?.details || + err.response?.data?.error || + err.message; + toast.error(`Facebook authentication failed: ${detail}`); + reject(err); + } + + FB.login( + response => { + if (response.authResponse) { + const { accessToken, userID, grantedScopes } = response.authResponse; + + // Send short-lived token to backend for exchange. + // Backend will store long-lived tokens server-side and + // return only page metadata + selectionNonce. + axios + .post(ENDPOINTS.FACEBOOK_AUTH_CALLBACK, { + accessToken, + userID, + grantedScopes, + requestor, + }) + .then(onAuthCallbackSuccess) + .catch(onAuthCallbackError); + } else { + toast.info('Facebook login was cancelled.'); + reject(new Error('Login cancelled')); + } + }, + { + scope: 'pages_manage_posts,pages_read_user_content', + return_scopes: true, + }, + ); + }); + } catch (error) { + toast.error(`Failed to initialize Facebook login: ${error.message}`); + throw error; + } + }; + +/** + * Connects a selected Facebook Page using server-held tokens. + * Only sends pageId + selectionNonce (no raw tokens). + */ +export const connectFacebookPage = + ({ pageId, pageName, selectionNonce, requestor }) => + async (dispatch) => { + try { + const { data } = await axios.post(ENDPOINTS.FACEBOOK_AUTH_CONNECT, { + pageId, + pageName, + selectionNonce, + requestor, + }); + + if (data.success) { + toast.success(`Connected to ${pageName || 'Facebook Page'}`); + dispatch(getFacebookConnectionStatus()); + return data; + } + throw new Error(data.error || 'Failed to connect page'); + } catch (error) { + const detail = + error.response?.data?.details || error.response?.data?.error || error.message; + toast.error(`Failed to connect Facebook Page: ${detail}`); + throw error; + } + }; + +export const disconnectFacebookPage = + ({ requestor }) => + async (dispatch) => { + try { + const { data } = await axios.post(ENDPOINTS.FACEBOOK_AUTH_DISCONNECT, { requestor }); + + if (data.success) { + toast.success('Facebook Page disconnected'); + dispatch(getFacebookConnectionStatus()); + return data; + } + throw new Error(data.error || 'Failed to disconnect'); + } catch (error) { + const detail = + error.response?.data?.details || error.response?.data?.error || error.message; + toast.error(`Failed to disconnect Facebook: ${detail}`); + throw error; + } + }; + +export const verifyFacebookConnection = () => async () => { + try { + const { data } = await axios.get(ENDPOINTS.FACEBOOK_AUTH_VERIFY); + return data; + } catch (error) { + console.error('[FacebookAuth] Verify failed:', error.message); + return { valid: false, error: error.message }; + } +}; diff --git a/src/components/ActualCostBreakdown/ActualCostBreakdown.module.css b/src/components/ActualCostBreakdown/ActualCostBreakdown.module.css index b7a4157740..8950605404 100644 --- a/src/components/ActualCostBreakdown/ActualCostBreakdown.module.css +++ b/src/components/ActualCostBreakdown/ActualCostBreakdown.module.css @@ -114,7 +114,7 @@ .custom-dropdown-trigger:focus { outline: none; border-color: #4A90E2; - box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1); + box-shadow: 0 0 0 3px rgb(74 144 226 / 10%); } .custom-dropdown-trigger .selected-value { @@ -153,11 +153,10 @@ background: #fff; border: 1.5px solid #e0e0e0; border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + box-shadow: 0 4px 12px rgb(0 0 0 / 15%); z-index: 100; max-height: 300px; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; } .custom-dropdown-menu::-webkit-scrollbar { @@ -226,7 +225,7 @@ .form-control:focus { outline: none; border-color: #4A90E2; - box-shadow: 0 0 0 3px rgba(74, 144, 226, 0.1); + box-shadow: 0 0 0 3px rgb(74 144 226 / 10%); } .filter-select { @@ -445,22 +444,22 @@ /* Custom tooltip */ .custom-tooltip { - background: rgba(0, 0, 0, 0.85); + background: rgb(0 0 0 / 85%); color: #fff; padding: 12px 16px; border-radius: 6px; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); + box-shadow: 0 4px 12px rgb(0 0 0 / 20%); } .tooltip-label { font-weight: 600; - margin: 0 0 6px 0; + margin: 0 0 6px; font-size: 14px; color: #fff; } .tooltip-value { - margin: 0 0 4px 0; + margin: 0 0 4px; font-size: 13px; color: #fff; } @@ -473,13 +472,13 @@ } /* Responsive design */ -@media (max-width: 1024px) { +@media (width <= 1024px) { .chart-area { min-height: 500px; } } -@media (max-width: 768px) { +@media (width <= 768px) { .actual-cost-breakdown { padding: 20px; } @@ -519,7 +518,7 @@ } } -@media (max-width: 480px) { +@media (width <= 480px) { .actual-cost-breakdown { padding: 15px; margin: 10px 0; diff --git a/src/components/Announcements/Announcements.module.css b/src/components/Announcements/Announcements.module.css index 93e9749301..8bdd876412 100644 --- a/src/components/Announcements/Announcements.module.css +++ b/src/components/Announcements/Announcements.module.css @@ -63,13 +63,16 @@ button.sendButton:hover { .editor { flex: 1; + /* Editor takes up remaining space */ margin-right: 20px; + /* Add some spacing between editor and email inputs */ } .editor :global(h2) { margin-top: 0; + /* Remove top margin for the heading */ } @@ -84,11 +87,13 @@ button.sendButton:hover { 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 :global(h3) { margin-top: 0; + /* Remove top margin for the email input headings */ } @@ -113,15 +118,16 @@ button.sendButton:hover { } /* Responsive design: Adjusts the preview section on smaller screens. */ -@media (max-width: 768px) { +@media (width <= 768px) { .emailContentPreview { margin-top: 10px; + /* Reduce margin top on smaller screens */ } } /* Responsive design: Adjust the layout for smaller screens */ -@media (max-width: 800px) { +@media (width <= 800px) { .emailUpdateContainer { display: flex; flex-direction: column; @@ -180,7 +186,6 @@ button.sendButton:hover { border-top-left-radius: 10px; border-top-right-radius: 10px; margin-right: 4px; - cursor: pointer; transition: all 0.2s ease; display: flex; @@ -240,8 +245,7 @@ button.sendButton:hover { .tabGrid { display: grid !important; gap: 8px; - overflow-x: auto; - overflow-y: hidden; + overflow: auto hidden; scroll-behavior: smooth; scrollbar-width: thin; scrollbar-color: #888 #f1f1f1; @@ -270,12 +274,12 @@ button.sendButton:hover { } .tabGrid.dark > .navItem .tabNavItem { - box-shadow: -2px 0 6px rgba(0, 0, 0, 0.25), 2px 0 6px rgba(0, 0, 0, 0.25); + box-shadow: -2px 0 6px rgb(0 0 0 / 25%), 2px 0 6px rgb(0 0 0 / 25%); } .tabGrid.dark > .navItem:nth-child(-n + 12) .tabNavItem { - box-shadow: -2px 0 6px rgba(0, 0, 0, 0.25), 2px 0 6px rgba(0, 0, 0, 0.25), - 0 6px 6px rgba(0, 0, 0, 0.35); + box-shadow: -2px 0 6px rgb(0 0 0 / 25%), 2px 0 6px rgb(0 0 0 / 25%), + 0 6px 6px rgb(0 0 0 / 35%); } .tabGrid.dark::-webkit-scrollbar-track { @@ -358,14 +362,14 @@ button.sendButton:hover { filter: brightness(1.05); } -@media (max-width: 1024px) { +@media (width <= 1024px) { .tabGrid { grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); grid-template-columns: repeat(auto, minmax(110px, 120px)); } } -@media (max-width: 700px) { +@media (width <= 700px) { .tabGrid { grid-template-columns: repeat(auto, minmax(95px, 100px)); } @@ -375,7 +379,7 @@ button.sendButton:hover { } } -@media (max-width: 460px) { +@media (width <= 460px) { .tabLabel { display: none; } @@ -402,72 +406,95 @@ button.sendButton:hover { .tabGrid > .navItem:nth-child(1) { z-index: 12; } + .tabGrid > .navItem:nth-child(2) { z-index: 11; } + .tabGrid > .navItem:nth-child(3) { z-index: 10; } + .tabGrid > .navItem:nth-child(4) { z-index: 9; } + .tabGrid > .navItem:nth-child(5) { z-index: 8; } + .tabGrid > .navItem:nth-child(6) { z-index: 7; } + .tabGrid > .navItem:nth-child(7) { z-index: 6; } + .tabGrid > .navItem:nth-child(8) { z-index: 5; } + .tabGrid > .navItem:nth-child(9) { z-index: 4; } + .tabGrid > .navItem:nth-child(10) { z-index: 3; } + .tabGrid > .navItem:nth-child(11) { z-index: 2; } + .tabGrid > .navItem:nth-child(12) { z-index: 1; } + .tabGrid > .navItem:nth-child(13) { z-index: 24; } + .tabGrid > .navItem:nth-child(14) { z-index: 23; } + .tabGrid > .navItem:nth-child(15) { z-index: 22; } + .tabGrid > .navItem:nth-child(16) { z-index: 21; } + .tabGrid > .navItem:nth-child(17) { z-index: 20; } + .tabGrid > .navItem:nth-child(18) { z-index: 19; } + .tabGrid > .navItem:nth-child(19) { z-index: 18; } + .tabGrid > .navItem:nth-child(20) { z-index: 17; } + .tabGrid > .navItem:nth-child(21) { z-index: 16; } + .tabGrid > .navItem:nth-child(22) { z-index: 15; } + .tabGrid > .navItem:nth-child(23) { z-index: 14; } + .tabGrid > .navItem:nth-child(24) { z-index: 13; } @@ -480,10 +507,10 @@ button.sendButton:hover { .tabGrid > .navItem .tabNavItem { /* left + right only */ - box-shadow: -2px 0 6px rgba(0, 0, 0, 0.12), 2px 0 6px rgba(0, 0, 0, 0.12); + box-shadow: -2px 0 6px rgb(0 0 0 / 12%), 2px 0 6px rgb(0 0 0 / 12%); } .tabGrid > .navItem:nth-child(-n + 12) .tabNavItem { - box-shadow: -2px 0 6px rgba(0, 0, 0, 0.12), 2px 0 6px rgba(0, 0, 0, 0.12), - 0 6px 6px rgba(0, 0, 0, 0.2); + box-shadow: -2px 0 6px rgb(0 0 0 / 12%), 2px 0 6px rgb(0 0 0 / 12%), + 0 6px 6px rgb(0 0 0 / 20%); } diff --git a/src/components/Announcements/FacebookConnection.jsx b/src/components/Announcements/FacebookConnection.jsx new file mode 100644 index 0000000000..7f852546f5 --- /dev/null +++ b/src/components/Announcements/FacebookConnection.jsx @@ -0,0 +1,416 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { toast } from 'react-toastify'; +import moment from 'moment-timezone'; +import { + getFacebookConnectionStatus, + initiateFacebookLogin, + connectFacebookPage, + disconnectFacebookPage, +} from '~/actions/facebookAuthActions'; + +const PST_TZ = 'America/Los_Angeles'; + +function buildRequestor(authUser) { + if (!authUser?.userid) return null; + return { + requestorId: authUser.userid, + name: `${authUser.firstName || ''} ${authUser.lastName || ''}`.trim(), + role: authUser.role, + permissions: authUser.permissions, + }; +} + +function formatDate(dateStr) { + if (!dateStr) return 'Unknown'; + return moment(dateStr) + .tz(PST_TZ) + .format('MMM D, YYYY h:mm A'); +} + +function applyConnectResult(result, setAvailablePages, setSelectionNonce, setShowPageSelector) { + if (result.success && result.pages?.length > 0) { + setAvailablePages(result.pages); + setSelectionNonce(result.selectionNonce); + setShowPageSelector(true); + } +} + +function isManagerRole(role) { + return role === 'Owner' || role === 'Administrator'; +} + +function loadInitialConnectionStatus(connectionStatus, dispatch) { + if (connectionStatus === null) { + dispatch(getFacebookConnectionStatus()); + } +} + +function getStatusBadge(connectionStatus) { + if (!connectionStatus?.connected) { + return { text: 'Not Connected', color: '#dc3545', bg: '#f8d7da' }; + } + if (connectionStatus.tokenStatus === 'expired') { + return { text: 'Token Expired', color: '#dc3545', bg: '#f8d7da' }; + } + if (connectionStatus.tokenStatus === 'expiring_soon') { + return { text: 'Expiring Soon', color: '#856404', bg: '#fff3cd' }; + } + return { text: 'Connected', color: '#155724', bg: '#d4edda' }; +} + +function PageSelectorModal({ availablePages, onSelectPage, onCancel, darkMode }) { + const panelBg = darkMode ? '#111827' : '#fff'; + const textColor = darkMode ? '#e5e7eb' : '#333'; + const mutedTextColor = darkMode ? '#cbd5e1' : '#666'; + const borderColor = darkMode ? '#1f2937' : '#ddd'; + + const modalOverlay = { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }; + const modalContent = { + backgroundColor: panelBg, + padding: '24px', + borderRadius: '8px', + width: '90%', + maxWidth: '450px', + maxHeight: '80vh', + overflow: 'auto', + color: textColor, + }; + const pageItemStyle = { + padding: '12px', + border: `1px solid ${borderColor}`, + borderRadius: '6px', + marginBottom: '8px', + cursor: 'pointer', + transition: 'background-color 0.2s', + backgroundColor: panelBg, + color: textColor, + }; + const btnCancel = { + backgroundColor: '#6c757d', + color: 'white', + padding: '8px 16px', + borderRadius: '6px', + border: 'none', + cursor: 'pointer', + fontSize: '14px', + marginTop: '12px', + width: '100%', + }; + + return ( +
+
+

Select a Facebook Page

+

+ Choose the Page you want to connect for posting: +

+ {availablePages.map(page => ( +
onSelectPage(page)} + onKeyDown={e => e.key === 'Enter' && onSelectPage(page)} + role="button" + tabIndex={0} + onMouseEnter={e => { + e.currentTarget.style.backgroundColor = darkMode ? '#1f2937' : '#f0f0f0'; + }} + onMouseLeave={e => { + e.currentTarget.style.backgroundColor = panelBg; + }} + > +

{page.pageName}

+

+ {page.category} • ID: {page.pageId} +

+
+ ))} + +
+
+ ); +} + +export default function FacebookConnection() { + const dispatch = useDispatch(); + const authUser = useSelector(state => state.auth?.user); + const darkMode = useSelector(state => state.theme.darkMode); + + const requestor = useMemo(() => buildRequestor(authUser), [ + authUser?.userid, + authUser?.firstName, + authUser?.lastName, + authUser?.role, + authUser?.permissions, + ]); + + const connectionStatus = useSelector(state => state.facebook?.connectionStatus); + const loading = useSelector(state => state.facebook?.loading); + const [connecting, setConnecting] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); + + // Page selection modal state + const [availablePages, setAvailablePages] = useState([]); + const [selectionNonce, setSelectionNonce] = useState(null); + const [showPageSelector, setShowPageSelector] = useState(false); + + const canManageConnection = useMemo(() => isManagerRole(authUser?.role), [authUser?.role]); + + useEffect(() => { + loadInitialConnectionStatus(connectionStatus, dispatch); + }, [dispatch, connectionStatus]); + + const handleConnect = async () => { + if (!requestor) { + toast.error('Please log in to connect Facebook.'); + return; + } + + setConnecting(true); + try { + const result = await dispatch(initiateFacebookLogin({ requestor })); + applyConnectResult(result, setAvailablePages, setSelectionNonce, setShowPageSelector); + } catch { + // Error already shown via toast + } finally { + setConnecting(false); + } + }; + + const handleSelectPage = async page => { + setConnecting(true); + try { + await dispatch( + connectFacebookPage({ + pageId: page.pageId, + pageName: page.pageName, + selectionNonce, + requestor, + }), + ); + setShowPageSelector(false); + setAvailablePages([]); + setSelectionNonce(null); + } catch { + // Error already shown via toast + } finally { + setConnecting(false); + } + }; + + const handleDisconnect = async () => { + if ( + !window.confirm( + 'Are you sure you want to disconnect Facebook? Scheduled posts will fail until reconnected.', + ) + ) { + return; + } + + setDisconnecting(true); + try { + await dispatch(disconnectFacebookPage({ requestor })); + } catch { + // Error already shown via toast + } finally { + setDisconnecting(false); + } + }; + + const handleCancelPageSelect = () => { + setShowPageSelector(false); + setAvailablePages([]); + setSelectionNonce(null); + }; + + const statusBadge = getStatusBadge(connectionStatus); + + // Styles + const textColor = darkMode ? '#e5e7eb' : '#333'; + const mutedTextColor = darkMode ? '#cbd5e1' : '#666'; + const surfaceBg = darkMode ? '#0f172a' : '#fafafa'; + const borderColor = darkMode ? '#1f2937' : '#ddd'; + const panelBg = darkMode ? '#111827' : '#fff'; + + const containerStyle = { + border: `1px solid ${borderColor}`, + borderRadius: '8px', + padding: '16px', + marginBottom: '16px', + backgroundColor: surfaceBg, + color: textColor, + }; + + const headerStyle = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: '12px', + }; + + const badgeStyle = { + padding: '4px 12px', + borderRadius: '12px', + fontSize: '12px', + fontWeight: 'bold', + color: statusBadge.color, + backgroundColor: statusBadge.bg, + }; + + const btnPrimary = { + backgroundColor: '#1877F2', + color: 'white', + padding: '10px 20px', + borderRadius: '6px', + border: 'none', + cursor: 'pointer', + fontSize: '14px', + fontWeight: 'bold', + display: 'flex', + alignItems: 'center', + gap: '8px', + }; + + const btnDanger = { + backgroundColor: darkMode ? '#b91c1c' : '#dc3545', + color: 'white', + padding: '8px 16px', + borderRadius: '6px', + border: 'none', + cursor: 'pointer', + fontSize: '14px', + }; + + if (loading) { + return ( +
+

Loading connection status...

+
+ ); + } + + return ( +
+
+
+ + + +
+

Facebook Page Connection

+ {connectionStatus?.connected && ( +

+ {connectionStatus.pageName} +

+ )} +
+
+ {statusBadge.text} +
+ + {connectionStatus?.connected ? ( +
+
+

+ Page ID: {connectionStatus.pageId} +

+

+ Connected: {formatDate(connectionStatus.connectedAt)} by{' '} + {connectionStatus.connectedBy} +

+ {connectionStatus.tokenStatus === 'expired' && ( +

+ ⚠️ Token Issue: Please reconnect to restore posting capability. +

+ )} + {connectionStatus.lastVerifiedAt && ( +

+ Last Verified: {formatDate(connectionStatus.lastVerifiedAt)} +

+ )} + {connectionStatus.lastError && ( +

+ Last Error: {connectionStatus.lastError} +

+ )} +
+ + {canManageConnection && ( +
+ + +
+ )} + + {!canManageConnection && ( +

+ Only Owners and Administrators can manage the Facebook connection. +

+ )} +
+ ) : ( +
+

+ Connect a Facebook Page to enable posting and scheduling. +

+ + {canManageConnection ? ( + + ) : ( +

+ Only Owners and Administrators can connect a Facebook Page. +

+ )} +
+ )} + + {/* Page Selection Modal */} + {showPageSelector && ( + + )} +
+ ); +} diff --git a/src/components/Announcements/SocialMediaComposer.jsx b/src/components/Announcements/SocialMediaComposer.jsx index 6ba6ae8de2..1108d9b754 100644 --- a/src/components/Announcements/SocialMediaComposer.jsx +++ b/src/components/Announcements/SocialMediaComposer.jsx @@ -1,13 +1,196 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { toast } from 'react-toastify'; -import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; +import moment from 'moment-timezone'; +import { + postFacebookContent, + postFacebookContentWithImage, + scheduleFacebookPost, + scheduleFacebookPostWithImage, + fetchScheduledPosts, + fetchPostHistory, + cancelScheduledPost, + updateScheduledPost, +} from '~/actions/facebookActions'; +import { getFacebookConnectionStatus } from '~/actions/facebookAuthActions'; +import FacebookConnection from './FacebookConnection'; import CharacterCounter from './CharacterCounter'; import ConfirmationModal from './ConfirmationModal'; import './SocialMediaComposer.module.css'; -const PREFS_KEY = 'mastodon_composer_prefs'; +const PST_TZ = 'America/Los_Angeles'; + +function getPostMethodBadgeStyle(postMethod, darkMode) { + const isDirect = postMethod === 'direct'; + return { + backgroundColor: isDirect + ? darkMode + ? '#1d4ed8' + : '#e3f2fd' + : darkMode + ? '#4338ca' + : '#f3e5f5', + color: isDirect ? (darkMode ? '#bfdbfe' : '#1565c0') : darkMode ? '#e0e7ff' : '#7b1fa2', + }; +} + +function getPostStatusBadgeStyle(status, darkMode) { + const isSent = status === 'sent'; + return { + backgroundColor: isSent ? (darkMode ? '#14532d' : '#e8f5e9') : darkMode ? '#3f1d2e' : '#ffebee', + color: isSent ? (darkMode ? '#bbf7d0' : '#2e7d32') : darkMode ? '#fecdd3' : '#c62828', + }; +} + +function buildRequestor(authUser) { + if (!authUser?.userid) return null; + return { + requestorId: authUser.userid, + name: `${authUser.firstName || ''} ${authUser.lastName || ''}`.trim(), + role: authUser.role, + permissions: authUser.permissions, + }; +} + +function getTabStyle(tabId, activeSubTab, darkMode, textColor) { + return { + padding: '10px 16px', + cursor: 'pointer', + border: 'none', + borderBottom: + activeSubTab === tabId + ? `3px solid ${darkMode ? '#60a5fa' : '#007bff'}` + : '3px solid transparent', + backgroundColor: + activeSubTab === tabId + ? darkMode + ? '#1d2b44' + : '#dbeeff' + : darkMode + ? '#111a2c' + : '#dedede', + color: activeSubTab === tabId ? (darkMode ? '#bfdbfe' : '#007bff') : textColor, + fontSize: '14px', + fontWeight: activeSubTab === tabId ? 'bold' : 'normal', + flex: 1, + textAlign: 'center', + outline: 'none', + }; +} + +function validateImageFile(file) { + const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + if (!allowedTypes.includes(file.type)) return 'Please select a JPEG, PNG, GIF, or WebP image'; + if (file.size > 10 * 1024 * 1024) return 'Image must be under 10MB'; + return null; +} + +function validatePostInput(platform, isConnected, postContent, imageFile, imageUrl, requestor) { + if (platform !== 'facebook') + return { msg: `Posting for ${platform} is not wired yet.`, type: 'info' }; + if (!isConnected) + return { msg: 'Please connect a Facebook Page in Settings before posting.', type: 'error' }; + if (!postContent.trim() && !imageFile && !imageUrl.trim()) + return { msg: 'Please enter content or add an image for your post.', type: 'error' }; + if (!requestor) return { msg: 'User information is missing; please re-login.', type: 'error' }; + return null; +} + +function validateScheduleInput( + platform, + isConnected, + scheduledContent, + scheduledImageFile, + scheduledImageUrl, + scheduledDateTime, + requestor, +) { + if (platform !== 'facebook') + return { msg: 'Scheduling is only available for Facebook right now.', type: 'info' }; + if (!isConnected) + return { msg: 'Please connect a Facebook Page in Settings before scheduling.', type: 'error' }; + if (!scheduledContent.trim() && !scheduledImageFile && !scheduledImageUrl.trim()) + return { msg: 'Please enter content or add an image for your scheduled post.', type: 'error' }; + if (!scheduledDateTime) return { msg: 'Please pick a date and time.', type: 'error' }; + const m = moment.tz(scheduledDateTime, 'YYYY-MM-DDTHH:mm', PST_TZ); + if (!m.isValid() || m.isBefore(moment.tz(PST_TZ))) + return { msg: 'Scheduled time must be a valid future date/time (PST).', type: 'error' }; + if (!requestor) return { msg: 'User information is missing; please re-login.', type: 'error' }; + return null; +} + +function validateEditInput(editMessage, editDateTime) { + if (!editMessage.trim()) return 'Message cannot be empty.'; + const m = moment.tz(editDateTime, 'YYYY-MM-DDTHH:mm', PST_TZ); + if (!m.isValid() || m.isBefore(moment.tz(PST_TZ))) + return 'Scheduled time must be in the future (PST).'; + return null; +} + +function isFacebookRequestSkippable(platform, requestor) { + return platform !== 'facebook' || !requestor; +} + +function buildHistoryFetchParams(requestor, historySource, historyStatus, historyPostMethod) { + return { + requestor, + source: historySource, + status: historyStatus !== 'all' ? historyStatus : undefined, + postMethod: historyPostMethod !== 'all' ? historyPostMethod : undefined, + }; +} + +function ConnectionWarning({ platform, isConnected, onGoToSettings, darkMode }) { + if (platform !== 'facebook' || isConnected === null || isConnected) return null; + const warningBanner = { + backgroundColor: darkMode ? '#3b3000' : '#fff3cd', + border: '1px solid #ffc107', + borderRadius: '6px', + padding: '12px', + marginBottom: '16px', + color: darkMode ? '#fef3c7' : '#856404', + }; + return ( +
+ ⚠️ Facebook Not Connected +

+ Posts and scheduled posts will fail until a Facebook Page is connected. Go to the{' '} + {' '} + to connect. +

+
+ ); +} export default function SocialMediaComposer({ platform }) { + const dispatch = useDispatch(); + const authUser = useSelector(state => state.auth?.user); + const darkMode = useSelector(state => state.theme.darkMode); + + const requestor = useMemo(() => buildRequestor(authUser), [ + authUser?.userid, + authUser?.firstName, + authUser?.lastName, + authUser?.role, + authUser?.permissions, + ]); + + const fbConnectionStatus = useSelector(state => state.facebook?.connectionStatus); + const isConnected = fbConnectionStatus?.connected ?? null; + const PLATFORM_CHAR_LIMITS = { mastodon: 500, x: 280, @@ -16,29 +199,44 @@ export default function SocialMediaComposer({ platform }) { instagram: 2200, threads: 500, }; - const charLimit = PLATFORM_CHAR_LIMITS[platform] || 500; + // Composer state const [postContent, setPostContent] = useState(''); - const [activeSubTab, setActiveSubTab] = useState('composer'); - const [isPosting, setIsPosting] = useState(false); - const [scheduleDate, setScheduleDate] = useState(''); - const [scheduleTime, setScheduleTime] = useState(''); + const [link, setLink] = useState(''); + const [imageUrl, setImageUrl] = useState(''); + const [imageFile, setImageFile] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + + // Scheduling state + const [scheduledContent, setScheduledContent] = useState(''); + const [scheduledDateTime, setScheduledDateTime] = useState(''); + const [scheduledLink, setScheduledLink] = useState(''); + const [scheduledImageUrl, setScheduledImageUrl] = useState(''); + const [scheduledImageFile, setScheduledImageFile] = useState(null); + const [scheduledImagePreview, setScheduledImagePreview] = useState(null); + const [isScheduling, setIsScheduling] = useState(false); + + // Scheduled posts list state const [scheduledPosts, setScheduledPosts] = useState([]); - const [isLoadingScheduled, setIsLoadingScheduled] = useState(false); - const [uploadedImage, setUploadedImage] = useState(null); - const [imageAltText, setImageAltText] = useState(''); - const [postHistory, setPostHistory] = useState([]); - const [isLoadingHistory, setIsLoadingHistory] = useState(false); - const [crossPostPlatforms, setCrossPostPlatforms] = useState({ - facebook: false, - linkedin: false, - instagram: false, - x: false, - }); - const [showCrossPost, setShowCrossPost] = useState(false); - const [editingPostId, setEditingPostId] = useState(null); + const [loadingScheduled, setLoadingScheduled] = useState(false); + // Post history state + const [postHistory, setPostHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + const [historySource, setHistorySource] = useState('mongodb'); + const [historyStatus, setHistoryStatus] = useState('all'); + const [historyPostMethod, setHistoryPostMethod] = useState('all'); + const [facebookApiError, setFacebookApiError] = useState(null); + + // Edit modal state + const [editingPost, setEditingPost] = useState(null); + const [editMessage, setEditMessage] = useState(''); + const [editDateTime, setEditDateTime] = useState(''); + + // Tabs and confirmation modal + const [activeSubTab, setActiveSubTab] = useState('composer'); + const [isPosting, setIsPosting] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [modalConfig, setModalConfig] = useState({ title: '', @@ -50,748 +248,1092 @@ export default function SocialMediaComposer({ platform }) { preferenceKey: null, }); - const [previewOpen, setPreviewOpen] = useState(false); - const [previewData, setPreviewData] = useState(null); - - const [preferences, setPreferences] = useState(() => { - const saved = localStorage.getItem(PREFS_KEY); - return saved - ? JSON.parse(saved) - : { - confirmDeleteScheduled: true, - confirmPostNow: true, - }; - }); - const tabOrder = [ { id: 'composer', label: '📝 Make Post' }, - { id: 'scheduled', label: '⏰ Scheduled Post' }, - { id: 'history', label: '📜 Post History' }, - { id: 'details', label: '🧩 Details' }, + { id: 'scheduled', label: '⏰ Scheduled' }, + { id: 'history', label: '📜 History' }, + { id: 'settings', label: '⚙️ Settings' }, ]; + // Dark mode styles + const textColor = darkMode ? '#e5e7eb' : '#333'; + const mutedTextColor = darkMode ? '#cbd5e1' : '#666'; + const surfaceBg = darkMode ? '#0f172a' : '#fff'; + const cardBg = darkMode ? '#111827' : '#fafafa'; + const borderColor = darkMode ? '#1f2937' : '#ddd'; + const inputBg = darkMode ? '#0b1220' : '#fff'; + const inputBorder = darkMode ? '#1f2937' : '#ccc'; + useEffect(() => { - if (activeSubTab === 'scheduled' && platform === 'mastodon') { - loadScheduledPosts(); - } else if (activeSubTab === 'history' && platform === 'mastodon') { - loadPostHistory(); + if (platform === 'facebook' && fbConnectionStatus === null) { + dispatch(getFacebookConnectionStatus()); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeSubTab, platform]); + }, [dispatch, platform, fbConnectionStatus]); - const updatePreference = (key, value) => { - const newPrefs = { ...preferences, [key]: value }; - setPreferences(newPrefs); - localStorage.setItem(PREFS_KEY, JSON.stringify(newPrefs)); - }; - - const loadScheduledPosts = async () => { - setIsLoadingScheduled(true); + // Fetch scheduled posts + const loadScheduledPosts = useCallback(async () => { + if (isFacebookRequestSkippable(platform, requestor)) return; + setLoadingScheduled(true); try { - const response = await fetch('/api/mastodon/schedule'); - if (response.ok) { - const data = await response.json(); - setScheduledPosts(data || []); - } - } catch (err) { - if (process.env.NODE_ENV === 'development') { - console.error('Error loading scheduled posts:', err); - } + const result = await dispatch(fetchScheduledPosts({ requestor })); + setScheduledPosts(result.scheduledPosts || []); + } catch { + setScheduledPosts([]); } finally { - setIsLoadingScheduled(false); + setLoadingScheduled(false); } - }; + }, [dispatch, platform, requestor]); - const loadPostHistory = async () => { - setIsLoadingHistory(true); + // Fetch post history + const loadPostHistory = useCallback(async () => { + if (isFacebookRequestSkippable(platform, requestor)) return; + setLoadingHistory(true); + setFacebookApiError(null); try { - const response = await fetch('/api/mastodon/history?limit=20'); - if (response.ok) { - const data = await response.json(); - setPostHistory(data || []); - } else { - toast.error('Failed to load post history'); - } - } catch (err) { - if (process.env.NODE_ENV === 'development') { - console.error('Error loading post history:', err); + const result = await dispatch( + fetchPostHistory( + buildHistoryFetchParams(requestor, historySource, historyStatus, historyPostMethod), + ), + ); + setPostHistory(result.posts || []); + if (result.facebookApiError) { + setFacebookApiError(result.facebookApiError); } - toast.error('Error loading post history'); + } catch { + setPostHistory([]); } finally { - setIsLoadingHistory(false); + setLoadingHistory(false); } - }; + }, [dispatch, platform, requestor, historySource, historyStatus, historyPostMethod]); - const showModal = config => { - setModalConfig(config); - setModalOpen(true); - }; + useEffect(() => { + if (activeSubTab === 'scheduled') loadScheduledPosts(); + if (activeSubTab === 'history') loadPostHistory(); + }, [activeSubTab, loadScheduledPosts, loadPostHistory]); - const handleImageUpload = e => { - const file = e.target.files[0]; + const handleImageFileChange = e => { + const file = e.target.files?.[0]; if (!file) return; - - if (!file.type.startsWith('image/')) { - toast.error('Please upload an image file'); - return; - } - - if (file.size > 5 * 1024 * 1024) { - toast.error('Image size must be less than 5MB'); + const error = validateImageFile(file); + if (error) { + toast.error(error); return; } - - const reader = new FileReader(); - reader.onloadend = () => { - const base64String = reader.result.split(',')[1]; - setUploadedImage({ - base64: base64String, - preview: reader.result, - name: file.name, - }); - toast.success('Image uploaded successfully!'); - }; - reader.onerror = () => { - toast.error('Failed to read image file'); - }; - reader.readAsDataURL(file); - }; - - const handleRemoveImage = () => { - setUploadedImage(null); - setImageAltText(''); - const fileInput = document.getElementById('image-upload'); - if (fileInput) fileInput.value = ''; + setImageFile(file); + setImagePreview(URL.createObjectURL(file)); + setImageUrl(''); }; - const handleCrossPostToggle = platformName => { - setCrossPostPlatforms(prev => ({ - ...prev, - [platformName]: !prev[platformName], - })); + const clearImageFile = () => { + setImageFile(null); + if (imagePreview) { + URL.revokeObjectURL(imagePreview); + } + setImagePreview(null); }; - const handleShowPreview = () => { - if (!postContent.trim()) { - toast.error('Post cannot be empty!'); + // Handle scheduled image file selection + const handleScheduledImageFileChange = e => { + const file = e.target.files?.[0]; + if (!file) return; + const error = validateImageFile(file); + if (error) { + toast.error(error); return; } - - const selectedPlatforms = Object.keys(crossPostPlatforms).filter(p => crossPostPlatforms[p]); - - setPreviewData({ - content: postContent, - image: uploadedImage, - altText: imageAltText, - scheduledTime: scheduleDate && scheduleTime ? `${scheduleDate}T${scheduleTime}` : null, - crossPostTo: selectedPlatforms, - }); - setPreviewOpen(true); + setScheduledImageFile(file); + setScheduledImagePreview(URL.createObjectURL(file)); + setScheduledImageUrl(''); }; - const clearComposer = () => { - setPostContent(''); - setScheduleDate(''); - setScheduleTime(''); - setUploadedImage(null); - setImageAltText(''); - setCrossPostPlatforms({ facebook: false, linkedin: false, instagram: false, x: false }); - setEditingPostId(null); - setPreviewOpen(false); + const clearScheduledImageFile = () => { + setScheduledImageFile(null); + if (scheduledImagePreview) { + URL.revokeObjectURL(scheduledImagePreview); + } + setScheduledImagePreview(null); }; - const handlePostNow = async () => { - if (!postContent.trim()) { - toast.error('Post cannot be empty!'); - return; - } - if (postContent.length > charLimit) { - toast.error(`Post exceeds ${charLimit} character limit.`); + useEffect(() => { + return () => { + if (imagePreview) { + URL.revokeObjectURL(imagePreview); + } + if (scheduledImagePreview) { + URL.revokeObjectURL(scheduledImagePreview); + } + }; + }, [imagePreview, scheduledImagePreview]); + + const handlePost = async () => { + const invalid = validatePostInput( + platform, + isConnected, + postContent, + imageFile, + imageUrl, + requestor, + ); + if (invalid) { + toast[invalid.type](invalid.msg); return; } - const selectedPlatforms = Object.keys(crossPostPlatforms).filter(p => crossPostPlatforms[p]); - setIsPosting(true); try { - const response = await fetch('/api/mastodon/createPin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: 'Mastodon Post', - description: postContent.trim(), - imgType: uploadedImage ? 'FILE' : 'URL', - mediaItems: uploadedImage ? `data:image/png;base64,${uploadedImage.base64}` : '', - mediaAltText: imageAltText || null, - crossPostTo: selectedPlatforms, - }), - }); - - if (response.ok) { - let message = `Successfully posted to ${platform}!`; - if (selectedPlatforms.length > 0) { - message += ` (Selected for: ${selectedPlatforms.join(', ')})`; - } - toast.success(message, { autoClose: 5000 }); - clearComposer(); - if (activeSubTab === 'history') { - loadPostHistory(); - } + if (imageFile) { + const formData = new FormData(); + if (postContent.trim()) formData.append('message', postContent.trim()); + if (link.trim()) formData.append('link', link.trim()); + formData.append('image', imageFile); + formData.append('requestor', JSON.stringify(requestor)); + + await dispatch(postFacebookContentWithImage(formData)); } else { - toast.error(`Failed to post to ${platform}.`); + await dispatch( + postFacebookContent({ + message: postContent.trim() || undefined, + link: link.trim() || undefined, + imageUrl: imageUrl.trim() || undefined, + requestor, + }), + ); } - } catch (err) { - toast.error(`Error while posting to ${platform}.`); + setPostContent(''); + setLink(''); + setImageUrl(''); + clearImageFile(); } finally { setIsPosting(false); } }; - const handleSchedulePost = async () => { - if (!postContent.trim()) { - toast.error('Post cannot be empty!'); - return; - } - if (postContent.length > charLimit) { - toast.error(`Post exceeds ${charLimit} character limit.`); - return; - } - if (!scheduleDate || !scheduleTime) { - toast.error('Please select both date and time.'); - return; - } - - const scheduledDateTime = new Date(`${scheduleDate}T${scheduleTime}`); - if (scheduledDateTime <= new Date()) { - toast.error('Scheduled time must be in the future.'); + const handleSchedule = async () => { + const invalid = validateScheduleInput( + platform, + isConnected, + scheduledContent, + scheduledImageFile, + scheduledImageUrl, + scheduledDateTime, + requestor, + ); + if (invalid) { + toast[invalid.type](invalid.msg); return; } - const selectedPlatforms = Object.keys(crossPostPlatforms).filter(p => crossPostPlatforms[p]); - - setIsPosting(true); + setIsScheduling(true); try { - // If editing, delete the old version first - if (editingPostId) { - await fetch(`/api/mastodon/schedule/${editingPostId}`, { method: 'DELETE' }); - } - - const response = await fetch('/api/mastodon/schedule', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: 'Mastodon Scheduled Post', - description: postContent.trim(), - imgType: uploadedImage ? 'FILE' : 'URL', - mediaItems: uploadedImage ? `data:image/png;base64,${uploadedImage.base64}` : '', - mediaAltText: imageAltText || null, - scheduledTime: scheduledDateTime.toISOString(), - crossPostTo: selectedPlatforms, - }), - }); - - if (response.ok) { - toast.success( - editingPostId ? 'Post updated successfully!' : 'Post scheduled successfully!', - ); - clearComposer(); - if (activeSubTab === 'scheduled') { - loadScheduledPosts(); - } + if (scheduledImageFile) { + const formData = new FormData(); + if (scheduledContent.trim()) formData.append('message', scheduledContent.trim()); + formData.append('scheduledFor', scheduledDateTime); + formData.append('timezone', PST_TZ); + if (scheduledLink.trim()) formData.append('link', scheduledLink.trim()); + formData.append('image', scheduledImageFile); + formData.append('requestor', JSON.stringify(requestor)); + + await dispatch(scheduleFacebookPostWithImage(formData)); } else { - toast.error('Failed to schedule post.'); - } - } catch (err) { - toast.error('Error while scheduling post.'); - } finally { - setIsPosting(false); - } - }; - - const handleEditScheduled = post => { - try { - const postData = JSON.parse(post.postData); - - // Load post content - setPostContent(postData.status || ''); - - // Load image if exists - if (postData.local_media_base64) { - setUploadedImage({ - base64: postData.local_media_base64.replace(/^data:image\/\w+;base64,/, ''), - preview: postData.local_media_base64, - name: 'scheduled-image.png', - }); + // Use regular JSON post + await dispatch( + scheduleFacebookPost({ + message: scheduledContent.trim() || undefined, + scheduledFor: scheduledDateTime, + timezone: PST_TZ, + link: scheduledLink.trim() || undefined, + imageUrl: scheduledImageUrl.trim() || undefined, + requestor, + }), + ); } - // Load alt text if exists - setImageAltText(postData.mediaAltText || ''); - - // Load scheduled time - const scheduledTime = new Date(post.scheduledTime); - const dateStr = scheduledTime.toISOString().split('T')[0]; - const timeStr = scheduledTime.toTimeString().slice(0, 5); - setScheduleDate(dateStr); - setScheduleTime(timeStr); - - // Set editing mode - setEditingPostId(post._id); - - // Switch to composer tab - setActiveSubTab('composer'); - - toast.info('Editing scheduled post. Modify and click "Schedule Post" to update.'); - } catch (err) { - toast.error('Failed to load post for editing'); - console.error('Edit error:', err); + setScheduledContent(''); + setScheduledDateTime(''); + setScheduledLink(''); + setScheduledImageUrl(''); + clearScheduledImageFile(); + loadScheduledPosts(); + } finally { + setIsScheduling(false); } }; - const handleCancelEdit = () => { - clearComposer(); - toast.info('Edit cancelled'); + const showModal = config => { + setModalConfig(config); + setModalOpen(true); }; - const handleDeleteScheduled = async (postId, skipConfirmation = false) => { - const performDelete = async () => { - try { - const response = await fetch(`/api/mastodon/schedule/${postId}`, { - method: 'DELETE', - }); - if (response.ok) { - toast.success('Scheduled post deleted!'); + const handleCancelPost = postId => { + showModal({ + title: 'Cancel Scheduled Post', + message: 'Are you sure you want to cancel this scheduled post?', + onConfirm: async () => { + try { + await dispatch(cancelScheduledPost({ postId, requestor })); loadScheduledPosts(); - } else { - toast.error('Failed to delete post.'); + } catch { + /* toast shown in action */ } - } catch (err) { - toast.error('Error deleting post.'); - } - }; + }, + confirmText: 'Cancel Post', + confirmColor: 'danger', + showDontShowAgain: false, + preferenceKey: null, + }); + }; - if (skipConfirmation || !preferences.confirmDeleteScheduled) { - await performDelete(); - } else { - showModal({ - title: 'Delete Scheduled Post', - message: 'Are you sure you want to delete this scheduled post?', - onConfirm: performDelete, - confirmText: 'Delete', - confirmColor: 'danger', - showDontShowAgain: true, - preferenceKey: 'confirmDeleteScheduled', - }); + const openEditModal = post => { + setEditingPost(post); + setEditMessage(post.message); + setEditDateTime( + moment(post.scheduledFor) + .tz(PST_TZ) + .format('YYYY-MM-DDTHH:mm'), + ); + }; + + const handleSaveEdit = async () => { + const error = validateEditInput(editMessage, editDateTime); + if (error) { + toast.error(error); + return; + } + try { + await dispatch( + updateScheduledPost({ + postId: editingPost._id, + message: editMessage.trim(), + scheduledFor: editDateTime, + timezone: PST_TZ, + requestor, + }), + ); + setEditingPost(null); + loadScheduledPosts(); + } catch { + /* toast shown in action */ } }; - const handlePostScheduledNow = async post => { - const performPost = async () => { - try { - const postData = JSON.parse(post.postData); - const response = await fetch('/api/mastodon/createPin', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - title: 'Mastodon Post', - description: postData.status, - imgType: postData.local_media_base64 ? 'FILE' : 'URL', - mediaItems: postData.local_media_base64 || '', - mediaAltText: postData.mediaAltText || null, - }), - }); + const handleDontShowAgainChange = () => {}; - if (response.ok) { - toast.success('Posted successfully!'); - await handleDeleteScheduled(post._id, true); - } else { - toast.error('Failed to post.'); - } - } catch (err) { - toast.error('Error posting.'); - } - }; + const handleImageUrlChange = e => { + setImageUrl(e.target.value); + if (e.target.value) clearImageFile(); + }; - if (!preferences.confirmPostNow) { - await performPost(); - } else { - showModal({ - title: 'Post Immediately', - message: - 'This will post immediately to Mastodon and remove it from your scheduled posts. Continue?', - onConfirm: performPost, - confirmText: 'Post Now', - confirmColor: 'success', - showDontShowAgain: true, - preferenceKey: 'confirmPostNow', - }); - } + const handleScheduledImageUrlChange = e => { + setScheduledImageUrl(e.target.value); + if (e.target.value) clearScheduledImageFile(); }; - const handleDontShowAgainChange = preferenceKey => { - updatePreference(preferenceKey, false); - toast.info('Preference saved! This confirmation will not show again.', { autoClose: 3000 }); + const formatDate = dateStr => + moment(dateStr) + .tz(PST_TZ) + .format('MMM D, YYYY h:mm A'); + + // Shared styles + const cardStyle = { + border: `1px solid ${borderColor}`, + borderRadius: '8px', + padding: '12px', + marginBottom: '12px', + backgroundColor: cardBg, + color: textColor, }; - const formatScheduledTime = isoString => { - try { - return new Date(isoString).toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: 'numeric', - minute: '2-digit', - hour12: true, - }); - } catch { - return isoString; - } + const btnPrimary = { + backgroundColor: darkMode ? '#2563eb' : '#007bff', + border: `1px solid ${darkMode ? '#1d4ed8' : '#006fe6'}`, + color: 'white', + padding: '8px 14px', + borderRadius: '6px', + cursor: 'pointer', + marginRight: '8px', + transition: 'background-color 0.15s ease', }; - const getScheduledPostImage = post => { - try { - const postData = JSON.parse(post.postData); - return postData.local_media_base64 || null; - } catch { - return null; - } + const btnDanger = { + backgroundColor: darkMode ? '#b91c1c' : '#dc3545', + color: 'white', + padding: '8px 14px', + borderRadius: '6px', + border: 'none', + cursor: 'pointer', + transition: 'background-color 0.15s ease', }; - const stripHtml = html => { - const tmp = document.createElement('DIV'); - tmp.innerHTML = html; - return tmp.textContent || tmp.innerText || ''; + const btnSuccess = { + backgroundColor: darkMode ? '#22c55e' : '#28a745', + color: 'white', + padding: '10px 16px', + borderRadius: '6px', + border: 'none', + cursor: 'pointer', }; - return ( -
-

{platform}

+ const warningBanner = { + backgroundColor: darkMode ? '#3b3000' : '#fff3cd', + border: '1px solid #ffc107', + borderRadius: '6px', + padding: '12px', + marginBottom: '16px', + color: darkMode ? '#fef3c7' : '#856404', + }; + + const isPostDisabled = isPosting || (platform === 'facebook' && !isConnected); + const isScheduleDisabled = isScheduling || (platform === 'facebook' && !isConnected); -
+ return ( +
+

{platform}

+ +
{tabOrder.map(({ id, label }) => ( ))}
+ {/* COMPOSER TAB */} {activeSubTab === 'composer' && ( -
- {editingPostId && ( -
- ✏️ Editing scheduled post - -
- )} - +
+ setActiveSubTab('settings')} + darkMode={darkMode} + />