diff --git a/eslint.config.js b/eslint.config.js index dafdfe29ce..bf8e52bfc0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -67,6 +67,7 @@ module.exports = [ }, rules: { 'react/react-in-jsx-scope': 'off', + 'react/no-unescaped-entities': 'off', 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx'] }], 'no-underscore-dangle': 'off', 'react/prop-types': 'off', diff --git a/src/components/Announcements/index.jsx b/src/components/Announcements/index.jsx index 372d815b25..51a86422ac 100644 --- a/src/components/Announcements/index.jsx +++ b/src/components/Announcements/index.jsx @@ -21,6 +21,7 @@ import BlueskyPostDetails from './BlueskyPostDetails'; import EmailPanel from './platforms/email'; import LinkedInAutoPoster from './platforms/linkedin'; import SlashdotAutoPoster from './platforms/slashdot'; +import RedditAutoPoster from './platforms/reddit'; function Announcements({ title, email: initialEmail }) { const [activeTab, setActiveTab] = useState('email'); @@ -181,13 +182,17 @@ function Announcements({ title, email: initialEmail }) { 'slashdot', 'blogger', ].map(platform => { - let PlatformComposer = SocialMediaComposer; - if (platform === 'slashdot') { - PlatformComposer = SlashdotAutoPoster; - } else if (platform === 'bluesky') { - PlatformComposer = BlueskyPostDetails; + let PlatformComposer; + switch (platform) { + case 'slashdot': + PlatformComposer = SlashdotAutoPoster; + break; + case 'reddit': + PlatformComposer = RedditAutoPoster; + break; + default: + PlatformComposer = SocialMediaComposer; } - return ( diff --git a/src/components/Announcements/platforms/reddit/Reddit.module.css b/src/components/Announcements/platforms/reddit/Reddit.module.css new file mode 100644 index 0000000000..4a48dc6152 --- /dev/null +++ b/src/components/Announcements/platforms/reddit/Reddit.module.css @@ -0,0 +1,489 @@ +.reddit-autoposter { + width: 100%; + margin: 0 auto; + display: grid; + gap: 24px; +} + +.reddit-autoposter.dark { + color: #dbe6ff; +} + +.reddit-autoposter.dark label { + color: #dbe6ff; +} + +.reddit-autoposter.dark p { + color: #dbe6ff; +} + +/* ── Sub-tabs ── */ +.reddit-subtabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 18px; + border-bottom: 1px solid #ccd4e0; +} + +.reddit-subtab { + padding: 9px 16px; + border-radius: 6px 6px 0 0; + border: 1px solid transparent; + border-bottom: none; + background: #d9d9d9; + color: #333; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.reddit-subtab:hover { + background: #cfcfcf; +} + +.reddit-subtab.active { + background: #fff0eb; + color: #c2410c; + border-color: #ffb59e; + box-shadow: inset 0 1px 0 rgb(255 255 255 / 60%); +} + +.reddit-autoposter.dark .reddit-subtab { + background: #2d3c53; + color: #cdd8f6; + border-color: transparent; +} + +.reddit-autoposter.dark .reddit-subtab.active { + background: #5c1f00; + border-color: #a03000; + color: #ffb89e; +} + +/* ── Cards ── */ +.reddit-card { + background: #fff; + border: 1px solid #d6dde7; + border-radius: 12px; + padding: 20px 22px; + box-shadow: 0 10px 24px rgb(15 37 80 / 8%); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.reddit-autoposter.dark .reddit-card { + background: #14233a; + border-color: #25354d; + box-shadow: none; +} + +.reddit-card.invalid { + border-color: #d9534f; + box-shadow: 0 0 0 1px rgb(217 83 79 / 18%); +} + +.reddit-autoposter.dark .reddit-card.invalid { + border-color: #ff7b72; + box-shadow: none; +} + +.reddit-card--wide { + grid-column: 1 / -1; +} + +/* ── Grid ── */ +.reddit-grid { + display: grid; + gap: 20px; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +/* ── Post type toggle ── */ +.reddit-type-toggle { + display: flex; + align-items: center; + gap: 16px; + margin-top: 16px; + flex-wrap: wrap; +} + +.reddit-toggle-group { + display: flex; + gap: 8px; +} + +.reddit-toggle-btn { + padding: 8px 18px; + border-radius: 999px; + border: 1px solid #d6dde7; + background: #f4f7fd; + color: #555; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.reddit-toggle-btn:hover { + background: #ffe8df; + border-color: #ffb59e; +} + +.reddit-toggle-btn.active { + background: #c2410c; + color: #fff; + border-color: #c2410c; +} + +.reddit-autoposter.dark .reddit-toggle-btn { + background: #1c2b44; + border-color: #2b3b55; + color: #cdd8f6; +} + +.reddit-autoposter.dark .reddit-toggle-btn.active { + background: #c43300; + border-color: #c43300; + color: #fff; +} + +/* ── Field header ── */ +.reddit-field__header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 8px; +} + +.reddit-field__meta { + font-size: 0.85rem; + color: #6c757d; +} + +.reddit-field__meta.invalid { + color: #d9534f; +} + +.reddit-autoposter.dark .reddit-field__meta { + color: #9aa9c6; +} + +.reddit-field__required { + color: #d9534f; + margin-left: 4px; +} + +.reddit-autoposter.dark .reddit-field__required { + color: #ff9384; +} + +/* ── Inputs ── */ +.reddit-field__input { + width: 100%; + border: 1px solid #c7d1e5; + border-radius: 8px; + padding: 12px 14px; + font-size: 0.95rem; + background: #fff; + color: #1b1f29; + box-sizing: border-box; +} + +.reddit-autoposter.dark .reddit-field__input { + background: #0f1c2d; + border-color: #2b3b55; + color: #e4edff; +} + +.reddit-autoposter.dark .reddit-field__input[type='date'], +.reddit-autoposter.dark .reddit-field__input[type='time'] { + color-scheme: dark; +} + +.reddit-field__input--invalid { + border-color: #d9534f; + box-shadow: 0 0 0 1px rgb(217 83 79 / 20%); +} + +.reddit-autoposter.dark .reddit-field__input--invalid { + border-color: #ff9384; + box-shadow: 0 0 0 1px rgb(255 147 132 / 30%); +} + +/* Subreddit prefix wrapper */ +.reddit-subreddit-input-wrap { + display: flex; + align-items: center; + border: 1px solid #c7d1e5; + border-radius: 8px; + overflow: hidden; + background: #fff; +} + +.reddit-autoposter.dark .reddit-subreddit-input-wrap { + background: #0f1c2d; + border-color: #2b3b55; +} + +.reddit-subreddit-prefix { + padding: 12px 10px 12px 14px; + background: #f4f7fd; + color: #6c757d; + font-weight: 700; + font-size: 0.95rem; + border-right: 1px solid #c7d1e5; + flex-shrink: 0; +} + +.reddit-autoposter.dark .reddit-subreddit-prefix { + background: #0d1a2b; + color: #9aa9c6; + border-right-color: #2b3b55; +} + +.reddit-field__input--subreddit { + border: none; + border-radius: 0; + box-shadow: none; + flex: 1; +} + +.reddit-field__input--subreddit:focus { + outline: none; +} + +.reddit-field__textarea { + resize: vertical; + min-height: 110px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.reddit-field__error { + color: #d9534f; + font-size: 0.85rem; + margin-top: 8px; +} + +.reddit-autoposter.dark .reddit-field__error { + color: #ff9384; +} + +.reddit-field__hint { + color: #6c757d; + font-size: 0.85rem; + margin-top: 8px; +} + +.reddit-autoposter.dark .reddit-field__hint { + color: #9aa9c6; +} + +/* ── Preview ── */ +.reddit-preview__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.reddit-preview__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.reddit-preview__body { + white-space: pre-wrap; + font-family: SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; + background: #f8f9fb; + border: 1px solid #d6dde7; + border-radius: 8px; + padding: 16px; + color: #27324b; + max-height: 240px; + overflow: auto; + overflow-wrap: anywhere; +} + +.reddit-autoposter.dark .reddit-preview__body { + background: #0f1c2d; + border-color: #2b3b55; + color: #e4edff; +} + +.reddit-preview__hint { + font-size: 0.85rem; + color: #6c757d; + margin-top: 12px; +} + +.reddit-autoposter.dark .reddit-preview__hint { + color: #9aa9c6; +} + +/* ── Scheduler ── */ +.reddit-card--scheduler { + width: 100%; +} + +.reddit-scheduler__grid { + display: grid; + gap: 20px; + grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr); + align-items: start; +} + +.reddit-card--saved { + max-width: 100%; +} + +.reddit-scheduler__note { + font-size: 0.85rem; + color: #6c757d; + margin-top: 12px; +} + +.reddit-autoposter.dark .reddit-scheduler__note { + color: #9aa9c6; +} + +.reddit-scheduler__controls { + display: flex; + flex-wrap: wrap; + gap: 16px; + margin: 18px 0; +} + +.reddit-scheduler__field { + flex: 1 1 200px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.reddit-scheduler__textarea { + min-height: 220px; +} + +.reddit-scheduler__actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 18px; +} + +.reddit-scheduler__empty { + font-size: 0.9rem; + color: #6c757d; + margin: 8px 0 0; +} + +.reddit-autoposter.dark .reddit-scheduler__empty { + color: #9aa9c6; +} + +/* ── Saved list ── */ +.reddit-saved__list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 18px; +} + +.reddit-saved__item { + border: 1px solid #d6dde7; + border-radius: 10px; + background: #f4f7fd; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.reddit-autoposter.dark .reddit-saved__item { + border-color: #2b3b55; + background: #0f1c2d; +} + +.reddit-saved__item--active { + border-color: #c2410c; + box-shadow: 0 0 0 1px rgb(255 69 0 / 24%); +} + +.reddit-autoposter.dark .reddit-saved__item--active { + border-color: #ff6030; + box-shadow: 0 0 0 1px rgb(255 96 48 / 32%); +} + +.reddit-saved__header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.reddit-saved__title { + font-size: 1rem; + font-weight: 600; + margin: 0; + color: #1b1f29; +} + +.reddit-autoposter.dark .reddit-saved__title { + color: #dbe6ff; +} + +.reddit-saved__meta { + font-size: 0.85rem; + color: #6c757d; +} + +.reddit-autoposter.dark .reddit-saved__meta { + color: #9aa9c6; +} + +.reddit-saved__excerpt { + font-size: 0.9rem; + color: #4f5a73; + margin: 0; +} + +.reddit-autoposter.dark .reddit-saved__excerpt { + color: #cfd9f8; +} + +.reddit-saved__actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +/* ── Responsive ── */ +@media (width <= 960px) { + .reddit-scheduler__grid { + grid-template-columns: 1fr; + } +} + +@media (width <= 640px) { + .reddit-autoposter { + gap: 18px; + } + + .reddit-card { + padding: 18px; + } + + .reddit-preview__header { + flex-direction: column; + align-items: flex-start; + } + + .reddit-preview__actions { + justify-content: flex-start; + } +} \ No newline at end of file diff --git a/src/components/Announcements/platforms/reddit/Reddithelpers.js b/src/components/Announcements/platforms/reddit/Reddithelpers.js new file mode 100644 index 0000000000..39b4993cea --- /dev/null +++ b/src/components/Announcements/platforms/reddit/Reddithelpers.js @@ -0,0 +1,240 @@ +// ─── Constants ─────────────────────────────────────────────────────────────── + +export const TITLE_MIN = 5; +export const TITLE_MAX = 300; +export const BODY_MAX = 40000; + +export const STOP_WORDS = new Set([ + 'about', + 'after', + 'also', + 'another', + 'because', + 'been', + 'being', + 'between', + 'can', + 'could', + 'during', + 'each', + 'from', + 'have', + 'into', + 'more', + 'other', + 'over', + 'since', + 'some', + 'than', + 'that', + 'their', + 'there', + 'these', + 'they', + 'this', + 'through', + 'under', + 'until', + 'where', + 'which', + 'while', + 'with', + 'within', +]); + +export const FLAIR_RULES = [ + { + flair: 'Question', + patterns: [/\?/, /\bhow\b/, /\bwhat\b/, /\bwhy\b/, /\bhelp\b/, /\bissue\b/, /\bproblem\b/], + }, + { + flair: 'Discussion', + patterns: [/\bdiscussion\b/, /\bthoughts\b/, /\bopinion\b/, /\bdebate\b/, /\bshould\b/], + }, + { + flair: 'News', + patterns: [ + /\bnews\b/, + /\breleased\b/, + /\blaunch\b/, + /\bannouncement\b/, + /\bupdate\b/, + /\bbreaking\b/, + ], + }, + { + flair: 'Tutorial', + patterns: [/\btutorial\b/, /\bguide\b/, /\bstep[- ]by[- ]step\b/, /\blearn\b/], + }, + { + flair: 'Showcase', + patterns: [/\bshowcase\b/, /\bproject\b/, /\bbuilt\b/, /\bmade\b/, /\bcreated\b/], + }, + { + flair: 'Bug', + patterns: [/\bbug\b/, /\berror\b/, /\bfix\b/, /\bcrash\b/, /\bissue\b/], + }, +]; + +// ─── String / field utilities ───────────────────────────────────────────────── + +/** Strips r/ prefix and non-alphanumeric-underscore chars; caps at 21 chars. */ +export const sanitizeSubreddit = raw => + raw + .trim() + .replace(/^r\//, '') + .replace(/\W/g, '') + .slice(0, 21); + +export const buildPreview = ({ title, url, subreddit, flair, body }) => + `Subreddit\nr/${subreddit?.trim() || '—'}\n\nTitle\n${title?.trim() || + '—'}\n\nURL\n${url?.trim() || '—'}\n\nBody\n${body?.trim() || '—'}\n\nFlair\n${flair?.trim() || + '(none)'}\n`; + +// ─── Date / time utilities ──────────────────────────────────────────────────── + +const padTimeUnit = value => String(value).padStart(2, '0'); + +export const formatLocalDate = date => + `${date.getFullYear()}-${padTimeUnit(date.getMonth() + 1)}-${padTimeUnit(date.getDate())}`; + +export const formatLocalTime = date => + `${padTimeUnit(date.getHours())}:${padTimeUnit(date.getMinutes())}`; + +const fallbackDateTime = (dateString, timeString) => { + const formattedTime = timeString ? `, ${timeString}` : ''; + + return `${dateString}${formattedTime}`; +}; + +const formatParsedDateTime = (parsed, timeString) => { + const formattedDate = parsed.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + const formattedTime = timeString + ? parsed.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' }) + : ''; + return formattedTime ? `${formattedDate} • ${formattedTime}` : formattedDate; +}; + +export const formatDisplayDateTime = (dateString, timeString) => { + if (!dateString) return '—'; + try { + const parsed = new Date(`${dateString}T${timeString || '00:00'}`); + if (Number.isNaN(parsed.getTime())) return fallbackDateTime(dateString, timeString); + return formatParsedDateTime(parsed, timeString); + } catch { + return fallbackDateTime(dateString, timeString); + } +}; + +/** + * Clamps a (date, time) pair so neither is in the past relative to right now. + * Used when loading a saved schedule for editing and when picker values change. + */ +export const clampScheduleDateTime = (targetDate, targetTime) => { + const today = formatLocalDate(new Date()); + const date = !targetDate || targetDate < today ? today : targetDate; + let time = targetTime || '00:00'; + if (date === today) { + const nowTime = formatLocalTime(new Date()); + if (time < nowTime) time = nowTime; + } + return { date, time }; +}; + +// ─── Schedule ID ────────────────────────────────────────────────────────────── + +const getSecureBase36 = length => { + const chars = []; + const max = 36 * 7; + while (chars.length < length) { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + for (const byte of bytes) { + if (byte >= max) continue; + chars.push((byte % 36).toString(36)); + if (chars.length === length) break; + } + } + return chars.join(''); +}; + +export const createScheduleId = () => `schedule-${Date.now().toString(36)}-${getSecureBase36(6)}`; + +// ─── Flair extraction ───────────────────────────────────────────────────────── + +const SUBREDDIT_FLAIR_MAP = { + reactjs: ['Help', 'Discussion', 'Showcase'], + javascript: ['Question', 'Discussion', 'News'], + programming: ['Discussion', 'News', 'Tutorial'], + webdev: ['Showcase', 'Tutorial', 'Question'], +}; + +export const extractFlairSuggestions = (title, body, subreddit = '') => { + const text = `${title} ${body}`.toLowerCase(); + const normalizedSubreddit = subreddit.toLowerCase(); + + if (SUBREDDIT_FLAIR_MAP[normalizedSubreddit]) { + return SUBREDDIT_FLAIR_MAP[normalizedSubreddit]; + } + + const matchedFlairs = []; + for (const rule of FLAIR_RULES) { + if (rule.patterns.some(p => p.test(text)) && !matchedFlairs.includes(rule.flair)) { + matchedFlairs.push(rule.flair); + } + } + + if (matchedFlairs.length > 0) return matchedFlairs; + + // Fallback: keyword extraction + const words = text.match(/[a-z0-9']+/g) || []; + return words + .map(word => word.replaceAll("'", '')) + .filter(word => word.length >= 4 && !STOP_WORDS.has(word)) + .filter((word, index, arr) => arr.indexOf(word) === index) + .slice(0, 3); +}; + +// ─── Style utilities ────────────────────────────────────────────────────────── + +export const topCardActions = () => ({ + display: 'flex', + flexWrap: 'wrap', + gap: '12px', + marginTop: '16px', +}); + +export 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: '#ff4500', color: '#fff' }; + if (variant === 'outline') + return { + ...base, + backgroundColor: 'transparent', + color: darkMode ? '#ff8060' : '#ff4500', + border: `1px solid ${darkMode ? '#6b3020' : '#ff4500'}`, + }; + return { + ...base, + backgroundColor: darkMode ? '#1c2b44' : '#fff0eb', + color: darkMode ? '#ffb8a0' : '#a33000', + }; +}; + +export const fieldActionRow = { + display: 'flex', + flexWrap: 'wrap', + gap: '10px', + marginTop: '12px', +}; diff --git a/src/components/Announcements/platforms/reddit/index.jsx b/src/components/Announcements/platforms/reddit/index.jsx new file mode 100644 index 0000000000..3e5337195d --- /dev/null +++ b/src/components/Announcements/platforms/reddit/index.jsx @@ -0,0 +1,754 @@ +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { useSelector } from 'react-redux'; +import { toast } from 'react-toastify'; + +import styles from './Reddit.module.css'; +import { + TITLE_MIN, + TITLE_MAX, + BODY_MAX, + sanitizeSubreddit, + buildPreview, + formatLocalDate, + formatLocalTime, + formatDisplayDateTime, + clampScheduleDateTime, + createScheduleId, + extractFlairSuggestions, + topCardActions, + buttonStyle, + fieldActionRow, +} from './Reddithelpers'; + +// ─── ScheduleField sub-component ───────────────────────────────────────────── +// Renders a labelled date/time input with inline validation error. + +function ScheduleField({ id, type, label, value, min, onChange, attemptedSave, errorText }) { + const isInvalid = attemptedSave && !value; + return ( +
+ + + {isInvalid &&

{errorText}

} +
+ ); +} + +ScheduleField.propTypes = { + id: PropTypes.string.isRequired, + type: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + min: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + attemptedSave: PropTypes.bool.isRequired, + errorText: PropTypes.string.isRequired, +}; + +// ─── RedditAutoPoster ───────────────────────────────────────────────────────── + +function RedditAutoPoster({ platform }) { + const darkMode = useSelector(state => state.theme.darkMode); + + const [title, setTitle] = useState(''); + const [url, setUrl] = useState(''); + const [subreddit, setSubreddit] = useState(''); + const [flair, setFlair] = useState(''); + const [body, setBody] = useState(''); + const [activeSubTab, setActiveSubTab] = useState('make'); + const [scheduledDraft, setScheduledDraft] = useState(''); + const [scheduledDate, setScheduledDate] = useState(() => formatLocalDate(new Date())); + const [scheduledTime, setScheduledTime] = useState(() => formatLocalTime(new Date())); + const [savedSchedules, setSavedSchedules] = useState([]); + const [editingScheduleId, setEditingScheduleId] = useState(null); + const [scheduleAttemptedSave, setScheduleAttemptedSave] = useState(false); + const [flairSuggestions, setFlairSuggestions] = useState([]); + + const subTabs = useMemo( + () => [ + { id: 'make', label: '📝 Create Post' }, + { id: 'schedule', label: '⏰ Scheduled Post' }, + ], + [], + ); + + // ── Derived validation state ────────────────────────────────────────────── + + const trimmedTitle = title.trim(); + const trimmedUrl = url.trim(); + const trimmedSubreddit = subreddit.trim(); + const trimmedBody = body.trim(); + const trimmedFlair = flair.trim(); + + const titleInRange = trimmedTitle.length >= TITLE_MIN && trimmedTitle.length <= TITLE_MAX; + const urlValid = trimmedUrl.length === 0 || /^https?:\/\//i.test(trimmedUrl); + const subredditValid = trimmedSubreddit.length >= 3 && trimmedSubreddit.length <= 21; + const bodyValid = trimmedBody.length <= BODY_MAX; + + const readyToCopy = titleInRange && subredditValid; + + const highlightTitle = trimmedTitle.length > 0 && !titleInRange; + const highlightUrl = trimmedUrl.length > 0 && !urlValid; + const highlightSubreddit = trimmedSubreddit.length > 0 && !subredditValid; + const highlightBody = trimmedBody.length > 0 && !bodyValid; + + const hasAnyInput = Boolean( + trimmedTitle || trimmedUrl || trimmedSubreddit || trimmedBody || trimmedFlair, + ); + + const preview = useMemo(() => { + if (!hasAnyInput) return ''; + return buildPreview({ title, url, subreddit: trimmedSubreddit, flair, body }); + }, [title, url, trimmedSubreddit, flair, body, hasAnyInput]); + + const scheduleHasDraft = scheduledDraft.trim().length > 0; + const editingSchedule = useMemo( + () => savedSchedules.find(s => s.id === editingScheduleId) || null, + [editingScheduleId, savedSchedules], + ); + + // ── Handlers ────────────────────────────────────────────────────────────── + + const copyText = async (text, label) => { + const value = text?.trim(); + if (!value) { + toast.warn(`Nothing to copy for ${label}.`); + return; + } + try { + await navigator.clipboard.writeText(value); + toast.success(`${label} copied to clipboard`); + } catch { + toast.error(`Could not copy ${label.toLowerCase()}.`); + } + }; + + const handleReset = () => { + setTitle(''); + setUrl(''); + setSubreddit(''); + setFlair(''); + setBody(''); + setFlairSuggestions([]); + }; + + const openRedditSubmit = () => { + const sub = trimmedSubreddit ? `r/${trimmedSubreddit}/` : ''; + window.open(`https://www.reddit.com/${sub}submit`, '_blank', 'noopener,noreferrer'); + }; + + const getMissingScheduleFields = () => { + const missing = []; + if (!trimmedTitle) missing.push('Title'); + if (!trimmedSubreddit) missing.push('Subreddit'); + return missing.length > 0 ? missing.join(', ') : null; + }; + + const handleScheduleClick = () => { + if (!hasAnyInput) { + toast.error('Nothing to schedule yet. Add details in Create Post first.'); + return; + } + const missingFields = getMissingScheduleFields(); + if (missingFields) { + toast.error(`Add ${missingFields} before scheduling.`); + return; + } + const now = new Date(); + setScheduledDate(formatLocalDate(now)); + setScheduledTime(formatLocalTime(now)); + setScheduledDraft(preview); + setScheduleAttemptedSave(false); + setActiveSubTab('schedule'); + toast.success('Draft moved to Schedule tab.'); + }; + + const now = new Date(); + const today = formatLocalDate(now); + const currentTime = formatLocalTime(now); + const scheduleTimeMin = scheduledDate === today ? currentTime : '00:00'; + + const applyScheduleDateTime = (nextDate, nextTime) => { + const { date, time } = clampScheduleDateTime(nextDate, nextTime); + setScheduledDate(date); + setScheduledTime(time); + setScheduleAttemptedSave(false); + }; + + const handleScheduleDateChange = event => { + if (!event.target.value) return; + applyScheduleDateTime(event.target.value, scheduledTime); + }; + + const handleScheduleTimeChange = event => { + if (!event.target.value) return; + applyScheduleDateTime(scheduledDate, event.target.value); + }; + + const handleBackToMake = () => { + setScheduleAttemptedSave(false); + setActiveSubTab('make'); + }; + + const handleSaveSchedule = () => { + setScheduleAttemptedSave(true); + if (!scheduleHasDraft) { + toast.warn('Add content to the schedule before saving.'); + return; + } + if (!scheduledDate || !scheduledTime) { + toast.error('Choose a schedule date and time.'); + return; + } + const isEditing = Boolean(editingScheduleId); + const recordId = isEditing ? editingScheduleId : createScheduleId(); + const record = { + id: recordId, + title, + url, + subreddit: trimmedSubreddit, + flair, + body, + scheduledDraft: scheduledDraft.trim(), + scheduledDate, + scheduledTime, + updatedAt: new Date().toISOString(), + }; + setSavedSchedules(prev => [record, ...prev.filter(item => item.id !== record.id)]); + toast.success(isEditing ? 'Scheduled post updated.' : 'Scheduled post saved.'); + setScheduleAttemptedSave(false); + setEditingScheduleId(null); + }; + + const handleEditSchedule = scheduleId => { + const target = savedSchedules.find(s => s.id === scheduleId); + if (!target) return; + const { date, time } = clampScheduleDateTime(target.scheduledDate, target.scheduledTime); + setTitle(target.title || ''); + setUrl(target.url || ''); + setSubreddit(target.subreddit || ''); + setFlair(target.flair || ''); + setBody(target.body || ''); + setScheduledDraft(target.scheduledDraft || ''); + setScheduledDate(date); + setScheduledTime(time); + setScheduleAttemptedSave(false); + setEditingScheduleId(target.id); + setActiveSubTab('schedule'); + toast.info('Loaded scheduled post for editing.'); + }; + + const handleMakeTabClick = id => { + if (id === 'make') { + setEditingScheduleId(null); + setScheduledDraft(''); + setScheduleAttemptedSave(false); + } + setActiveSubTab(id); + }; + + const handleSuggestFlair = () => { + const suggestions = extractFlairSuggestions(title, body, subreddit); + setFlairSuggestions(suggestions); + if (suggestions.length === 0) toast.info('No flair suggestions found.'); + }; + + const handleClearFlair = () => { + setFlair(''); + setFlairSuggestions([]); + }; + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+
+ {subTabs.map(({ id, label }) => ( + + ))} +
+ + {activeSubTab === 'make' ? ( + <> + {/* Top card */} +
+

Reddit Auto Poster

+

Compose your Reddit post, then copy each field or open Reddit directly.

+
+ +
+
+ +
+ {/* Subreddit */} +
+
+ + + r/ + +
+
+ r/ + setSubreddit(sanitizeSubreddit(e.target.value))} + className={classNames( + styles['reddit-field__input'], + styles['reddit-field__input--subreddit'], + { [styles['reddit-field__input--invalid']]: highlightSubreddit }, + )} + placeholder="programming" + maxLength={21} + /> +
+ {!trimmedSubreddit && ( +

+ Enter the subreddit name without the "r/" prefix (3–21 characters). +

+ )} + {highlightSubreddit && ( +

+ Subreddit name must be 3–21 characters (letters, digits, underscores only). +

+ )} +
+ +
+
+ + {/* Title */} +
+
+ + + {title.trim().length}/{TITLE_MAX} + +
+ setTitle(e.target.value)} + className={classNames(styles['reddit-field__input'], { + [styles['reddit-field__input--invalid']]: highlightTitle, + })} + placeholder="e.g. Open-source tool achieves 10x performance improvement" + maxLength={TITLE_MAX} + /> + {!trimmedTitle && ( +

+ Keep titles clear and specific. Reddit allows up to {TITLE_MAX} characters. +

+ )} + {highlightTitle && ( +

+ Title must be at least {TITLE_MIN} characters. +

+ )} +
+ + +
+
+ + {/* URL */} +
+
+ + optional +
+ setUrl(e.target.value)} + className={classNames(styles['reddit-field__input'], { + [styles['reddit-field__input--invalid']]: highlightUrl, + })} + placeholder="https://" + /> + {!trimmedUrl && ( +

+ Paste the full URL you want to share. Must start with http:// or https://. +

+ )} + {highlightUrl && ( +

+ Enter a valid URL starting with http:// or https://. +

+ )} +
+ +
+
+ + {/* Flair */} +
+
+ + optional +
+ setFlair(e.target.value)} + className={styles['reddit-field__input']} + placeholder="e.g. Discussion, News, Question" + /> + {!trimmedFlair && ( +

+ Smart flair suggestions are based on your title and content. +

+ )} +
+ + + +
+ {flairSuggestions.length > 0 && ( +
+ {flairSuggestions.map(item => ( + + ))} +
+ )} +
+ + {/* Body */} +
+
+ + + {body.trim().length}/{BODY_MAX} + +
+