diff --git a/admin/src/app/official-accounts/page.tsx b/admin/src/app/official-accounts/page.tsx new file mode 100644 index 0000000..7887bec --- /dev/null +++ b/admin/src/app/official-accounts/page.tsx @@ -0,0 +1,611 @@ +'use client'; + +import AdminShell from '@/components/AdminShell'; +import { api } from '@/lib/api'; +import { useEffect, useState } from 'react'; +import { + Plus, Play, Eye, Trash2, Power, PowerOff, RefreshCw, Newspaper, + ChevronDown, ChevronUp, Bot, Clock, AlertCircle, CheckCircle, ExternalLink, +} from 'lucide-react'; + +const DEFAULT_NEWS_SOURCES = [ + { name: 'NPR', rss_url: 'https://feeds.npr.org/1001/rss.xml', enabled: true }, + { name: 'AP News', rss_url: 'https://rsshub.app/apnews/topics/apf-topnews', enabled: true }, + { name: 'Bring Me The News', rss_url: 'https://bringmethenews.com/feed', enabled: true }, +]; + +const DEFAULT_NEWS_PROMPT = `You are a news curator for Sojorn, a social media platform. Your job is to write brief, engaging social media posts about news articles. + +Rules: +- Keep posts under 280 characters (excluding the link) +- Be factual and neutral — no editorializing or opinion +- Include the article link at the end +- Do NOT use hashtags +- Do NOT use emojis +- Write in a professional but conversational tone +- Start with the most important fact or detail +- Do NOT include "Source:" or "via" attribution — the link speaks for itself`; + +const DEFAULT_GENERAL_PROMPT = `You are a social media content creator for an official Sojorn account. Generate engaging, original posts that spark conversation. + +Rules: +- Keep posts under 500 characters +- Be authentic and conversational +- Ask questions to encourage engagement +- Vary your tone and topics +- Do NOT use hashtags excessively (1-2 max if any) +- Do NOT be overly promotional`; + +interface NewsSource { + name: string; + rss_url: string; + enabled: boolean; +} + +interface Config { + id: string; + profile_id: string; + account_type: string; + enabled: boolean; + model_id: string; + system_prompt: string; + temperature: number; + max_tokens: number; + post_interval_minutes: number; + max_posts_per_day: number; + posts_today: number; + last_posted_at: string | null; + news_sources: NewsSource[] | string; + last_fetched_at: string | null; + handle: string; + display_name: string; + avatar_url: string; +} + +export default function OfficialAccountsPage() { + const [configs, setConfigs] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [expandedId, setExpandedId] = useState(null); + const [actionLoading, setActionLoading] = useState>({}); + const [actionResult, setActionResult] = useState>({}); + + const fetchConfigs = async () => { + setLoading(true); + try { + const data = await api.listOfficialAccounts(); + setConfigs(data.configs || []); + } catch { + setConfigs([]); + } + setLoading(false); + }; + + useEffect(() => { fetchConfigs(); }, []); + + const setAction = (id: string, loading: boolean, result?: { ok: boolean; message: string }) => { + setActionLoading((p) => ({ ...p, [id]: loading })); + if (result) setActionResult((p) => ({ ...p, [id]: result })); + }; + + const handleToggle = async (cfg: Config) => { + setAction(cfg.id, true); + try { + await api.toggleOfficialAccount(cfg.id, !cfg.enabled); + setAction(cfg.id, false, { ok: true, message: cfg.enabled ? 'Disabled' : 'Enabled' }); + fetchConfigs(); + } catch (e: any) { + setAction(cfg.id, false, { ok: false, message: e.message }); + } + }; + + const handleDelete = async (cfg: Config) => { + if (!confirm(`Delete official account config for @${cfg.handle}?`)) return; + try { + await api.deleteOfficialAccount(cfg.id); + fetchConfigs(); + } catch (e: any) { + alert(e.message); + } + }; + + const handleTrigger = async (cfg: Config) => { + setAction(cfg.id, true); + try { + const resp = await api.triggerOfficialPost(cfg.id); + setAction(cfg.id, false, { ok: true, message: resp.body ? `Posted: ${resp.body.slice(0, 100)}...` : resp.message }); + fetchConfigs(); + } catch (e: any) { + setAction(cfg.id, false, { ok: false, message: e.message }); + } + }; + + const handlePreview = async (cfg: Config) => { + setAction(cfg.id, true); + try { + const resp = await api.previewOfficialPost(cfg.id); + setAction(cfg.id, false, { + ok: true, + message: resp.preview + ? `Preview: ${resp.preview}${resp.article_title ? `\n\nArticle: ${resp.article_title}` : ''}` + : resp.message || 'No content to preview', + }); + } catch (e: any) { + setAction(cfg.id, false, { ok: false, message: e.message }); + } + }; + + return ( + +
+
+

Official Accounts

+

Manage AI-powered official accounts and news automation

+
+
+ + +
+
+ + {showForm && { setShowForm(false); fetchConfigs(); }} />} + + {loading ? ( +
Loading...
+ ) : configs.length === 0 ? ( +
No official accounts configured yet.
+ ) : ( +
+ {configs.map((cfg) => { + const expanded = expandedId === cfg.id; + const sources = typeof cfg.news_sources === 'string' ? JSON.parse(cfg.news_sources) : (cfg.news_sources || []); + return ( +
+ {/* Header */} +
+
+ {cfg.account_type === 'news' ? : } +
+
+
+ @{cfg.handle} + {cfg.account_type} + + {cfg.enabled ? 'Active' : 'Disabled'} + +
+
+ Every {cfg.post_interval_minutes}m + {cfg.posts_today}/{cfg.max_posts_per_day} today + {cfg.last_posted_at && Last: {new Date(cfg.last_posted_at).toLocaleString()}} + {cfg.model_id} +
+
+
+ + + + + +
+
+ + {/* Action result */} + {actionResult[cfg.id] && ( +
+ {actionResult[cfg.id].ok ? : } +
{actionResult[cfg.id].message}
+ +
+ )} + + {/* Expanded details */} + {expanded && ( +
+
+
+ Profile ID: + {cfg.profile_id} +
+
+ Model: + {cfg.model_id} +
+
+ Temperature: + {cfg.temperature} +
+
+ Max Tokens: + {cfg.max_tokens} +
+
+ + {cfg.system_prompt && ( +
+ System Prompt: +
{cfg.system_prompt}
+
+ )} + + {cfg.account_type === 'news' && sources.length > 0 && ( +
+ News Sources: +
+ {sources.map((src: NewsSource, i: number) => ( +
+ + {src.name} + + RSS + +
+ ))} +
+ {cfg.last_fetched_at && ( +

Last fetched: {new Date(cfg.last_fetched_at).toLocaleString()}

+ )} + +
+ )} + + +
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +// ─── Create Account Form ────────────────────────────── +function CreateAccountForm({ onDone }: { onDone: () => void }) { + const [handle, setHandle] = useState(''); + const [accountType, setAccountType] = useState('general'); + const [modelId, setModelId] = useState('google/gemini-2.0-flash-001'); + const [systemPrompt, setSystemPrompt] = useState(DEFAULT_GENERAL_PROMPT); + const [temperature, setTemperature] = useState(0.7); + const [maxTokens, setMaxTokens] = useState(500); + const [intervalMin, setIntervalMin] = useState(60); + const [maxPerDay, setMaxPerDay] = useState(24); + const [newsSources, setNewsSources] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (accountType === 'news') { + setNewsSources(DEFAULT_NEWS_SOURCES); + setSystemPrompt(DEFAULT_NEWS_PROMPT); + } else { + setNewsSources([]); + setSystemPrompt(DEFAULT_GENERAL_PROMPT); + } + }, [accountType]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + try { + await api.upsertOfficialAccount({ + handle: handle.trim().toLowerCase(), + account_type: accountType, + enabled: false, + model_id: modelId, + system_prompt: systemPrompt, + temperature, + max_tokens: maxTokens, + post_interval_minutes: intervalMin, + max_posts_per_day: maxPerDay, + news_sources: newsSources, + }); + onDone(); + } catch (e: any) { + setError(e.message); + } + setLoading(false); + }; + + return ( +
+

Add Official Account

+ +
+
+ + setHandle(e.target.value)} + className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" placeholder="newsbot" /> +
+
+ + +
+
+ + setModelId(e.target.value)} + className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm font-mono" /> +
+
+ +
+
+ + setTemperature(Number(e.target.value))} + className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" /> +
+
+ + setMaxTokens(Number(e.target.value))} + className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" /> +
+
+ + setIntervalMin(Number(e.target.value))} + className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" /> +
+
+ + setMaxPerDay(Number(e.target.value))} + className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" /> +
+
+ +
+ +