From 541a4098068217f23471b23fd0a7981d50822363 Mon Sep 17 00:00:00 2001 From: Patrick Britton Date: Sun, 8 Feb 2026 22:38:03 -0600 Subject: [PATCH] feat: article pipeline UI with status tabs + stats bar, backend status filter endpoint --- admin/src/app/official-accounts/page.tsx | 130 ++++++++++-------- admin/src/lib/api.ts | 4 +- go-backend/internal/handlers/admin_handler.go | 8 +- 3 files changed, 83 insertions(+), 59 deletions(-) diff --git a/admin/src/app/official-accounts/page.tsx b/admin/src/app/official-accounts/page.tsx index 1d30a7b..085e8ec 100644 --- a/admin/src/app/official-accounts/page.tsx +++ b/admin/src/app/official-accounts/page.tsx @@ -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([]); - const [posted, setPosted] = useState([]); - const [tab, setTab] = useState<'pending' | 'posted'>('pending'); + const [stats, setStats] = useState(null); + const [tab, setTab] = useState('discovered'); const [loading, setLoading] = useState(false); - const fetchPending = async () => { + const fetchTab = async (t: PipelineTab) => { setLoading(true); try { - const data = await api.fetchNewsArticles(configId); - setArticles(data.articles || []); + 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 (
+ {/* Stats bar */} + {stats && ( +
+ Pipeline + {stats.discovered} pending + {stats.posted} posted + {stats.failed > 0 && {stats.failed} failed} + {stats.skipped > 0 && {stats.skipped} skipped} + {stats.total} total +
+ )} + + {/* Tabs */}
- - + {tabConfig.map((t) => ( + + ))}
-
+ + {/* Article list */} +
{loading ? (

Loading...

- ) : tab === 'pending' ? ( - articles.length === 0 ? ( -

No pending articles

- ) : ( - articles.map((a, i) => ( -
-
- {a.source} - - {a.title} - -
- {a.description &&

{a.description}

} + ) : articles.length === 0 ? ( +

No {tab} articles

+ ) : tab === 'discovered' ? ( + articles.map((a, i) => ( +
+
+ {a.source} + + {a.title} +
- )) - ) + {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()} -
+ articles.map((a) => ( +
+
+ {a.source_name} + + {a.title} + + {a.posted_at && {new Date(a.posted_at).toLocaleString()}}
- )) - ) + {a.error_message &&

{a.error_message}

} +
+ )) )}
diff --git a/admin/src/lib/api.ts b/admin/src/lib/api.ts index f97f9a6..d2a304a 100644 --- a/admin/src/lib/api.ts +++ b/admin/src/lib/api.ts @@ -437,8 +437,8 @@ class ApiClient { return this.request(`/api/v1/admin/official-accounts/${id}/articles`); } - async getPostedArticles(id: string, limit = 50) { - return this.request(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}`); + async getPostedArticles(id: string, limit = 50, status = 'posted') { + return this.request(`/api/v1/admin/official-accounts/${id}/posted?limit=${limit}&status=${status}`); } async adminImportContent(data: { diff --git a/go-backend/internal/handlers/admin_handler.go b/go-backend/internal/handlers/admin_handler.go index e97fa01..70b3f0c 100644 --- a/go-backend/internal/handlers/admin_handler.go +++ b/go-backend/internal/handlers/admin_handler.go @@ -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 ─────────────────────────