feat: article pipeline UI with status tabs + stats bar, backend status filter endpoint

This commit is contained in:
Patrick Britton 2026-02-08 22:38:03 -06:00
parent ebbe8d92d1
commit 541a409806
3 changed files with 83 additions and 59 deletions

View file

@ -648,81 +648,103 @@ function EditAccountForm({ config, onDone }: { config: Config; onDone: () => voi
);
}
// ─── Articles Panel (news accounts) ──────────────────
// ─── Articles Pipeline Panel ─────────────────────────
type PipelineTab = 'discovered' | 'posted' | 'failed' | 'skipped';
interface ArticleStats { discovered: number; posted: number; failed: number; skipped: number; total: number; }
function ArticlesPanel({ configId }: { configId: string }) {
const [articles, setArticles] = useState<any[]>([]);
const [posted, setPosted] = useState<any[]>([]);
const [tab, setTab] = useState<'pending' | 'posted'>('pending');
const [stats, setStats] = useState<ArticleStats | null>(null);
const [tab, setTab] = useState<PipelineTab>('discovered');
const [loading, setLoading] = useState(false);
const fetchPending = async () => {
const fetchTab = async (t: PipelineTab) => {
setLoading(true);
try {
if (t === 'discovered') {
const data = await api.fetchNewsArticles(configId);
setArticles(data.articles || []);
if (data.stats) setStats(data.stats);
} else {
const data = await api.getPostedArticles(configId, 50, t);
setArticles(data.articles || []);
if (data.stats) setStats(data.stats);
}
} catch { setArticles([]); }
setLoading(false);
};
const fetchPosted = async () => {
setLoading(true);
try {
const data = await api.getPostedArticles(configId);
setPosted(data.articles || []);
} catch { setPosted([]); }
setLoading(false);
};
useEffect(() => { fetchTab(tab); }, [tab]);
useEffect(() => {
if (tab === 'pending') fetchPending();
else fetchPosted();
}, [tab]);
const tabConfig: { key: PipelineTab; label: string; color: string; bgColor: string }[] = [
{ key: 'discovered', label: 'Pending', color: 'text-blue-700', bgColor: 'bg-blue-100' },
{ key: 'posted', label: 'Posted', color: 'text-green-700', bgColor: 'bg-green-100' },
{ key: 'failed', label: 'Failed', color: 'text-red-700', bgColor: 'bg-red-100' },
{ key: 'skipped', label: 'Skipped', color: 'text-gray-600', bgColor: 'bg-gray-100' },
];
const getCount = (key: PipelineTab) => stats ? stats[key] : 0;
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>
{/* Stats bar */}
{stats && (
<div className="flex items-center gap-3 px-3 py-2 bg-warm-50 border-b border-warm-200">
<span className="text-[10px] font-semibold text-gray-500 uppercase tracking-wide">Pipeline</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 font-medium">{stats.discovered} pending</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700 font-medium">{stats.posted} posted</span>
{stats.failed > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-700 font-medium">{stats.failed} failed</span>}
{stats.skipped > 0 && <span className="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 font-medium">{stats.skipped} skipped</span>}
<span className="text-[10px] text-gray-400 ml-auto">{stats.total} total</span>
</div>
<div className="max-h-48 overflow-y-auto">
)}
{/* Tabs */}
<div className="flex bg-warm-100">
{tabConfig.map((t) => (
<button key={t.key} onClick={() => setTab(t.key)}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
tab === t.key ? 'bg-white text-gray-900 border-b-2 border-brand-500' : 'text-gray-500 hover:text-gray-700'
}`}>
{t.label}
{stats && getCount(t.key) > 0 && (
<span className={`ml-1.5 text-[10px] px-1 py-0.5 rounded ${t.bgColor} ${t.color}`}>{getCount(t.key)}</span>
)}
</button>
))}
</div>
{/* Article list */}
<div className="max-h-56 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.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No {tab} articles</p>
) : tab === 'discovered' ? (
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" />
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source}</span>
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
</div>
{a.description && <p className="text-[10px] text-gray-500 mt-0.5">{a.description}</p>}
{a.description && <p className="text-[10px] text-gray-500 mt-0.5 line-clamp-2">{a.description}</p>}
</div>
))
)
) : (
posted.length === 0 ? (
<p className="p-3 text-xs text-gray-500">No posted articles yet</p>
) : (
posted.map((a) => (
articles.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>
<span className="text-[10px] px-1.5 py-0.5 bg-warm-200 rounded text-gray-600 flex-shrink-0">{a.source_name}</span>
<a href={a.link} target="_blank" className="text-xs font-medium text-brand-600 hover:underline flex items-center gap-1 truncate">
{a.title} <ExternalLink className="w-3 h-3 flex-shrink-0" />
</a>
{a.posted_at && <span className="text-[10px] text-gray-400 ml-auto flex-shrink-0">{new Date(a.posted_at).toLocaleString()}</span>}
</div>
{a.error_message && <p className="text-[10px] text-red-500 mt-0.5">{a.error_message}</p>}
</div>
))
)
)}
</div>
</div>

View file

@ -437,8 +437,8 @@ class ApiClient {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/articles`);
}
async getPostedArticles(id: string, limit = 50) {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}`);
async getPostedArticles(id: string, limit = 50, status = 'posted') {
return this.request<any>(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`);
}
async adminImportContent(data: {

View file

@ -3113,9 +3113,10 @@ func (h *AdminHandler) FetchNewsArticles(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"articles": previews, "count": len(previews), "stats": stats})
}
// Get posted articles history for an account
// Get articles by status for an account (defaults to "posted")
func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
id := c.Param("id")
status := c.DefaultQuery("status", "posted")
limit := 50
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
@ -3123,7 +3124,7 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
}
}
articles, err := h.officialAccountsService.GetRecentArticles(c.Request.Context(), id, limit)
articles, err := h.officialAccountsService.GetArticleQueue(c.Request.Context(), id, status, limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@ -3131,7 +3132,8 @@ func (h *AdminHandler) GetPostedArticles(c *gin.Context) {
if articles == nil {
articles = []services.CachedArticle{}
}
c.JSON(http.StatusOK, gin.H{"articles": articles})
stats, _ := h.officialAccountsService.GetArticleStats(c.Request.Context(), id)
c.JSON(http.StatusOK, gin.H{"articles": articles, "stats": stats})
}
// ── Safe Domains Management ─────────────────────────