'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) => (
))}
{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 (
);
}
// ─── Edit Account Form (inline) ───────────────────────
function EditAccountForm({ config, onDone }: { config: Config; onDone: () => void }) {
const sources = typeof config.news_sources === 'string' ? JSON.parse(config.news_sources) : (config.news_sources || []);
const [modelId, setModelId] = useState(config.model_id);
const [systemPrompt, setSystemPrompt] = useState(config.system_prompt);
const [temperature, setTemperature] = useState(config.temperature);
const [maxTokens, setMaxTokens] = useState(config.max_tokens);
const [intervalMin, setIntervalMin] = useState(config.post_interval_minutes);
const [maxPerDay, setMaxPerDay] = useState(config.max_posts_per_day);
const [newsSources, setNewsSources] = useState(sources);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<{ ok: boolean; msg: string } | null>(null);
const handleSave = async () => {
setLoading(true);
setResult(null);
try {
await api.upsertOfficialAccount({
profile_id: config.profile_id,
account_type: config.account_type,
enabled: config.enabled,
model_id: modelId,
system_prompt: systemPrompt,
temperature,
max_tokens: maxTokens,
post_interval_minutes: intervalMin,
max_posts_per_day: maxPerDay,
news_sources: newsSources,
});
setResult({ ok: true, msg: 'Saved' });
onDone();
} catch (e: any) {
setResult({ ok: false, msg: e.message });
}
setLoading(false);
};
return (
Edit Configuration
{config.account_type === 'news' && (
)}
{result && (
{result.msg}
)}
);
}
// ─── Articles Panel (news accounts) ──────────────────
function ArticlesPanel({ configId }: { configId: string }) {
const [articles, setArticles] = useState([]);
const [posted, setPosted] = useState([]);
const [tab, setTab] = useState<'pending' | 'posted'>('pending');
const [loading, setLoading] = useState(false);
const fetchPending = async () => {
setLoading(true);
try {
const data = await api.fetchNewsArticles(configId);
setArticles(data.articles || []);
} catch { setArticles([]); }
setLoading(false);
};
const fetchPosted = async () => {
setLoading(true);
try {
const data = await api.getPostedArticles(configId);
setPosted(data.articles || []);
} catch { setPosted([]); }
setLoading(false);
};
useEffect(() => {
if (tab === 'pending') fetchPending();
else fetchPosted();
}, [tab]);
return (
{loading ? (
Loading...
) : tab === 'pending' ? (
articles.length === 0 ? (
No pending articles
) : (
articles.map((a, i) => (
{a.description &&
{a.description}
}
))
)
) : (
posted.length === 0 ? (
No posted articles yet
) : (
posted.map((a) => (
{a.source_name}
{a.article_title}
{new Date(a.posted_at).toLocaleString()}
))
)
)}
);
}