'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'; // ─── Model Selector (fetches from OpenRouter) ───────── function ModelSelector({ value, onChange, className }: { value: string; onChange: (v: string) => void; className?: string }) { const [models, setModels] = useState<{ id: string; name: string }[]>([]); const [search, setSearch] = useState(''); const [open, setOpen] = useState(false); const [loading, setLoading] = useState(false); useEffect(() => { setLoading(true); api.listOpenRouterModels().then((data) => { const list = (data.models || []).map((m: any) => ({ id: m.id, name: m.name || m.id })); setModels(list); }).catch(() => {}).finally(() => setLoading(false)); }, []); const filtered = search ? models.filter((m) => m.id.toLowerCase().includes(search.toLowerCase()) || m.name.toLowerCase().includes(search.toLowerCase())) : models; const displayName = models.find((m) => m.id === value)?.name || value; return (
{open && (
setSearch(e.target.value)} autoFocus className="px-3 py-2 border-b border-warm-200 text-sm outline-none" />
{filtered.length === 0 ? (

{loading ? 'Loading...' : 'No models found'}

) : ( filtered.slice(0, 100).map((m) => ( )) )}
)}
); } 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; } interface OfficialProfile { profile_id: string; handle: string; display_name: string; avatar_url: string; bio: string; is_verified: boolean; has_config: boolean; config_id?: string; } export default function OfficialAccountsPage() { const [configs, setConfigs] = useState([]); const [profiles, setProfiles] = useState([]); const [loading, setLoading] = useState(true); const [showForm, setShowForm] = useState(false); const [setupProfile, setSetupProfile] = useState(null); const [expandedId, setExpandedId] = useState(null); const [actionLoading, setActionLoading] = useState>({}); const [actionResult, setActionResult] = useState>({}); const fetchAll = async () => { setLoading(true); const [configData, profileData] = await Promise.allSettled([ api.listOfficialAccounts(), api.listOfficialProfiles(), ]); setConfigs(configData.status === 'fulfilled' ? (configData.value.configs || []) : []); setProfiles(profileData.status === 'fulfilled' ? (profileData.value.profiles || []) : []); setLoading(false); }; const fetchConfigs = fetchAll; useEffect(() => { fetchAll(); }, []); 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); fetchAll(); }} initialProfile={setupProfile} />} {/* Official Profiles Overview */} {!loading && profiles.length > 0 && (

Official Profiles ({profiles.length})

{profiles.map((p) => (
{p.avatar_url ? : p.handle[0]?.toUpperCase()}
@{p.handle} {p.is_verified && }

{p.display_name}

{p.has_config ? ( Configured ) : ( )}
))}
)} {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, initialProfile }: { onDone: () => void; initialProfile?: OfficialProfile | null }) { const [handle, setHandle] = useState(initialProfile?.handle || ''); 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" />
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" />