From aa01b888bb7b8c1385656c7b98fab343c42a3564 Mon Sep 17 00:00:00 2001 From: Marcus Yi Date: Mon, 16 Mar 2026 15:49:02 -0400 Subject: [PATCH 1/7] build: x frontend posting --- .../Announcements/SocialMediaComposer.jsx | 458 ++++++++++++------ 1 file changed, 302 insertions(+), 156 deletions(-) diff --git a/src/components/Announcements/SocialMediaComposer.jsx b/src/components/Announcements/SocialMediaComposer.jsx index 6ba6ae8de2..4a774d06e9 100644 --- a/src/components/Announcements/SocialMediaComposer.jsx +++ b/src/components/Announcements/SocialMediaComposer.jsx @@ -7,6 +7,78 @@ import './SocialMediaComposer.module.css'; const PREFS_KEY = 'mastodon_composer_prefs'; +// Platform API abstraction +// Keeps every handler free of platform conditionals. +// Add new platforms here; the rest of the component stays unchanged. +const platformAPI = { + mastodon: { + postNow: (content, image, altText, crossPostTo) => ({ + url: '/api/mastodon/createPin', + body: { + title: 'Mastodon Post', + description: content, + imgType: image ? 'FILE' : 'URL', + mediaItems: image ? `data:image/png;base64,${image.base64}` : '', + mediaAltText: altText || null, + crossPostTo, + }, + }), + schedule: (content, image, altText, scheduledTime, crossPostTo) => ({ + url: '/api/mastodon/schedule', + body: { + title: 'Mastodon Scheduled Post', + description: content, + imgType: image ? 'FILE' : 'URL', + mediaItems: image ? `data:image/png;base64,${image.base64}` : '', + mediaAltText: altText || null, + scheduledTime, + crossPostTo, + }, + }), + deleteSchedule: id => `/api/mastodon/schedule/${id}`, + getScheduled: () => '/api/mastodon/schedule', + getHistory: () => '/api/mastodon/history?limit=20', + // Mastodon stores post content inside a JSON-encoded postData field + parseScheduledText: post => { + try { + return JSON.parse(post.postData).status || 'No content'; + } catch { + return 'Invalid post data'; + } + }, + parseScheduledImage: post => { + try { + return JSON.parse(post.postData).local_media_base64 || null; + } catch { + return null; + } + }, + parseScheduledTime: post => post.scheduledTime, + }, + + x: { + postNow: content => ({ + url: '/api/x/post', + body: { content }, + }), + schedule: (content, _image, _altText, scheduledTime) => ({ + url: '/api/x/schedule', + body: { content, scheduledAt: scheduledTime }, + }), + deleteSchedule: id => `/api/x/schedule/${id}`, + getScheduled: () => '/api/x/schedule', + getHistory: () => '/api/x/history?limit=20', + // X model stores fields at top level + parseScheduledText: post => post.content || 'No content', + parseScheduledImage: () => null, // media support comes later + parseScheduledTime: post => post.scheduledAt, + }, +}; + +// Fallback to mastodon shape for any platform not yet wired +const getAPI = platform => platformAPI[platform] || platformAPI.mastodon; + +// Component export default function SocialMediaComposer({ platform }) { const PLATFORM_CHAR_LIMITS = { mastodon: 500, @@ -18,6 +90,7 @@ export default function SocialMediaComposer({ platform }) { }; const charLimit = PLATFORM_CHAR_LIMITS[platform] || 500; + const api = getAPI(platform); const [postContent, setPostContent] = useState(''); const [activeSubTab, setActiveSubTab] = useState('composer'); @@ -70,10 +143,11 @@ export default function SocialMediaComposer({ platform }) { { id: 'details', label: '🧩 Details' }, ]; + // Load data when switching tabs (now works for all wired platforms) useEffect(() => { - if (activeSubTab === 'scheduled' && platform === 'mastodon') { + if (activeSubTab === 'scheduled' && platformAPI[platform]) { loadScheduledPosts(); - } else if (activeSubTab === 'history' && platform === 'mastodon') { + } else if (activeSubTab === 'history' && platformAPI[platform]) { loadPostHistory(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -85,10 +159,11 @@ export default function SocialMediaComposer({ platform }) { localStorage.setItem(PREFS_KEY, JSON.stringify(newPrefs)); }; + // API calls (platform-routed) const loadScheduledPosts = async () => { setIsLoadingScheduled(true); try { - const response = await fetch('/api/mastodon/schedule'); + const response = await fetch(api.getScheduled()); if (response.ok) { const data = await response.json(); setScheduledPosts(data || []); @@ -105,10 +180,11 @@ export default function SocialMediaComposer({ platform }) { const loadPostHistory = async () => { setIsLoadingHistory(true); try { - const response = await fetch('/api/mastodon/history?limit=20'); + const response = await fetch(api.getHistory()); if (response.ok) { const data = await response.json(); - setPostHistory(data || []); + // X wraps results in { posts, total }; Mastodon returns an array + setPostHistory(data.posts || data || []); } else { toast.error('Failed to load post history'); } @@ -200,6 +276,7 @@ export default function SocialMediaComposer({ platform }) { setPreviewOpen(false); }; + // Post Now (platform-routed) const handlePostNow = async () => { if (!postContent.trim()) { toast.error('Post cannot be empty!'); @@ -214,17 +291,17 @@ export default function SocialMediaComposer({ platform }) { setIsPosting(true); try { - const response = await fetch('/api/mastodon/createPin', { + const { url, body } = api.postNow( + postContent.trim(), + uploadedImage, + imageAltText, + selectedPlatforms, + ); + + const response = await fetch(url, { 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, - }), + body: JSON.stringify(body), }); if (response.ok) { @@ -238,7 +315,8 @@ export default function SocialMediaComposer({ platform }) { loadPostHistory(); } } else { - toast.error(`Failed to post to ${platform}.`); + const err = await response.json().catch(() => null); + toast.error(err?.detail || `Failed to post to ${platform}.`); } } catch (err) { toast.error(`Error while posting to ${platform}.`); @@ -247,6 +325,7 @@ export default function SocialMediaComposer({ platform }) { } }; + // Schedule Post (platform-routed) const handleSchedulePost = async () => { if (!postContent.trim()) { toast.error('Post cannot be empty!'); @@ -273,21 +352,21 @@ export default function SocialMediaComposer({ platform }) { try { // If editing, delete the old version first if (editingPostId) { - await fetch(`/api/mastodon/schedule/${editingPostId}`, { method: 'DELETE' }); + await fetch(api.deleteSchedule(editingPostId), { method: 'DELETE' }); } - const response = await fetch('/api/mastodon/schedule', { + const { url, body } = api.schedule( + postContent.trim(), + uploadedImage, + imageAltText, + scheduledDateTime.toISOString(), + selectedPlatforms, + ); + + const response = await fetch(url, { 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, - }), + body: JSON.stringify(body), }); if (response.ok) { @@ -308,38 +387,41 @@ export default function SocialMediaComposer({ platform }) { } }; + // Edit / Delete / Post-Now for scheduled posts const handleEditScheduled = post => { try { - const postData = JSON.parse(post.postData); - - // Load post content - setPostContent(postData.status || ''); + const content = api.parseScheduledText(post); + setPostContent(content); - // Load image if exists - if (postData.local_media_base64) { + // Load image if exists (Mastodon-specific for now) + const imageBase64 = api.parseScheduledImage(post); + if (imageBase64) { setUploadedImage({ - base64: postData.local_media_base64.replace(/^data:image\/\w+;base64,/, ''), - preview: postData.local_media_base64, + base64: imageBase64.replace(/^data:image\/\w+;base64,/, ''), + preview: imageBase64, name: 'scheduled-image.png', }); } // Load alt text if exists - setImageAltText(postData.mediaAltText || ''); + if (platform === 'mastodon') { + try { + const postData = JSON.parse(post.postData); + setImageAltText(postData.mediaAltText || ''); + } catch { + // ignore + } + } // Load scheduled time - const scheduledTime = new Date(post.scheduledTime); + const scheduledTime = new Date(api.parseScheduledTime(post)); 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'); @@ -355,7 +437,7 @@ export default function SocialMediaComposer({ platform }) { const handleDeleteScheduled = async (postId, skipConfirmation = false) => { const performDelete = async () => { try { - const response = await fetch(`/api/mastodon/schedule/${postId}`, { + const response = await fetch(api.deleteSchedule(postId), { method: 'DELETE', }); if (response.ok) { @@ -387,24 +469,36 @@ export default function SocialMediaComposer({ platform }) { const handlePostScheduledNow = async post => { const performPost = async () => { try { - const postData = JSON.parse(post.postData); - const response = await fetch('/api/mastodon/createPin', { + const content = api.parseScheduledText(post); + let req; + + if (platform === 'x') { + req = api.postNow(content); + } else { + // Mastodon path - preserve original image/alt handling + const postData = JSON.parse(post.postData); + req = api.postNow( + postData.status, + postData.local_media_base64 + ? { base64: postData.local_media_base64.replace(/^data:image\/\w+;base64,/, '') } + : null, + postData.mediaAltText, + [], + ); + } + + const response = await fetch(req.url, { 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, - }), + body: JSON.stringify(req.body), }); if (response.ok) { toast.success('Posted successfully!'); await handleDeleteScheduled(post._id, true); } else { - toast.error('Failed to post.'); + const err = await response.json().catch(() => null); + toast.error(err?.detail || 'Failed to post.'); } } catch (err) { toast.error('Error posting.'); @@ -416,8 +510,7 @@ export default function SocialMediaComposer({ platform }) { } else { showModal({ title: 'Post Immediately', - message: - 'This will post immediately to Mastodon and remove it from your scheduled posts. Continue?', + message: `This will post immediately to ${platform} and remove it from your scheduled posts. Continue?`, onConfirm: performPost, confirmText: 'Post Now', confirmColor: 'success', @@ -447,21 +540,21 @@ export default function SocialMediaComposer({ platform }) { } }; - const getScheduledPostImage = post => { - try { - const postData = JSON.parse(post.postData); - return postData.local_media_base64 || null; - } catch { - return null; - } - }; - const stripHtml = html => { const tmp = document.createElement('DIV'); tmp.innerHTML = html; return tmp.textContent || tmp.innerText || ''; }; + // Platform display names / icons + const platformDisplay = { + mastodon: { name: 'Mastodon', icon: 'https://cdn-icons-png.flaticon.com/512/6295/6295417.png' }, + x: { name: 'X', icon: 'https://cdn-icons-png.flaticon.com/512/5969/5969020.png' }, + }; + + const display = platformDisplay[platform] || { name: platform, icon: null }; + + // Render return (

