From 410c2ceb690e81908963525a6a306adca7be8d04 Mon Sep 17 00:00:00 2001 From: Mahitha-pasupuleti Date: Sat, 17 Oct 2026 13:41:52 -0500 Subject: [PATCH 1/2] Add Medium Auto-Poster feature --- src/components/Announcements/index.jsx | 5 + .../Announcements/platforms/index.js | 1 + .../medium/MediumAutoPoster.module.css | 425 +++++++++ .../Announcements/platforms/medium/index.jsx | 833 ++++++++++++++++++ 4 files changed, 1264 insertions(+) create mode 100644 src/components/Announcements/platforms/medium/MediumAutoPoster.module.css create mode 100644 src/components/Announcements/platforms/medium/index.jsx diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index 4f9b0c6cc28..679502faf4c 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -15,10 +15,12 @@ import { useHistory, useLocation } from 'react-router-dom'; import ReactTooltip from 'react-tooltip'; import { Nav, NavItem, NavLink, TabContent, TabPane } from 'reactstrap'; import styles from './Announcements.module.css'; + import EmailPanel from './platforms/email'; import RedditAutoPoster from './platforms/reddit'; import SlashdotAutoPoster from './platforms/slashdot'; import SocialMediaComposer from './platforms/social/SocialMediaComposer'; +import MediumAutoPoster from './platforms/medium'; function Announcements({ title, email: initialEmail }) { const [activeTab, setActiveTab] = useState('email'); @@ -246,6 +248,9 @@ function Announcements({ title, email: initialEmail }) { case 'reddit': PlatformComposer = RedditAutoPoster; break; + case 'medium': + PlatformComposer = MediumAutoPoster; + break; default: PlatformComposer = SocialMediaComposer; } diff --git a/src/components/Announcements/platforms/index.js b/src/components/Announcements/platforms/index.js index 7c5caec51ad..862faedc94a 100644 --- a/src/components/Announcements/platforms/index.js +++ b/src/components/Announcements/platforms/index.js @@ -1,6 +1,7 @@ // Platform exports for Announcements export { default as EmailPanel } from './email'; export { default as SocialMediaComposer } from './social'; +export { default as MediumAutoPoster } from './medium'; // Re-export shared components export { default as TinyMCE } from '../shared/tinyMCE'; diff --git a/src/components/Announcements/platforms/medium/MediumAutoPoster.module.css b/src/components/Announcements/platforms/medium/MediumAutoPoster.module.css new file mode 100644 index 00000000000..9c2c224dab0 --- /dev/null +++ b/src/components/Announcements/platforms/medium/MediumAutoPoster.module.css @@ -0,0 +1,425 @@ + +.medium-autoposter { + width: 100%; + margin: 0 auto; + display: grid; + gap: 24px; + color: #1b1f29; +} + +.medium-autoposter.dark { + color: #dbe6ff; +} + +.medium-autoposter label { + display: block; + margin-bottom: 8px; + font-weight: 600; +} + +.medium-autoposter p { + line-height: 1.6; +} + +.subtabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; + border-bottom: 1px solid #ccd4e0; +} + +.subtab { + padding: 9px 16px; + border: 1px solid transparent; + border-bottom: none; + border-radius: 6px 6px 0 0; + background: #d9d9d9; + color: #333; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease; +} + +.subtab:hover { + background: #cfcfcf; +} + +.subtab.active { + background: #d7ecff; + color: #0d6efd; + border-color: #99c8ff; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 60%); +} + +.dark .subtab { + background: #2d3c53; + color: #cdd8f6; +} + +.dark .subtab.active { + background: #1f4a80; + border-color: #1f4a80; + color: #fff; +} + +.card { + min-width: 0; + padding: 20px 22px; + border: 1px solid #d6dde7; + border-radius: 12px; + background: #fff; + box-shadow: 0 10px 24px rgb(15 37 80 / 8%); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.dark .card { + background: #14233a; + border-color: #25354d; + box-shadow: none; +} + +.card.invalid { + border-color: #d9534f; + box-shadow: 0 0 0 1px rgb(217 83 79 / 18%); +} + +.grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); +} + +.topActions, +.fieldActions, +.previewActions, +.schedulerActions, +.savedActions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.fieldHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; +} + +.fieldMeta { + color: #6c757d; + font-size: 0.85rem; +} + +.invalidText { + color: #d9534f; +} + +.error { + color: #d9534f; + margin-top: 8px; + font-size: 0.85rem; +} + +.dark .error { + color: #ff9384; +} + +.dark .fieldMeta { + color: #9aa9c6; +} + +.input { + display: block; + width: 100%; + min-width: 0; + padding: 12px 14px; + border: 1px solid #c7d1e5; + border-radius: 8px; + background: #fff; + color: #1b1f29; + font: inherit; + font-size: 0.95rem; +} + +.input:focus { + border-color: #0d6efd; + outline: 2px solid rgb(13 110 253 / 15%); +} + +.dark .input { + background: #0f1c2d; + border-color: #2b3b55; + color: #e4edff; +} + +.textarea { + min-height: 180px; + resize: vertical; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.tagInput { + min-height: 80px; + resize: vertical; +} + +.hint { + margin-top: 8px; + font-size: 0.85rem; +} + +.dark .hint { + color: #9aa9c6; +} + +.chips { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + padding: 4px 10px; + border-radius: 999px; + background: #e9efff; + color: #1c3f82; + font-size: 0.8rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.dark .chip { + background: rgb(13 110 253 / 22%); + color: #cfe0ff; +} + +.chipClear { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 4px; + border: none; + background: transparent; + color: #d9534f; + font-size: 1.1rem; + cursor: pointer; +} + +.chipClear:hover { + color: #b7322d; +} + +.dark .chipClear { + color: #ff9384; +} + +.previewHeader { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; +} + +.previewHeader h4 { + margin: 0; +} + +.previewActions { + justify-content: flex-end; + margin-top: 0; +} + +.previewBody { + max-height: 320px; + padding: 16px; + overflow: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; + border: 1px solid #d6dde7; + border-radius: 8px; + background: #f8f9fb; + color: #27324b; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.9rem; +} + +.dark .previewBody { + background: #0f1c2d; + border-color: #2b3b55; + color: #e4edff; +} + +.previewHint { + margin-top: 12px; + color: #6c757d; + font-size: 0.85rem; +} + +.dark .previewHint { + color: #9aa9c6; +} + +.schedulerGrid { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + align-items: start; + gap: 20px; +} + +.schedulerCard, +.savedCard { + width: 100%; + min-width: 0; +} + +.schedulerNote { + margin-top: 12px; + color: #6c757d; + font-size: 0.85rem; +} + +.dark .schedulerNote { + color: #9aa9c6; +} + +.scheduleControls { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin: 18px 0; +} + +.scheduleField { + display: flex; + flex: 1 1 200px; + min-width: 0; + flex-direction: column; + gap: 8px; +} + +.schedulerTextarea { + min-height: 220px; + resize: vertical; +} + +.savedList { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 18px; +} + +.empty { + margin: 8px 0 0; + color: #6c757d; + font-size: 0.9rem; +} + +.dark .empty { + color: #9aa9c6; +} + +.savedItem { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 14px; + border: 1px solid #d6dde7; + border-radius: 10px; + background: #f4f7fd; +} + +.dark .savedItem { + background: #0f1c2d; + border-color: #2b3b55; +} + +.savedItemActive { + border-color: #0d6efd; + box-shadow: 0 0 0 1px rgb(13 110 253 / 24%); +} + +.dark .savedItemActive { + border-color: #4785ff; + box-shadow: 0 0 0 1px rgb(71 133 255 / 32%); +} + +.savedHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; +} + +.savedTitle { + margin: 0; + color: #1b1f29; + font-size: 1rem; + font-weight: 600; + overflow-wrap: anywhere; +} + +.dark .savedTitle { + color: #dbe6ff; +} + +.savedMeta { + color: #6c757d; + font-size: 0.85rem; +} + +.dark .savedMeta { + color: #9aa9c6; +} + +.savedExcerpt { + margin: 0; + color: #4f5a73; + font-size: 0.9rem; + overflow-wrap: anywhere; +} + +.dark .savedExcerpt { + color: #cfd9f8; +} + +.savedActions { + margin-top: 0; +} + +@media (width <= 960px) { + .schedulerGrid { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (width <= 640px) { + .medium-autoposter { + gap: 18px; + } + + .card { + padding: 18px; + } + + .previewHeader { + align-items: flex-start; + } + + .previewActions { + justify-content: flex-start; + } +} diff --git a/src/components/Announcements/platforms/medium/index.jsx b/src/components/Announcements/platforms/medium/index.jsx new file mode 100644 index 00000000000..3829f58e830 --- /dev/null +++ b/src/components/Announcements/platforms/medium/index.jsx @@ -0,0 +1,833 @@ +import React, { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; +import { toast } from 'react-toastify'; +import styles from './MediumAutoPoster.module.css'; + +const TITLE_MIN = 10; +const TITLE_MAX = 100; +const CONTENT_MIN = 50; + +const sanitizeTags = text => + [ + ...new Set( + text + .split(',') + .map(tag => + tag + .trim() + .replace(/^#/, '') + .toLowerCase(), + ) + .filter(Boolean) + .map(tag => tag.replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '')) + .filter(Boolean), + ), + ].slice(0, 5); + +const pad = value => String(value).padStart(2, '0'); + +const formatDate = date => + `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + +const formatTime = date => `${pad(date.getHours())}:${pad(date.getMinutes())}`; + +const formatDateTime = (date, time) => { + if (!date) return 'Date unavailable'; + + const parsed = new Date(`${date}T${time || '00:00'}`); + + return Number.isNaN(parsed.getTime()) + ? date + : parsed.toLocaleString(undefined, { + dateStyle: 'medium', + timeStyle: time ? 'short' : undefined, + }); +}; + +const makeId = () => + `medium-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + +const buttonStyle = (variant, darkMode) => { + const base = { + borderRadius: '999px', + border: 'none', + cursor: 'pointer', + fontWeight: 600, + padding: '10px 18px', + transition: 'filter 0.2s ease', + }; + + if (variant === 'primary') { + return { ...base, backgroundColor: '#0d6efd', color: '#fff' }; + } + + if (variant === 'outline') { + return { + ...base, + backgroundColor: 'transparent', + color: darkMode ? '#9bb5ff' : '#0d6efd', + border: `1px solid ${darkMode ? '#3d4d6d' : '#0d6efd'}`, + }; + } + + return { + ...base, + backgroundColor: darkMode ? '#1c2b44' : '#e9efff', + color: darkMode ? '#cfd9f8' : '#1c3f82', + }; +}; + +function MediumAutoPoster() { + const darkMode = useSelector(state => state.theme?.darkMode); + + const [activeTab, setActiveTab] = useState('make'); + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + const [tagsText, setTagsText] = useState(''); + const [canonicalUrl, setCanonicalUrl] = useState(''); + const [visibility, setVisibility] = useState('draft'); + + const [scheduledDraft, setScheduledDraft] = useState(''); + const [scheduledDate, setScheduledDate] = useState(() => formatDate(new Date())); + const [scheduledTime, setScheduledTime] = useState(() => formatTime(new Date())); + const [savedSchedules, setSavedSchedules] = useState([]); + const [editingId, setEditingId] = useState(null); + const [scheduleAttemptedSave, setScheduleAttemptedSave] = useState(false); + + const tags = useMemo(() => sanitizeTags(tagsText), [tagsText]); + + const trimmedTitle = title.trim(); + const trimmedContent = content.trim(); + const trimmedUrl = canonicalUrl.trim(); + + const titleValid = trimmedTitle.length >= TITLE_MIN && trimmedTitle.length <= TITLE_MAX; + + const contentValid = trimmedContent.length >= CONTENT_MIN; + + const urlValid = !trimmedUrl || /^https?:\/\/\S+$/i.test(trimmedUrl); + + const readyToCopy = titleValid && contentValid && tags.length > 0 && urlValid; + + const titleInvalid = trimmedTitle.length > 0 && !titleValid; + const contentInvalid = trimmedContent.length > 0 && !contentValid; + const urlInvalid = !urlValid; + + const hasInput = Boolean(trimmedTitle || trimmedContent || tagsText.trim() || trimmedUrl); + + const buildDraft = () => { + const tagList = tags.length ? tags.map(tag => `#${tag}`).join(', ') : '—'; + + return [ + `Title\n${trimmedTitle || '—'}`, + `Content\n${trimmedContent || '—'}`, + `Tags\n${tagList}`, + `Canonical URL\n${trimmedUrl || '—'}`, + `Visibility\n${visibility}`, + ].join('\n\n'); + }; + + const preview = useMemo( + () => (hasInput ? buildDraft() : ''), + // The preview should update whenever the editor fields change. + // eslint-disable-next-line react-hooks/exhaustive-deps + [title, content, tagsText, canonicalUrl, visibility, hasInput], + ); + + const editingSchedule = useMemo( + () => savedSchedules.find(item => item.id === editingId) || null, + [savedSchedules, editingId], + ); + + const scheduleHasDraft = Boolean(scheduledDraft.trim()); + + const copyText = async (text, label) => { + if (!text?.trim()) { + toast.warn(`Nothing to copy for ${label}.`); + return; + } + + try { + await navigator.clipboard.writeText(text); + toast.success(`${label} copied to clipboard.`); + } catch { + toast.error(`Could not copy ${label.toLowerCase()}.`); + } + }; + + const resetEditor = () => { + setTitle(''); + setContent(''); + setTagsText(''); + setCanonicalUrl(''); + setVisibility('draft'); + }; + + const validateArticle = () => { + if (!trimmedTitle) { + toast.error('Please enter an article title.'); + return false; + } + + if (!titleValid) { + toast.error(`Title must contain ${TITLE_MIN}-${TITLE_MAX} characters.`); + return false; + } + + if (!trimmedContent) { + toast.error('Please enter article content.'); + return false; + } + + if (!contentValid) { + toast.error(`Article content must contain at least ${CONTENT_MIN} characters.`); + return false; + } + + if (!tags.length) { + toast.error('Please add at least one tag.'); + return false; + } + + if (!urlValid) { + toast.error('Enter a valid HTTP(S) canonical URL or leave it empty.'); + return false; + } + + return true; + }; + + const handleReset = () => { + resetEditor(); + toast.info('Editor fields cleared.'); + }; + + const handleScheduleClick = () => { + if (!validateArticle()) return; + + const now = new Date(); + + setScheduledDate(formatDate(now)); + setScheduledTime(formatTime(now)); + setScheduledDraft(preview); + setScheduleAttemptedSave(false); + setEditingId(null); + setActiveTab('schedule'); + + toast.success('Draft moved to the Schedule tab.'); + }; + + const handleScheduleDateChange = event => { + const nextDate = event.target.value; + if (!nextDate) return; + + const today = formatDate(new Date()); + const safeDate = nextDate < today ? today : nextDate; + + setScheduledDate(safeDate); + setScheduleAttemptedSave(false); + + if (safeDate === today) { + const currentTime = formatTime(new Date()); + setScheduledTime(previous => (!previous || previous < currentTime ? currentTime : previous)); + } + }; + + const handleScheduleTimeChange = event => { + const nextTime = event.target.value; + if (!nextTime) return; + + if (scheduledDate === formatDate(new Date())) { + const currentTime = formatTime(new Date()); + setScheduledTime(nextTime < currentTime ? currentTime : nextTime); + } else { + setScheduledTime(nextTime); + } + + setScheduleAttemptedSave(false); + }; + + const handleSaveSchedule = () => { + setScheduleAttemptedSave(true); + + if (!scheduleHasDraft) { + toast.warn('Add an article to the schedule first.'); + return; + } + + if (!scheduledDate || !scheduledTime) { + toast.error('Choose a schedule date and time.'); + return; + } + + const scheduledAt = new Date(`${scheduledDate}T${scheduledTime}`); + + if (Number.isNaN(scheduledAt.getTime()) || scheduledAt <= new Date()) { + toast.error('Choose a future date and time.'); + return; + } + + const id = editingId || makeId(); + + const record = { + id, + title: trimmedTitle, + content: trimmedContent, + tags: [...tags], + tagsText, + canonicalUrl: trimmedUrl, + visibility, + scheduledDraft: scheduledDraft.trim(), + scheduledDate, + scheduledTime, + updatedAt: new Date().toISOString(), + }; + + setSavedSchedules(previous => [record, ...previous.filter(item => item.id !== id)]); + + toast.success( + editingId + ? 'Scheduled draft updated for this session.' + : 'Scheduled draft saved for this session.', + ); + + resetEditor(); + setScheduledDraft(''); + setScheduledDate(formatDate(new Date())); + setScheduledTime(formatTime(new Date())); + setScheduleAttemptedSave(false); + setEditingId(null); + setActiveTab('make'); + }; + + const handleEditSchedule = item => { + const today = formatDate(new Date()); + let date = item.scheduledDate || today; + let time = item.scheduledTime || formatTime(new Date()); + + if (date < today) date = today; + + if (date === today && time < formatTime(new Date())) { + time = formatTime(new Date()); + } + + setTitle(item.title || ''); + setContent(item.content || ''); + setTagsText(item.tagsText || ''); + setCanonicalUrl(item.canonicalUrl || ''); + setVisibility(item.visibility || 'draft'); + + setScheduledDraft(item.scheduledDraft || ''); + setScheduledDate(date); + setScheduledTime(time); + setEditingId(item.id); + setScheduleAttemptedSave(false); + setActiveTab('schedule'); + + toast.info('Loaded scheduled draft for editing.'); + }; + + const handleDeleteSchedule = id => { + setSavedSchedules(previous => previous.filter(item => item.id !== id)); + + if (editingId === id) { + setEditingId(null); + setScheduledDraft(''); + } + + toast.info('Scheduled draft removed from this session.'); + }; + + const openMediumEditor = () => { + window.open('https://medium.com/new-story', '_blank', 'noopener,noreferrer'); + }; + + const removeTag = tagToRemove => { + setTagsText(tags.filter(tag => tag !== tagToRemove).join(', ')); + }; + + const today = formatDate(new Date()); + const scheduleTimeMin = scheduledDate === today ? formatTime(new Date()) : undefined; + + return ( +
+
+ {[ + { id: 'make', label: '📝 Make Post' }, + { id: 'schedule', label: '⏰ Scheduled Post' }, + ].map(tab => ( + + ))} +
+ + {activeTab === 'make' ? ( + <> +
+

Medium Auto-Poster

+

+ Create and format a Medium article draft. Add a title, article content, relevant tags, + and optional canonical URL. Preview your draft, copy the content, or open Medium to + submit it manually. +

+ +
+ +
+
+ +
+
+
+ + + {title.length}/{TITLE_MAX} + +
+ + setTitle(event.target.value)} + className={styles.input} + placeholder="Write a clear, engaging title" + /> + + {!trimmedTitle &&

Use at least {TITLE_MIN} characters.

} + + {titleInvalid && ( +

+ The title must contain {TITLE_MIN}-{TITLE_MAX} characters. +

+ )} + +
+ + +
+
+ +
+
+ + optional +
+ + setCanonicalUrl(event.target.value)} + className={styles.input} + placeholder="https://example.com/original-article" + /> + +

+ {urlInvalid + ? 'Enter a valid HTTP(S) URL.' + : 'Add the original article URL if this content was published elsewhere.'} +

+ +
+ + +
+
+ +
+
+ + + {content.length.toLocaleString()} characters + +
+ +