'use client'; import React, { useState, useEffect, useCallback } from 'react'; import Link from 'next/link'; import { useAuth } from '@/contexts/AuthContext'; import { useRouter } from 'next/navigation'; import AppImage from '@/components/ui/AppImage'; import dynamic from 'next/dynamic'; const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), { ssr: false }); interface Article { id: string; title: string; author_name: string | null; category: string | null; featured_image: string | null; featured_image_alt: string | null; date: string | null; status: string; source: 'imported' | 'native'; wp_slug?: string; slug?: string; scheduled_at?: string | null; submitted_for_review_at?: string | null; } type StatusFilter = 'all' | 'published' | 'draft' | 'pending' | 'archived'; type SourceFilter = 'all' | 'imported' | 'native'; type BulkAction = 'publish' | 'pending' | 'draft' | 'archive' | 'delete'; const STATUS_COLORS: Record = { published: 'text-green-400 bg-green-400/10', draft: 'text-yellow-400 bg-yellow-400/10', pending: 'text-orange-400 bg-orange-400/10', archived: 'text-[#555] bg-[#1a1a1a]', }; const STATUS_LABELS: Record = { published: 'Published', draft: 'Draft', pending: 'Pending Review', archived: 'Archived', }; export default function AdminArticlesPage() { const { user, loading } = useAuth(); const router = useRouter(); const [articles, setArticles] = useState([]); const [loadingData, setLoadingData] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); const [sourceFilter, setSourceFilter] = useState('all'); const [selectedIds, setSelectedIds] = useState>(new Set()); const [bulkAction, setBulkAction] = useState(''); const [processingIds, setProcessingIds] = useState>(new Set()); const [bulkProcessing, setBulkProcessing] = useState(false); const [editingArticle, setEditingArticle] = useState
(null); const [editTitle, setEditTitle] = useState(''); const [editAuthor, setEditAuthor] = useState(''); const [editCategory, setEditCategory] = useState(''); const [editStatus, setEditStatus] = useState(''); const [editScheduledAt, setEditScheduledAt] = useState(''); const [editSaving, setEditSaving] = useState(false); const [notification, setNotification] = useState<{ type: 'success' | 'error'; message: string } | null>(null); const [activeTab, setActiveTab] = useState('all'); // New Article editor state const [showNewArticle, setShowNewArticle] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newExcerpt, setNewExcerpt] = useState(''); const [newContent, setNewContent] = useState(''); const [newAuthor, setNewAuthor] = useState(''); const [newCategory, setNewCategory] = useState(''); const [newFeaturedImage, setNewFeaturedImage] = useState(''); const [newFeaturedImageAlt, setNewFeaturedImageAlt] = useState(''); const [newStatus, setNewStatus] = useState<'draft' | 'pending' | 'published'>('draft'); const [newSaving, setNewSaving] = useState(false); useEffect(() => { if (!loading && !user) router.replace('/login?next=' + encodeURIComponent(window.location.pathname)); }, [user, loading, router]); const showNotification = (type: 'success' | 'error', message: string) => { setNotification({ type, message }); setTimeout(() => setNotification(null), 3500); }; const fetchArticles = useCallback(async () => { setLoadingData(true); try { const params = new URLSearchParams(); if (sourceFilter !== 'all') params.set('source', sourceFilter); if (activeTab !== 'all') params.set('status', activeTab); if (searchQuery) params.set('search', searchQuery); const res = await fetch(`/api/admin/articles?${params.toString()}`); if (res.ok) { const data = await res.json(); const combined: Article[] = [ ...(data.imported || []), ...(data.native || []), ].sort((a, b) => new Date(b.date || 0).getTime() - new Date(a.date || 0).getTime()); setArticles(combined); } } catch { // silent } finally { setLoadingData(false); } }, [sourceFilter, activeTab, searchQuery]); useEffect(() => { if (user) fetchArticles(); }, [user, fetchArticles]); useEffect(() => { const t = setTimeout(() => { if (user) fetchArticles(); }, 400); return () => clearTimeout(t); }, [searchQuery]); // eslint-disable-line const toggleSelect = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev); next.has(id) ? next.delete(id) : next.add(id); return next; }); }; const toggleSelectAll = () => { if (selectedIds.size === articles.length) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(articles.map((a) => a.id))); } }; const handleStatusChange = async (article: Article, newStatus: string) => { setProcessingIds((prev) => new Set(prev).add(article.id)); try { const res = await fetch('/api/admin/articles', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: article.id, source: article.source, updates: { status: newStatus } }), }); if (res.ok) { setArticles((prev) => prev.map((a) => a.id === article.id ? { ...a, status: newStatus } : a)); showNotification('success', `Article moved to ${STATUS_LABELS[newStatus] || newStatus}`); } else { showNotification('error', 'Failed to update status'); } } catch { showNotification('error', 'Failed to update status'); } finally { setProcessingIds((prev) => { const s = new Set(prev); s.delete(article.id); return s; }); } }; const handleDelete = async (article: Article) => { if (!confirm(`Delete "${article.title}"? This cannot be undone.`)) return; setProcessingIds((prev) => new Set(prev).add(article.id)); try { const res = await fetch('/api/admin/articles', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: article.id, source: article.source }), }); if (res.ok) { setArticles((prev) => prev.filter((a) => a.id !== article.id)); setSelectedIds((prev) => { const s = new Set(prev); s.delete(article.id); return s; }); showNotification('success', 'Article deleted'); } else { showNotification('error', 'Failed to delete article'); } } catch { showNotification('error', 'Failed to delete article'); } finally { setProcessingIds((prev) => { const s = new Set(prev); s.delete(article.id); return s; }); } }; const handleBulkAction = async () => { if (!bulkAction || selectedIds.size === 0) return; if (bulkAction === 'delete' && !confirm(`Delete ${selectedIds.size} article(s)? This cannot be undone.`)) return; setBulkProcessing(true); try { if (bulkAction === 'delete') { const items = articles .filter((a) => selectedIds.has(a.id)) .map((a) => ({ id: a.id, source: a.source })); const res = await fetch('/api/admin/articles', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items }), }); if (res.ok) { setArticles((prev) => prev.filter((a) => !selectedIds.has(a.id))); showNotification('success', `${selectedIds.size} article(s) deleted`); setSelectedIds(new Set()); } else { showNotification('error', 'Bulk delete failed'); } } else { const statusMap: Record = { publish: 'published', pending: 'pending', draft: 'draft', archive: 'archived', }; const newStatus = statusMap[bulkAction] || bulkAction; const targets = articles.filter((a) => selectedIds.has(a.id)); await Promise.all( targets.map((a) => fetch('/api/admin/articles', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: a.id, source: a.source, updates: { status: newStatus } }), }) ) ); setArticles((prev) => prev.map((a) => selectedIds.has(a.id) ? { ...a, status: newStatus } : a) ); showNotification('success', `${selectedIds.size} article(s) moved to ${STATUS_LABELS[newStatus] || newStatus}`); setSelectedIds(new Set()); } } catch { showNotification('error', 'Bulk action failed'); } finally { setBulkProcessing(false); setBulkAction(''); } }; const openEdit = (article: Article) => { setEditingArticle(article); setEditTitle(article.title); setEditAuthor(article.author_name || ''); setEditCategory(article.category || ''); setEditStatus(article.status); // Format scheduled_at for datetime-local input if (article.scheduled_at) { const d = new Date(article.scheduled_at); const local = new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16); setEditScheduledAt(local); } else { setEditScheduledAt(''); } }; const handleEditSave = async () => { if (!editingArticle) return; setEditSaving(true); try { const updates: Record = { title: editTitle, author_name: editAuthor, category: editCategory, status: editStatus, }; // Handle scheduling if (editStatus === 'draft' && editScheduledAt) { updates.scheduled_at = new Date(editScheduledAt).toISOString(); } else { updates.scheduled_at = null; } const res = await fetch('/api/admin/articles', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: editingArticle.id, source: editingArticle.source, updates, }), }); if (res.ok) { setArticles((prev) => prev.map((a) => a.id === editingArticle.id ? { ...a, title: editTitle, author_name: editAuthor, category: editCategory, status: editStatus, scheduled_at: updates.scheduled_at, } : a ) ); showNotification('success', 'Article updated'); setEditingArticle(null); } else { showNotification('error', 'Failed to save changes'); } } catch { showNotification('error', 'Failed to save changes'); } finally { setEditSaving(false); } }; const handleNewArticleSave = async () => { if (!newTitle.trim()) return; setNewSaving(true); try { const slug = newTitle .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .trim() + '-' + Date.now(); const res = await fetch('/api/admin/articles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source: 'native', title: newTitle, excerpt: newExcerpt, content: newContent, author_name: newAuthor, category: newCategory, featured_image: newFeaturedImage, featured_image_alt: newFeaturedImageAlt, status: newStatus, slug, }), }); if (res.ok) { showNotification('success', 'Article created successfully'); setShowNewArticle(false); setNewTitle(''); setNewExcerpt(''); setNewContent(''); setNewAuthor(''); setNewCategory(''); setNewFeaturedImage(''); setNewFeaturedImageAlt(''); setNewStatus('draft'); fetchArticles(); } else { const err = await res.json().catch(() => ({})); showNotification('error', err?.error || 'Failed to create article'); } } catch { showNotification('error', 'Failed to create article'); } finally { setNewSaving(false); } }; const formatDate = (d: string | null) => { if (!d) return '—'; return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); }; const formatDateTime = (d: string | null) => { if (!d) return '—'; return new Date(d).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); }; // Count by status from ALL articles (fetch all for counts) const counts = { all: articles.length, published: articles.filter((a) => a.status === 'published').length, draft: articles.filter((a) => a.status === 'draft').length, pending: articles.filter((a) => a.status === 'pending').length, archived: articles.filter((a) => a.status === 'archived').length, }; const pendingArticles = articles.filter((a) => a.status === 'pending'); const tabs: { key: StatusFilter; label: string; color: string }[] = [ { key: 'all', label: 'All', color: 'text-white' }, { key: 'published', label: 'Published', color: 'text-green-400' }, { key: 'pending', label: 'Pending Review', color: 'text-orange-400' }, { key: 'draft', label: 'Drafts', color: 'text-yellow-400' }, { key: 'archived', label: 'Archived', color: 'text-[#555]' }, ]; if (loading || (!user && !loading)) { return (
); } return (
{/* Notification */} {notification && (
{notification.type === 'success' ? ( ) : ( )} {notification.message}
)} {/* Page Header */}
Home / Admin / Articles

Article Management

Manage drafts, review pending submissions, publish, schedule, and archive articles

WP Import
{/* Approval Queue Banner — shown when pending articles exist */} {pendingArticles.length > 0 && activeTab !== 'pending' && (

{pendingArticles.length} article{pendingArticles.length !== 1 ? 's' : ''} awaiting approval

Review and approve or reject pending submissions

)} {/* Stats Row */}
{[ { label: 'Total', value: counts.all, color: 'text-white' }, { label: 'Published', value: counts.published, color: 'text-green-400' }, { label: 'Pending', value: counts.pending, color: 'text-orange-400' }, { label: 'Drafts', value: counts.draft, color: 'text-yellow-400' }, { label: 'Archived', value: counts.archived, color: 'text-[#555]' }, ].map((s) => (

{s.value}

{s.label}

))}
{/* Status Tabs */}
{tabs.map((tab) => ( ))}
{/* Filters Row */}
setSearchQuery(e.target.value)} className="w-full bg-[#111] border border-[#252525] text-[#cccccc] text-sm rounded pl-9 pr-4 py-2 focus:outline-none focus:border-[#ffb800] placeholder-[#444]" />
{/* Bulk Actions Bar */} {selectedIds.size > 0 && (
{selectedIds.size} selected
)} {/* Pending Review Queue — special view */} {activeTab === 'pending' && !loadingData && pendingArticles.length > 0 && (
Approval Queue — Review and approve or reject each submission
{pendingArticles.map((article) => (
{article.featured_image ? (
) : (
)}

{article.title}

{article.author_name || 'Unknown author'} {article.category && ( {article.category} )} {article.source === 'imported' ? 'WP' : 'Native'}
{article.submitted_for_review_at && (

Submitted {formatDateTime(article.submitted_for_review_at)}

)}
))}
)} {/* Articles Table */}
{/* Table Header */}
0 && selectedIds.size === articles.length} onChange={toggleSelectAll} className="w-4 h-4 rounded border-[#333] bg-[#1a1a1a] accent-[#ffb800] cursor-pointer" /> Article Category Source Date / Schedule Status Actions
{loadingData ? (
) : articles.length === 0 ? (

No articles found

) : (
{articles.map((article) => (
{/* Checkbox */} toggleSelect(article.id)} className="w-4 h-4 rounded border-[#333] bg-[#1a1a1a] accent-[#ffb800] cursor-pointer flex-shrink-0" /> {/* Thumbnail + Title */}
{article.featured_image ? (
) : (
)}

{article.title}

{article.author_name || '—'}

{/* Category */}
{article.category ? ( {article.category} ) : ( )}
{/* Source */}
{article.source === 'imported' ? 'WP' : 'Native'}
{/* Date / Schedule */}
{article.scheduled_at ? (

Scheduled

{formatDateTime(article.scheduled_at)}

) : ( {formatDate(article.date)} )}
{/* Status */}
{article.status === 'pending' ? 'Pending' : article.status}
{/* Actions */}
{/* Edit */} {/* Submit for Review (draft → pending) */} {article.status === 'draft' && ( )} {/* Approve (pending → published) */} {article.status === 'pending' && ( )} {/* Reject (pending → draft) */} {article.status === 'pending' && ( )} {/* Publish (non-published, non-pending) */} {article.status !== 'published' && article.status !== 'pending' && ( )} {/* Archive */} {article.status !== 'archived' && ( )} {/* Delete */}
))}
)}
{!loadingData && articles.length > 0 && (

{articles.length} article{articles.length !== 1 ? 's' : ''} shown

)} {/* New Article Modal */} {showNewArticle && (
{/* Modal Header */}

New Article

Native
{/* Title */}
setNewTitle(e.target.value)} placeholder="Enter article title..." className="w-full bg-[#1a1a1a] border border-[#333] text-white text-lg font-bold rounded px-4 py-3 focus:outline-none focus:border-[#ffb800] placeholder-[#444]" />
{/* Excerpt */}