666 lines
31 KiB
TypeScript
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>
|
|
);
|
|
}
|