← back to Patty
app/petitions/[slug]/PostToPlatformsPanel.tsx
647 lines
'use client';
import { useState, useEffect, useCallback } from 'react';
interface PlatformDef {
id: string;
label: string;
color: string;
icon: string;
hasConfig?: boolean;
configLabel?: string;
configPlaceholder?: string;
configDefault?: string;
configType?: 'select' | 'text';
configOptions?: string[];
}
interface PostLog {
id: string;
platform: string;
status: string;
error_msg: string | null;
actor: string | null;
posted_at: string | null;
created_at: string;
post_url: string | null;
platform_config: Record<string, string> | null;
}
interface PostToPlatformsPanelProps {
petitionSlug: string;
petitionTitle: string;
petitionId: string;
}
const PLATFORMS: PlatformDef[] = [
{
id: 'reddit',
label: 'Reddit',
color: '#FF4500',
icon: 'R',
hasConfig: true,
configLabel: 'Subreddit',
configType: 'select',
configOptions: [
'studentloans',
'StudentDebt',
'PSLF',
'personalfinance',
'politics',
'lostgeneration',
'antiwork',
],
configDefault: 'studentloans',
},
{
id: 'discord',
label: 'Discord',
color: '#5865F2',
icon: 'D',
hasConfig: true,
configLabel: 'Channel ID',
configType: 'text',
configPlaceholder: 'e.g. 1234567890',
},
{
id: 'twitter',
label: 'X / Twitter',
color: '#1DA1F2',
icon: 'X',
},
{
id: 'bluesky',
label: 'Bluesky',
color: '#0085FF',
icon: 'B',
},
{
id: 'moveon',
label: 'MoveOn',
color: '#E03131',
icon: 'M',
},
{
id: 'patty',
label: 'Launch on Patty',
color: '#7c3aed',
icon: 'P',
},
];
export default function PostToPlatformsPanel({
petitionSlug,
petitionTitle,
petitionId,
}: PostToPlatformsPanelProps) {
const [enabled, setEnabled] = useState<Record<string, boolean>>({});
const [configs, setConfigs] = useState<Record<string, Record<string, string>>>({});
const [posting, setPosting] = useState(false);
const [postResult, setPostResult] = useState<{
results: Array<{ platform: string; status: string; message: string }>;
summary: { total: number; posted: number; failed: number };
} | null>(null);
const [logs, setLogs] = useState<PostLog[]>([]);
const [logsLoading, setLogsLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
// Initialize defaults
useEffect(() => {
const defaultEnabled: Record<string, boolean> = {};
const defaultConfigs: Record<string, Record<string, string>> = {};
for (const p of PLATFORMS) {
defaultEnabled[p.id] = false;
if (p.hasConfig && p.configDefault) {
defaultConfigs[p.id] = { [p.configLabel?.toLowerCase().replace(/\s/g, '_') || 'value']: p.configDefault };
}
}
setEnabled(defaultEnabled);
setConfigs(defaultConfigs);
}, []);
const fetchLogs = useCallback(async () => {
try {
const res = await fetch(`/api/petitions/${petitionSlug}/post-log`);
if (res.ok) {
const data = await res.json();
setLogs(data.logs || []);
}
} catch {
// silently fail
} finally {
setLogsLoading(false);
}
}, [petitionSlug]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
function togglePlatform(id: string) {
setEnabled((prev) => ({ ...prev, [id]: !prev[id] }));
}
function selectAll() {
const next: Record<string, boolean> = {};
for (const p of PLATFORMS) next[p.id] = true;
setEnabled(next);
}
function selectNone() {
const next: Record<string, boolean> = {};
for (const p of PLATFORMS) next[p.id] = false;
setEnabled(next);
}
function updateConfig(platformId: string, key: string, value: string) {
setConfigs((prev) => ({
...prev,
[platformId]: { ...prev[platformId], [key]: value },
}));
}
async function handlePost() {
const selectedPlatforms = PLATFORMS.filter((p) => enabled[p.id]).map((p) => ({
name: p.id,
enabled: true,
config: configs[p.id] || {},
}));
if (selectedPlatforms.length === 0) return;
setPosting(true);
setPostResult(null);
try {
const res = await fetch(`/api/petitions/${petitionSlug}/post`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ platforms: selectedPlatforms, action: 'post' }),
});
const data = await res.json();
if (res.ok) {
setPostResult({
results: data.results || [],
summary: data.summary || { total: 0, posted: 0, failed: 0 },
});
// Refresh logs
fetchLogs();
} else {
setPostResult({
results: [{ platform: 'system', status: 'failed', message: data.error || 'Request failed' }],
summary: { total: 1, posted: 0, failed: 1 },
});
}
} catch {
setPostResult({
results: [{ platform: 'system', status: 'failed', message: 'Network error' }],
summary: { total: 1, posted: 0, failed: 1 },
});
} finally {
setPosting(false);
}
}
const enabledCount = Object.values(enabled).filter(Boolean).length;
const statusColor = (status: string) => {
switch (status) {
case 'posted': return 'var(--color-success)';
case 'failed': return 'var(--color-error)';
case 'pending': return 'var(--color-warning)';
case 'skipped': return 'var(--color-text-muted)';
default: return 'var(--color-text-muted)';
}
};
const platformColor = (name: string) => {
const p = PLATFORMS.find((pl) => pl.id === name);
return p?.color || 'var(--color-text-muted)';
};
return (
<div style={{
backgroundColor: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-lg)',
padding: '1.5rem',
}}>
{/* Header */}
<button
onClick={() => setExpanded(!expanded)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
>
<h3 style={{
color: 'var(--color-text)',
fontSize: '1rem',
fontWeight: 700,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
margin: 0,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
<polyline points="16 6 12 2 8 6"/>
<line x1="12" y1="2" x2="12" y2="15"/>
</svg>
Post to Platforms
</h3>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-muted)"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transition: 'transform 150ms ease',
transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)',
}}
>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{/* Expandable Content */}
<div style={{
maxHeight: expanded ? '2000px' : '0',
overflow: 'hidden',
transition: 'max-height 300ms cubic-bezier(0.4, 0, 0.2, 1)',
}}>
<div style={{ paddingTop: '1rem' }}>
{/* Platform toggles */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
marginBottom: '0.75rem',
}}>
{PLATFORMS.map((p) => (
<label
key={p.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
padding: '0.375rem 0.625rem',
borderRadius: 'var(--radius-sm)',
backgroundColor: enabled[p.id]
? `${p.color}18`
: 'var(--color-surface-el)',
border: `1px solid ${enabled[p.id] ? `${p.color}55` : 'var(--color-border)'}`,
cursor: 'pointer',
fontSize: '0.8125rem',
fontWeight: 500,
color: enabled[p.id] ? p.color : 'var(--color-text-secondary)',
transition: 'all 150ms ease',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={enabled[p.id] || false}
onChange={() => togglePlatform(p.id)}
style={{
accentColor: p.color,
cursor: 'pointer',
width: '0.875rem',
height: '0.875rem',
}}
/>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: p.color,
flexShrink: 0,
}} />
{p.label}
</label>
))}
</div>
{/* All / None buttons */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.75rem' }}>
<button
onClick={selectAll}
style={{
fontSize: '0.75rem',
padding: '0.125rem 0.5rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid rgba(124, 58, 237, 0.4)',
background: 'transparent',
color: 'var(--color-primary)',
cursor: 'pointer',
}}
>
All
</button>
<button
onClick={selectNone}
style={{
fontSize: '0.75rem',
padding: '0.125rem 0.5rem',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
background: 'transparent',
color: 'var(--color-text-muted)',
cursor: 'pointer',
}}
>
None
</button>
</div>
{/* Platform-specific config fields */}
{PLATFORMS.filter((p) => p.hasConfig && enabled[p.id]).map((p) => {
const configKey = p.configLabel?.toLowerCase().replace(/\s/g, '_') || 'value';
return (
<div
key={p.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
marginBottom: '0.5rem',
padding: '0.5rem 0.625rem',
borderRadius: 'var(--radius-sm)',
backgroundColor: `${p.color}08`,
border: `1px solid ${p.color}22`,
}}
>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: p.color,
flexShrink: 0,
}} />
<span style={{
fontSize: '0.75rem',
color: 'var(--color-text-secondary)',
minWidth: '60px',
fontWeight: 500,
}}>
{p.configLabel}:
</span>
{p.configType === 'select' && p.configOptions ? (
<select
value={configs[p.id]?.[configKey] || p.configDefault || ''}
onChange={(e) => updateConfig(p.id, configKey, e.target.value)}
className="input"
style={{
flex: 1,
padding: '0.25rem 0.5rem',
fontSize: '0.8125rem',
}}
>
{p.configOptions.map((opt) => (
<option key={opt} value={opt}>
r/{opt}
</option>
))}
</select>
) : (
<input
type="text"
className="input"
placeholder={p.configPlaceholder || ''}
value={configs[p.id]?.[configKey] || ''}
onChange={(e) => updateConfig(p.id, configKey, e.target.value)}
style={{
flex: 1,
padding: '0.25rem 0.5rem',
fontSize: '0.8125rem',
}}
/>
)}
</div>
);
})}
{/* Review & Post button */}
<button
onClick={handlePost}
disabled={posting || enabledCount === 0}
className="btn btn-primary"
style={{
width: '100%',
padding: '0.75rem 1rem',
fontSize: '0.9375rem',
fontWeight: 700,
marginTop: '0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
}}
>
{posting ? (
<>
<span className="spinner" style={{ width: '1rem', height: '1rem', borderWidth: '2px' }} />
Posting...
</>
) : (
<>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
<polyline points="16 6 12 2 8 6"/>
<line x1="12" y1="2" x2="12" y2="15"/>
</svg>
Review & Post ({enabledCount})
</>
)}
</button>
{/* Post results */}
{postResult && (
<div style={{
marginTop: '0.75rem',
padding: '0.75rem',
borderRadius: 'var(--radius-md)',
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.5rem',
}}>
<span style={{
fontSize: '0.75rem',
fontWeight: 600,
color: 'var(--color-text-secondary)',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>
Dispatch Results
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
{postResult.summary.posted}/{postResult.summary.total} posted
</span>
</div>
{postResult.results.map((r, i) => (
<div
key={i}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0',
fontSize: '0.8125rem',
borderTop: i > 0 ? '1px solid var(--color-border)' : 'none',
}}
>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: statusColor(r.status),
flexShrink: 0,
}} />
<span style={{
fontWeight: 600,
color: platformColor(r.platform),
minWidth: '70px',
textTransform: 'capitalize',
}}>
{r.platform}
</span>
<span style={{ color: 'var(--color-text-muted)', fontSize: '0.75rem', flex: 1 }}>
{r.message}
</span>
<span className={`badge badge-${r.status === 'posted' ? 'success' : r.status === 'failed' ? 'error' : 'warning'}`}>
{r.status}
</span>
</div>
))}
</div>
)}
{/* Post history log */}
<div style={{ marginTop: '1rem' }}>
<h4 style={{
fontSize: '0.8125rem',
fontWeight: 600,
color: 'var(--color-text-secondary)',
marginBottom: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.375rem',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
Posting History
</h4>
{logsLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '1rem 0' }}>
<span className="spinner" style={{ width: '1.25rem', height: '1.25rem' }} />
</div>
) : logs.length === 0 ? (
<p style={{
color: 'var(--color-text-muted)',
fontSize: '0.8125rem',
textAlign: 'center',
padding: '0.75rem 0',
fontStyle: 'italic',
}}>
No posts yet. Select platforms above and click Review & Post.
</p>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '0.375rem',
maxHeight: '200px',
overflowY: 'auto',
}}>
{logs.map((log) => (
<div
key={log.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.5rem',
borderRadius: 'var(--radius-sm)',
backgroundColor: 'var(--color-surface-el)',
border: '1px solid var(--color-border)',
fontSize: '0.75rem',
}}
>
<span style={{
width: '6px',
height: '6px',
borderRadius: '50%',
backgroundColor: platformColor(log.platform),
flexShrink: 0,
}} />
<span style={{
fontWeight: 600,
color: platformColor(log.platform),
minWidth: '55px',
textTransform: 'capitalize',
}}>
{log.platform}
</span>
<span style={{
color: statusColor(log.status),
fontWeight: 600,
minWidth: '42px',
textTransform: 'capitalize',
}}>
{log.status}
</span>
<span style={{
flex: 1,
color: 'var(--color-text-muted)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{log.error_msg || (log.actor ? `by ${log.actor}` : '')}
</span>
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0 }}>
{log.posted_at
? new Date(log.posted_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: new Date(log.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
}