{platform}

@@ -497,50 +590,57 @@ export default function SocialMediaComposer({ platform }) { /> -
- - - {uploadedImage && ( -
-
- Upload preview - -
-
- - setImageAltText(e.target.value)} - placeholder="Describe the image for screen readers..." - className="alt-text-input" - maxLength={1500} - /> -
- {imageAltText.length} / 1500 characters + {/* Image upload - hide for X until media support is added */} + {platform !== 'x' && ( +
+ + + {uploadedImage && ( +
+
+ Upload preview + +
+
+ + setImageAltText(e.target.value)} + placeholder="Describe the image for screen readers..." + className="alt-text-input" + maxLength={1500} + /> +
+ {imageAltText.length} / 1500 characters +
-
- )} -
+ )} +
+ )}
); @@ -1057,7 +1186,7 @@ export default function SocialMediaComposer({ platform }) { ) : ( )} From 216fe486b2e3ed4bc73a6fa87cc91898aed54807 Mon Sep 17 00:00:00 2001 From: Marcus Yi Date: Mon, 18 May 2026 20:29:35 -0400 Subject: [PATCH 4/7] fix: merge conflicts --- package.json | 2 +- .../Announcements/SocialMediaComposer.jsx | 135 +++++++++--------- ...ser.css => SocialMediaComposer.module.css} | 0 3 files changed, 72 insertions(+), 65 deletions(-) rename src/components/Announcements/{SocialMediaComposer.css => SocialMediaComposer.module.css} (100%) diff --git a/package.json b/package.json index 3138f21cbc..b3365a0fa5 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "leaflet": "^1.9.4", "leaflet.heat": "^0.2.0", "leaflet.markercluster": "^1.5.3", - "libphonenumber-js": "^1.12.40", + "libphonenumber-js": "^1.12.41", "lodash": "^4.17.21", "lucide-react": "^0.484.0", "micromatch": "^4.0.8", diff --git a/src/components/Announcements/SocialMediaComposer.jsx b/src/components/Announcements/SocialMediaComposer.jsx index 08ba800f0e..589f4eeb59 100644 --- a/src/components/Announcements/SocialMediaComposer.jsx +++ b/src/components/Announcements/SocialMediaComposer.jsx @@ -3,7 +3,7 @@ import { toast } from 'react-toastify'; import { Modal, ModalHeader, ModalBody, ModalFooter, Button } from 'reactstrap'; import CharacterCounter from './CharacterCounter'; import ConfirmationModal from './ConfirmationModal'; -import './SocialMediaComposer.css'; +import styles from './SocialMediaComposer.module.css'; const PREFS_KEY = 'mastodon_composer_prefs'; @@ -288,10 +288,7 @@ export default function SocialMediaComposer({ platform }) { } catch { // clipboard may fail in non-HTTPS contexts; continue anyway } - window.open( - `https://x.com/intent/tweet?text=${encodeURIComponent(content)}`, - '_blank', - ); + window.open(`https://x.com/intent/tweet?text=${encodeURIComponent(content)}`, '_blank'); toast.success('Content copied to clipboard! X is opening β€” paste and post.', { autoClose: 5000, }); @@ -635,15 +632,15 @@ export default function SocialMediaComposer({ platform }) { // Render return ( -
-

{platform}

+
+

{platform}

-
+
{tabOrder.map(({ id, label }) => ( @@ -668,11 +665,15 @@ export default function SocialMediaComposer({ platform }) { )} {activeSubTab === 'composer' && ( -
+
{editingPostId && ( -
+
✏️ Editing scheduled post -
@@ -682,14 +683,14 @@ export default function SocialMediaComposer({ platform }) { value={postContent} onChange={e => setPostContent(e.target.value)} placeholder={`Write your ${platform} post here...`} - className="post-textarea" + className={styles['post-textarea']} /> {/* Image upload - hide for X until media support is added */} {platform !== 'x' && ( -
-