sojorn/admin/src/app/official-accounts/page.tsx

666 lines
31 KiB
TypeScript

'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;
}
interface OfficialProfile {
profile_id: string;
user_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<Config[]>([]);
const [profiles, setProfiles] = useState<OfficialProfile[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [setupProfile, setSetupProfile] = useState<OfficialProfile | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({});
const [actionResult, setActionResult] = useState<Record<string, { ok: boolean; message: string }>>({});
const fetchAll = async () => {
setLoading(true);
try {
const [configData, profileData] = await Promise.all([
api.listOfficialAccounts(),
api.listOfficialProfiles(),
]);
setConfigs(configData.configs || []);
setProfiles(profileData.profiles || []);
} catch {
setConfigs([]);
setProfiles([]);
}
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 (
<AdminShell>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Official Accounts</h1>
<p className="text-gray-500 mt-1">Manage AI-powered official accounts and news automation</p>
</div>
<div className="flex gap-2">
<button onClick={fetchConfigs} className="flex items-center gap-2 px-3 py-2 text-sm border border-warm-300 rounded-lg hover:bg-warm-200 transition-colors">
<RefreshCw className="w-4 h-4" /> Refresh
</button>
<button onClick={() => setShowForm(!showForm)} className="flex items-center gap-2 px-4 py-2 text-sm bg-brand-500 text-white rounded-lg hover:bg-brand-600 transition-colors">
<Plus className="w-4 h-4" /> Add Account
</button>
</div>
</div>
{showForm && <CreateAccountForm onDone={() => { setShowForm(false); fetchAll(); }} initialProfile={setupProfile} />}
{/* Official Profiles Overview */}
{!loading && profiles.length > 0 && (
<div className="mb-6">
<h2 className="text-sm font-semibold text-gray-600 uppercase tracking-wide mb-3">Official Profiles ({profiles.length})</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{profiles.map((p) => (
<div key={p.profile_id} className="bg-white rounded-lg border border-warm-300 p-3 flex items-center gap-3">
<div className="w-9 h-9 bg-brand-100 rounded-full flex items-center justify-center flex-shrink-0 text-brand-600 font-bold text-sm">
{p.avatar_url ? <img src={p.avatar_url} className="w-9 h-9 rounded-full object-cover" /> : p.handle[0]?.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-gray-900 text-sm truncate">@{p.handle}</span>
{p.is_verified && <CheckCircle className="w-3.5 h-3.5 text-brand-500 flex-shrink-0" />}
</div>
<p className="text-xs text-gray-500 truncate">{p.display_name}</p>
</div>
{p.has_config ? (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-green-100 text-green-700 flex-shrink-0">Configured</span>
) : (
<button
onClick={() => { setSetupProfile(p); setShowForm(true); }}
className="text-xs px-2 py-1 bg-brand-50 text-brand-600 rounded hover:bg-brand-100 transition-colors flex-shrink-0"
>
Setup AI
</button>
)}
</div>
))}
</div>
</div>
)}
{loading ? (
<div className="text-center py-12 text-gray-500">Loading...</div>
) : configs.length === 0 ? (
<div className="text-center py-12 text-gray-500">No official accounts configured yet.</div>
) : (
<div className="space-y-4">
{configs.map((cfg) => {
const expanded = expandedId === cfg.id;
const sources = typeof cfg.news_sources === 'string' ? JSON.parse(cfg.news_sources) : (cfg.news_sources || []);
return (
<div key={cfg.id} className="bg-white rounded-xl border border-warm-300 overflow-hidden">
{/* Header */}
<div className="p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-brand-100 rounded-full flex items-center justify-center flex-shrink-0">
{cfg.account_type === 'news' ? <Newspaper className="w-5 h-5 text-brand-600" /> : <Bot className="w-5 h-5 text-brand-600" />}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-gray-900">@{cfg.handle}</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-warm-200 text-gray-600">{cfg.account_type}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${cfg.enabled ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{cfg.enabled ? 'Active' : 'Disabled'}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 mt-1">
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> Every {cfg.post_interval_minutes}m</span>
<span>{cfg.posts_today}/{cfg.max_posts_per_day} today</span>
{cfg.last_posted_at && <span>Last: {new Date(cfg.last_posted_at).toLocaleString()}</span>}
<span className="text-gray-400 font-mono text-[10px]">{cfg.model_id}</span>
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => handlePreview(cfg)} disabled={actionLoading[cfg.id]} title="Preview AI output" className="p-2 rounded-lg hover:bg-warm-200 transition-colors text-gray-500 disabled:opacity-50">
<Eye className="w-4 h-4" />
</button>
<button onClick={() => handleTrigger(cfg)} disabled={actionLoading[cfg.id]} title="Trigger post now" className="p-2 rounded-lg hover:bg-green-50 transition-colors text-green-600 disabled:opacity-50">
<Play className="w-4 h-4" />
</button>
<button onClick={() => handleToggle(cfg)} disabled={actionLoading[cfg.id]} title={cfg.enabled ? 'Disable' : 'Enable'} className="p-2 rounded-lg hover:bg-warm-200 transition-colors disabled:opacity-50">
{cfg.enabled ? <PowerOff className="w-4 h-4 text-red-500" /> : <Power className="w-4 h-4 text-green-500" />}
</button>
<button onClick={() => handleDelete(cfg)} title="Delete" className="p-2 rounded-lg hover:bg-red-50 transition-colors text-red-500">
<Trash2 className="w-4 h-4" />
</button>
<button onClick={() => setExpandedId(expanded ? null : cfg.id)} className="p-2 rounded-lg hover:bg-warm-200 transition-colors text-gray-500">
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
</div>
</div>
{/* Action result */}
{actionResult[cfg.id] && (
<div className={`mx-4 mb-3 p-3 rounded-lg text-sm flex items-start gap-2 ${actionResult[cfg.id].ok ? 'bg-green-50 text-green-800 border border-green-200' : 'bg-red-50 text-red-800 border border-red-200'}`}>
{actionResult[cfg.id].ok ? <CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" /> : <AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />}
<pre className="whitespace-pre-wrap break-all text-xs">{actionResult[cfg.id].message}</pre>
<button onClick={() => setActionResult((p) => { const n = { ...p }; delete n[cfg.id]; return n; })} className="ml-auto text-gray-400 hover:text-gray-600 text-xs"></button>
</div>
)}
{/* Expanded details */}
{expanded && (
<div className="px-4 pb-4 border-t border-warm-200 pt-3 space-y-3">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-600">Profile ID:</span>
<span className="ml-2 font-mono text-xs text-gray-500">{cfg.profile_id}</span>
</div>
<div>
<span className="font-medium text-gray-600">Model:</span>
<span className="ml-2 font-mono text-xs text-gray-500">{cfg.model_id}</span>
</div>
<div>
<span className="font-medium text-gray-600">Temperature:</span>
<span className="ml-2">{cfg.temperature}</span>
</div>
<div>
<span className="font-medium text-gray-600">Max Tokens:</span>
<span className="ml-2">{cfg.max_tokens}</span>
</div>
</div>
{cfg.system_prompt && (
<div>
<span className="text-sm font-medium text-gray-600">System Prompt:</span>
<pre className="mt-1 p-2 bg-warm-100 rounded text-xs text-gray-700 whitespace-pre-wrap max-h-40 overflow-y-auto">{cfg.system_prompt}</pre>
</div>
)}
{cfg.account_type === 'news' && sources.length > 0 && (
<div>
<span className="text-sm font-medium text-gray-600">News Sources:</span>
<div className="mt-1 space-y-1">
{sources.map((src: NewsSource, i: number) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className={`w-2 h-2 rounded-full ${src.enabled ? 'bg-green-500' : 'bg-gray-300'}`} />
<span className="font-medium">{src.name}</span>
<a href={src.rss_url} target="_blank" className="text-brand-500 hover:underline flex items-center gap-1">
RSS <ExternalLink className="w-3 h-3" />
</a>
</div>
))}
</div>
{cfg.last_fetched_at && (
<p className="text-xs text-gray-500 mt-1">Last fetched: {new Date(cfg.last_fetched_at).toLocaleString()}</p>
)}
<ArticlesPanel configId={cfg.id} />
</div>
)}
<EditAccountForm config={cfg} onDone={fetchConfigs} />
</div>
)}
</div>
);
})}
</div>
)}
</AdminShell>
);
}
// ─── 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<NewsSource[]>([]);
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 (
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-warm-300 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Add Official Account</h2>
<div className="grid grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Handle *</label>
<input type="text" required value={handle} onChange={(e) => setHandle(e.target.value)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" placeholder="newsbot" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Account Type</label>
<select value={accountType} onChange={(e) => setAccountType(e.target.value)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm">
<option value="general">General</option>
<option value="news">News</option>
<option value="community">Community</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Model</label>
<input type="text" value={modelId} onChange={(e) => setModelId(e.target.value)}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm font-mono" />
</div>
</div>
<div className="grid grid-cols-4 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Temperature</label>
<input type="number" step="0.1" min="0" max="2" value={temperature} onChange={(e) => setTemperature(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max Tokens</label>
<input type="number" min="50" max="4000" value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Interval (min)</label>
<input type="number" min="5" value={intervalMin} onChange={(e) => setIntervalMin(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Max/Day</label>
<input type="number" min="1" value={maxPerDay} onChange={(e) => setMaxPerDay(Number(e.target.value))}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm" />
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">System Prompt</label>
<textarea value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} rows={6}
className="w-full px-3 py-2 border border-warm-300 rounded-lg text-sm font-mono" />
</div>
{accountType === 'news' && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">News Sources (RSS Feeds)</label>
{newsSources.map((src, i) => (
<div key={i} className="flex items-center gap-2 mb-2">
<input type="checkbox" checked={src.enabled}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], enabled: e.target.checked }; setNewsSources(n); }}
className="w-4 h-4 rounded" />
<input type="text" value={src.name} placeholder="Name"
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], name: e.target.value }; setNewsSources(n); }}
className="w-32 px-2 py-1 border border-warm-300 rounded text-sm" />
<input type="text" value={src.rss_url} placeholder="RSS URL"
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], rss_url: e.target.value }; setNewsSources(n); }}
className="flex-1 px-2 py-1 border border-warm-300 rounded text-sm font-mono" />
<button type="button" onClick={() => setNewsSources(newsSources.filter((_, j) => j !== i))}
className="text-red-500 hover:text-red-700 text-sm"></button>
</div>
))}
<button type="button" onClick={() => setNewsSources([...newsSources, { name: '', rss_url: '', enabled: true }])}
className="text-sm text-brand-500 hover:text-brand-600 flex items-center gap-1 mt-1">
<Plus className="w-3 h-3" /> Add Source
</button>
</div>
)}
{error && (
<div className="mb-4 p-3 rounded-lg text-sm bg-red-50 text-red-800 border border-red-200 flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> {error}
</div>
)}
<div className="flex gap-2">
<button type="submit" disabled={loading}
className="px-4 py-2 bg-brand-500 text-white rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-50">
{loading ? 'Creating...' : 'Create Account Config'}
</button>
<button type="button" onClick={() => { /* parent handles close via onDone */ }}
className="px-4 py-2 border border-warm-300 rounded-lg text-sm hover:bg-warm-200">
Cancel
</button>
</div>
</form>
);
}
// ─── 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<NewsSource[]>(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 (
<div className="border-t border-warm-200 pt-3 mt-3">
<h3 className="text-sm font-semibold text-gray-700 mb-3">Edit Configuration</h3>
<div className="grid grid-cols-4 gap-3 mb-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Model</label>
<input type="text" value={modelId} onChange={(e) => setModelId(e.target.value)}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs font-mono" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Temperature</label>
<input type="number" step="0.1" min="0" max="2" value={temperature} onChange={(e) => setTemperature(Number(e.target.value))}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Interval (min)</label>
<input type="number" min="5" value={intervalMin} onChange={(e) => setIntervalMin(Number(e.target.value))}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs" />
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Max/Day</label>
<input type="number" min="1" value={maxPerDay} onChange={(e) => setMaxPerDay(Number(e.target.value))}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs" />
</div>
</div>
<div className="mb-3">
<label className="block text-xs font-medium text-gray-600 mb-1">System Prompt</label>
<textarea value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} rows={4}
className="w-full px-2 py-1.5 border border-warm-300 rounded text-xs font-mono" />
</div>
{config.account_type === 'news' && (
<div className="mb-3">
<label className="block text-xs font-medium text-gray-600 mb-1">News Sources</label>
{newsSources.map((src, i) => (
<div key={i} className="flex items-center gap-2 mb-1">
<input type="checkbox" checked={src.enabled}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], enabled: e.target.checked }; setNewsSources(n); }}
className="w-3 h-3 rounded" />
<input type="text" value={src.name}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], name: e.target.value }; setNewsSources(n); }}
className="w-28 px-2 py-1 border border-warm-300 rounded text-xs" />
<input type="text" value={src.rss_url}
onChange={(e) => { const n = [...newsSources]; n[i] = { ...n[i], rss_url: e.target.value }; setNewsSources(n); }}
className="flex-1 px-2 py-1 border border-warm-300 rounded text-xs font-mono" />
<button type="button" onClick={() => setNewsSources(newsSources.filter((_, j) => j !== i))}
className="text-red-500 text-xs"></button>
</div>
))}
<button type="button" onClick={() => setNewsSources([...newsSources, { name: '', rss_url: '', enabled: true }])}
className="text-xs text-brand-500 hover:text-brand-600 flex items-center gap-1 mt-1">
<Plus className="w-3 h-3" /> Add
</button>
</div>
)}
<div className="flex items-center gap-2">
<button onClick={handleSave} disabled={loading}
className="px-3 py-1.5 bg-brand-500 text-white rounded text-xs font-medium hover:bg-brand-600 disabled:opacity-50">
{loading ? 'Saving...' : 'Save Changes'}
</button>
{result && (
<span className={`text-xs ${result.ok ? 'text-green-600' : 'text-red-600'}`}>{result.msg}</span>
)}
</div>
</div>
);
}
// ─── Articles Panel (news accounts) ──────────────────
function ArticlesPanel({ configId }: { configId: string }) {
const [articles, setArticles] = useState<any[]>([]);
const [posted, setPosted] = useState<any[]>([]);
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 (
<div className="mt-3 border border-warm-200 rounded-lg overflow-hidden">
<div className="flex bg-warm-100">
<button onClick={() => setTab('pending')}
className={`px-3 py-1.5 text-xs font-medium ${tab === 'pending' ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500'}`}>
Pending Articles
</button>
<button onClick={() => setTab('posted')}
className={`px-3 py-1.5 text-xs font-medium ${tab === 'posted' ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500'}`}>
Posted History
</button>
</div>
<div className="max-h-48 overflow-y-auto">
{loading ? (
<p className="p-3 text-xs text-gray-500">Loading...</p>
) : tab === 'pending' ? (
articles.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No pending articles</p>
) : (
articles.map((a, i) => (
<div key={i} className="p-2 border-b border-warm-100 last:border-0">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600">{a.source}</span>
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1">
{a.title} <ExternalLink className="w-3 h-3" />
</a>
</div>
{a.description && <p className="text-[10px] text-gray-500 mt-0.5">{a.description}</p>}
</div>
))
)
) : (
posted.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No posted articles yet</p>
) : (
posted.map((a) => (
<div key={a.id} className="p-2 border-b border-warm-100 last:border-0">
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600">{a.source_name}</span>
<span className="text-xs font-medium text-gray-700">{a.article_title}</span>
<span className="text-[10px] text-gray-400 ml-auto">{new Date(a.posted_at).toLocaleString()}</span>
</div>
</div>
))
)
)}
</div>
</div>
);
}