Files
avbeat-com/src/app/admin/articles/page.tsx

1107 lines
54 KiB
TypeScript

'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<string, string> = {
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<string, string> = {
published: 'Published',
draft: 'Draft',
pending: 'Pending Review',
archived: 'Archived',
};
export default function AdminArticlesPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [articles, setArticles] = useState<Article[]>([]);
const [loadingData, setLoadingData] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkAction, setBulkAction] = useState<BulkAction | ''>('');
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
const [bulkProcessing, setBulkProcessing] = useState(false);
const [editingArticle, setEditingArticle] = useState<Article | null>(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<StatusFilter>('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<string, string> = {
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<string, any> = {
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 (
<div className="min-h-screen bg-[#0a0a0a] flex items-center justify-center">
<div className="w-6 h-6 border-2 border-[#ffb800] border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-[#0a0a0a] text-[#cccccc]">
{/* Notification */}
{notification && (
<div className={`fixed top-4 right-4 z-50 flex items-center gap-2 px-4 py-3 rounded-lg border text-sm font-bold shadow-lg transition-all ${
notification.type === 'success' ? 'bg-[#0a1a0a] border-[#1e5f1e] text-green-400' : 'bg-[#1a0a0a] border-[#5f1e1e] text-red-400'
}`}>
{notification.type === 'success' ? (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 6L9 17l-5-5" /></svg>
) : (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
)}
{notification.message}
</div>
)}
{/* Page Header */}
<div className="bg-[#111111] border-b border-[#252525]">
<div className="max-w-7xl mx-auto px-4 py-5">
<div className="flex items-center gap-3 mb-1">
<Link href="/home-page" className="text-[#555] hover:text-[#ffb800] transition-colors text-sm">Home</Link>
<span className="text-[#333]">/</span>
<span className="text-[#888] text-sm">Admin</span>
<span className="text-[#333]">/</span>
<span className="text-[#cccccc] text-sm">Articles</span>
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mt-3">
<div>
<h1 className="text-2xl font-bold text-white font-heading">Article Management</h1>
<p className="text-[#666] text-sm mt-1">Manage drafts, review pending submissions, publish, schedule, and archive articles</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowNewArticle(true)}
className="flex items-center gap-1.5 text-xs font-bold text-white bg-[#ffb800] hover:bg-[#d99700] rounded px-3 py-2 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
New Article
</button>
<Link
href="/admin/import"
className="flex items-center gap-1.5 text-xs text-[#666] hover:text-[#ffb800] border border-[#252525] hover:border-[#ffb800] rounded px-3 py-2 transition-colors">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
WP Import
</Link>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 py-6 space-y-5">
{/* Approval Queue Banner — shown when pending articles exist */}
{pendingArticles.length > 0 && activeTab !== 'pending' && (
<div className="flex items-center gap-3 bg-orange-400/5 border border-orange-400/20 rounded-lg px-4 py-3">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-orange-400/10 flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-orange-400">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<div className="flex-1">
<p className="text-orange-400 text-sm font-bold">
{pendingArticles.length} article{pendingArticles.length !== 1 ? 's' : ''} awaiting approval
</p>
<p className="text-[#666] text-xs mt-0.5">Review and approve or reject pending submissions</p>
</div>
<button
onClick={() => setActiveTab('pending')}
className="flex-shrink-0 text-xs font-bold text-orange-400 border border-orange-400/30 hover:border-orange-400/60 px-3 py-1.5 rounded transition-colors">
Review Queue
</button>
</div>
)}
{/* Stats Row */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
{[
{ 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) => (
<div key={s.label} className="bg-[#111] border border-[#252525] rounded-lg p-4 text-center">
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
<p className="text-[#555] text-xs mt-0.5 uppercase tracking-wider">{s.label}</p>
</div>
))}
</div>
{/* Status Tabs */}
<div className="flex items-center gap-1 border-b border-[#252525] overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => { setActiveTab(tab.key); setSelectedIds(new Set()); }}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-bold whitespace-nowrap border-b-2 transition-colors ${
activeTab === tab.key
? `${tab.color} border-current`
: 'text-[#555] border-transparent hover:text-[#888]'
}`}>
{tab.label}
{tab.key !== 'all' && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
activeTab === tab.key ? 'bg-current/10' : 'bg-[#1a1a1a]'
}`}>
{counts[tab.key]}
</span>
)}
</button>
))}
</div>
{/* Filters Row */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1 max-w-sm">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="absolute left-3 top-1/2 -translate-y-1/2 text-[#555]">
<circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
placeholder="Search articles..."
value={searchQuery}
onChange={(e) => 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]"
/>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value as SourceFilter)}
className="bg-[#111] border border-[#252525] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800]">
<option value="all">All Sources</option>
<option value="imported">Imported (WP)</option>
<option value="native">Native</option>
</select>
</div>
{/* Bulk Actions Bar */}
{selectedIds.size > 0 && (
<div className="flex items-center gap-3 bg-[#0d1a2e] border border-[#4a3500] rounded-lg px-4 py-3">
<span className="text-[#ffb800] text-sm font-bold">{selectedIds.size} selected</span>
<div className="flex-1" />
<select
value={bulkAction}
onChange={(e) => setBulkAction(e.target.value as BulkAction | '')}
className="bg-[#111] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-1.5 focus:outline-none focus:border-[#ffb800]">
<option value="">Bulk Action</option>
<option value="publish">Publish</option>
<option value="pending">Submit for Review</option>
<option value="draft">Move to Draft</option>
<option value="archive">Archive</option>
<option value="delete">Delete</option>
</select>
<button
onClick={handleBulkAction}
disabled={!bulkAction || bulkProcessing}
className="flex items-center gap-1.5 bg-[#ffb800] hover:bg-[#d99700] disabled:bg-[#4a3500] disabled:cursor-not-allowed text-white text-sm font-bold px-4 py-1.5 rounded transition-colors">
{bulkProcessing ? (
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : null}
Apply
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="text-[#555] hover:text-[#999] text-sm transition-colors">
Clear
</button>
</div>
)}
{/* Pending Review Queue — special view */}
{activeTab === 'pending' && !loadingData && pendingArticles.length > 0 && (
<div className="bg-[#111] border border-orange-400/20 rounded-lg overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-orange-400/20 bg-orange-400/5">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-orange-400">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span className="text-orange-400 text-sm font-bold">Approval Queue</span>
<span className="text-[#555] text-xs ml-1"> Review and approve or reject each submission</span>
</div>
<div className="divide-y divide-[#1a1a1a]">
{pendingArticles.map((article) => (
<div key={article.id} className={`flex items-start gap-4 px-4 py-4 hover:bg-[#141414] transition-colors ${processingIds.has(article.id) ? 'opacity-50 pointer-events-none' : ''}`}>
{article.featured_image ? (
<div className="flex-shrink-0 w-16 h-12 rounded overflow-hidden bg-[#1a1a1a]">
<AppImage src={article.featured_image} alt={article.featured_image_alt || article.title} width={64} height={48} className="w-full h-full object-cover" />
</div>
) : (
<div className="flex-shrink-0 w-16 h-12 rounded bg-[#1a1a1a] flex items-center justify-center">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[#333]">
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
</svg>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-white line-clamp-1">{article.title}</p>
<div className="flex items-center gap-3 mt-1">
<span className="text-[11px] text-[#555]">{article.author_name || 'Unknown author'}</span>
{article.category && (
<span className="text-[10px] font-bold uppercase tracking-wider text-[#ffb800] bg-[#0d1a2e] px-1.5 py-0.5 rounded">{article.category}</span>
)}
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${article.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#ffb800] bg-[#ffb800]/10'}`}>
{article.source === 'imported' ? 'WP' : 'Native'}
</span>
</div>
{article.submitted_for_review_at && (
<p className="text-[11px] text-[#444] mt-1">Submitted {formatDateTime(article.submitted_for_review_at)}</p>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleStatusChange(article, 'published')}
className="flex items-center gap-1.5 text-xs font-bold text-green-400 border border-green-400/30 hover:border-green-400/60 hover:bg-green-400/5 px-3 py-1.5 rounded transition-colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M20 6L9 17l-5-5" /></svg>
Approve
</button>
<button
onClick={() => handleStatusChange(article, 'draft')}
className="flex items-center gap-1.5 text-xs font-bold text-[#666] border border-[#333] hover:border-[#555] hover:bg-[#1a1a1a] px-3 py-1.5 rounded transition-colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
Reject
</button>
<button
onClick={() => openEdit(article)}
className="p-1.5 text-[#555] hover:text-[#ffb800] transition-colors rounded hover:bg-[#1a1a1a]"
title="Edit">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Articles Table */}
<div className="bg-[#111] border border-[#252525] rounded-lg overflow-hidden">
{/* Table Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-[#252525] bg-[#0d0d0d]">
<input
type="checkbox"
checked={articles.length > 0 && selectedIds.size === articles.length}
onChange={toggleSelectAll}
className="w-4 h-4 rounded border-[#333] bg-[#1a1a1a] accent-[#ffb800] cursor-pointer"
/>
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold flex-1">Article</span>
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold w-24 hidden md:block">Category</span>
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold w-20 hidden lg:block">Source</span>
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold w-28 hidden sm:block">Date / Schedule</span>
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold w-28">Status</span>
<span className="text-[10px] text-[#555] uppercase tracking-wider font-bold w-32 text-right">Actions</span>
</div>
{loadingData ? (
<div className="flex items-center justify-center py-16">
<div className="w-6 h-6 border-2 border-[#ffb800] border-t-transparent rounded-full animate-spin" />
</div>
) : articles.length === 0 ? (
<div className="text-center py-16">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[#333] mx-auto mb-3">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7" /><polyline points="14 2 14 8 20 8" />
</svg>
<p className="text-[#555] text-sm">No articles found</p>
</div>
) : (
<div className="divide-y divide-[#1a1a1a]">
{articles.map((article) => (
<div
key={article.id}
className={`flex items-center gap-3 px-4 py-3 hover:bg-[#141414] transition-colors ${
selectedIds.has(article.id) ? 'bg-[#0d1a2e]' : ''
} ${processingIds.has(article.id) ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Checkbox */}
<input
type="checkbox"
checked={selectedIds.has(article.id)}
onChange={() => toggleSelect(article.id)}
className="w-4 h-4 rounded border-[#333] bg-[#1a1a1a] accent-[#ffb800] cursor-pointer flex-shrink-0"
/>
{/* Thumbnail + Title */}
<div className="flex items-center gap-3 flex-1 min-w-0">
{article.featured_image ? (
<div className="flex-shrink-0 w-12 h-9 rounded overflow-hidden bg-[#1a1a1a]">
<AppImage
src={article.featured_image}
alt={article.featured_image_alt || article.title}
width={48}
height={36}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="flex-shrink-0 w-12 h-9 rounded bg-[#1a1a1a] flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-[#333]">
<rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="8.5" cy="8.5" r="1.5" /><polyline points="21 15 16 10 5 21" />
</svg>
</div>
)}
<div className="min-w-0">
<p className="text-sm font-bold text-[#cccccc] line-clamp-1 leading-snug">{article.title}</p>
<p className="text-[11px] text-[#555] mt-0.5">{article.author_name || '—'}</p>
</div>
</div>
{/* Category */}
<div className="w-24 hidden md:block">
{article.category ? (
<span className="text-[10px] font-bold uppercase tracking-wider text-[#ffb800] bg-[#0d1a2e] px-1.5 py-0.5 rounded">
{article.category}
</span>
) : (
<span className="text-[#444] text-xs"></span>
)}
</div>
{/* Source */}
<div className="w-20 hidden lg:block">
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${
article.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#ffb800] bg-[#ffb800]/10'
}`}>
{article.source === 'imported' ? 'WP' : 'Native'}
</span>
</div>
{/* Date / Schedule */}
<div className="w-28 hidden sm:block">
{article.scheduled_at ? (
<div>
<p className="text-[10px] text-orange-400 font-bold uppercase tracking-wider">Scheduled</p>
<p className="text-[11px] text-[#555]">{formatDateTime(article.scheduled_at)}</p>
</div>
) : (
<span className="text-[11px] text-[#555]">{formatDate(article.date)}</span>
)}
</div>
{/* Status */}
<div className="w-28">
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${STATUS_COLORS[article.status] || 'text-[#555] bg-[#1a1a1a]'}`}>
{article.status === 'pending' ? 'Pending' : article.status}
</span>
</div>
{/* Actions */}
<div className="w-32 flex items-center justify-end gap-1">
{/* Edit */}
<button
onClick={() => openEdit(article)}
title="Edit"
className="p-1.5 text-[#555] hover:text-[#ffb800] transition-colors rounded hover:bg-[#1a1a1a]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/* Submit for Review (draft → pending) */}
{article.status === 'draft' && (
<button
onClick={() => handleStatusChange(article, 'pending')}
title="Submit for Review"
className="p-1.5 text-[#555] hover:text-orange-400 transition-colors rounded hover:bg-[#1a1a1a]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</button>
)}
{/* Approve (pending → published) */}
{article.status === 'pending' && (
<button
onClick={() => handleStatusChange(article, 'published')}
title="Approve & Publish"
className="p-1.5 text-[#555] hover:text-green-400 transition-colors rounded hover:bg-[#1a1a1a]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
</button>
)}
{/* Reject (pending → draft) */}
{article.status === 'pending' && (
<button
onClick={() => handleStatusChange(article, 'draft')}
title="Reject (back to draft)"
className="p-1.5 text-[#555] hover:text-red-400 transition-colors rounded hover:bg-[#1a1a1a]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
)}
{/* Publish (non-published, non-pending) */}
{article.status !== 'published' && article.status !== 'pending' && (
<button
onClick={() => handleStatusChange(article, 'published')}
title="Publish"
className="p-1.5 text-[#555] hover:text-green-400 transition-colors rounded hover:bg-[#1a1a1a]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" />
</svg>
</button>
)}
{/* Archive */}
{article.status !== 'archived' && (
<button
onClick={() => handleStatusChange(article, 'archived')}
title="Archive"
className="p-1.5 text-[#555] hover:text-yellow-400 transition-colors rounded hover:bg-[#1a1a1a]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="21 8 21 21 3 21 3 8" /><rect x="1" y="3" width="22" height="5" /><line x1="10" y1="12" x2="14" y2="12" />
</svg>
</button>
)}
{/* Delete */}
<button
onClick={() => handleDelete(article)}
title="Delete"
className="p-1.5 text-[#555] hover:text-red-400 transition-colors rounded hover:bg-[#1a1a1a]">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" /><path d="M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
</div>
))}
</div>
)}
</div>
{!loadingData && articles.length > 0 && (
<p className="text-[#444] text-xs text-right">{articles.length} article{articles.length !== 1 ? 's' : ''} shown</p>
)}
{/* New Article Modal */}
{showNewArticle && (
<div className="fixed inset-0 z-50 flex items-start justify-center p-4 bg-black/80 overflow-y-auto">
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-4xl shadow-2xl my-8">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[#252525] sticky top-0 bg-[#111] z-10 rounded-t-xl">
<div className="flex items-center gap-3">
<h2 className="text-base font-bold text-white font-heading">New Article</h2>
<span className="text-[10px] font-bold uppercase tracking-wider text-[#ffb800] bg-[#ffb800]/10 px-1.5 py-0.5 rounded">Native</span>
</div>
<button
onClick={() => setShowNewArticle(false)}
className="text-[#555] hover:text-[#999] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="px-6 py-6 space-y-5">
{/* Title */}
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Title *</label>
<input
type="text"
value={newTitle}
onChange={(e) => 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]"
/>
</div>
{/* Excerpt */}
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Excerpt</label>
<textarea
value={newExcerpt}
onChange={(e) => setNewExcerpt(e.target.value)}
placeholder="Brief summary shown in article listings..."
rows={2}
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2.5 focus:outline-none focus:border-[#ffb800] placeholder-[#444] resize-none"
/>
</div>
{/* Rich Text Content */}
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Content</label>
<RichTextEditor
value={newContent}
onChange={setNewContent}
placeholder="Start writing your article..."
/>
</div>
{/* Meta row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Author</label>
<input
type="text"
value={newAuthor}
onChange={(e) => setNewAuthor(e.target.value)}
placeholder="Author name"
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800] placeholder-[#444]"
/>
</div>
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Category</label>
<input
type="text"
value={newCategory}
onChange={(e) => setNewCategory(e.target.value)}
placeholder="e.g. Technology"
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800] placeholder-[#444]"
/>
</div>
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Status</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value as 'draft' | 'pending' | 'published')}
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800]">
<option value="draft">Draft</option>
<option value="pending">Submit for Review</option>
<option value="published">Publish Now</option>
</select>
</div>
</div>
{/* Featured Image */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Featured Image URL</label>
<input
type="url"
value={newFeaturedImage}
onChange={(e) => setNewFeaturedImage(e.target.value)}
placeholder="https://..."
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800] placeholder-[#444]"
/>
</div>
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider font-bold">Image Alt Text</label>
<input
type="text"
value={newFeaturedImageAlt}
onChange={(e) => setNewFeaturedImageAlt(e.target.value)}
placeholder="Describe the image..."
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800] placeholder-[#444]"
/>
</div>
</div>
{/* Featured image preview */}
{newFeaturedImage && (
<div className="rounded-lg overflow-hidden border border-[#252525] max-h-48">
<img src={newFeaturedImage} alt={newFeaturedImageAlt || 'Featured image preview'} className="w-full h-48 object-cover" />
</div>
)}
</div>
{/* Modal Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-[#252525] bg-[#0d0d0d] rounded-b-xl">
<p className="text-[11px] text-[#444]">
{newStatus === 'draft' ? 'Saved as draft — not publicly visible' : newStatus === 'pending' ? 'Submitted for editorial review' : 'Will be published immediately'}
</p>
<div className="flex items-center gap-3">
<button
onClick={() => setShowNewArticle(false)}
className="text-[#666] hover:text-[#999] text-sm transition-colors px-4 py-2">
Cancel
</button>
<button
onClick={handleNewArticleSave}
disabled={newSaving || !newTitle.trim()}
className="flex items-center gap-2 bg-[#ffb800] hover:bg-[#d99700] disabled:bg-[#4a3500] disabled:cursor-not-allowed text-white text-sm font-bold px-5 py-2 rounded transition-colors">
{newSaving ? (
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" /><polyline points="17 21 17 13 7 13 7 21" /><polyline points="7 3 7 8 15 8" /></svg>
)}
{newStatus === 'published' ? 'Publish Article' : newStatus === 'pending' ? 'Submit for Review' : 'Save Draft'}
</button>
</div>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingArticle && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70">
<div className="bg-[#111] border border-[#252525] rounded-xl w-full max-w-lg shadow-2xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-[#252525]">
<h2 className="text-base font-bold text-white font-heading">Edit Article</h2>
<button
onClick={() => setEditingArticle(null)}
className="text-[#555] hover:text-[#999] transition-colors">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="px-5 py-5 space-y-4">
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider">Title</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800]"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider">Author</label>
<input
type="text"
value={editAuthor}
onChange={(e) => setEditAuthor(e.target.value)}
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800]"
/>
</div>
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider">Category</label>
<input
type="text"
value={editCategory}
onChange={(e) => setEditCategory(e.target.value)}
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800]"
/>
</div>
</div>
<div>
<label className="block text-xs text-[#666] mb-1.5 uppercase tracking-wider">Status</label>
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value)}
className="w-full bg-[#1a1a1a] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800]">
<option value="draft">Draft</option>
<option value="pending">Pending Review</option>
<option value="published">Published</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Scheduling — only shown for draft status */}
{editStatus === 'draft' && (
<div className="bg-[#0d1a2e] border border-[#4a3500] rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#ffb800]">
<circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" />
</svg>
<label className="text-xs text-[#ffb800] font-bold uppercase tracking-wider">Schedule Publish</label>
</div>
<input
type="datetime-local"
value={editScheduledAt}
onChange={(e) => setEditScheduledAt(e.target.value)}
min={new Date().toISOString().slice(0, 16)}
className="w-full bg-[#111] border border-[#333] text-[#cccccc] text-sm rounded px-3 py-2 focus:outline-none focus:border-[#ffb800] [color-scheme:dark]"
/>
{editScheduledAt && (
<div className="flex items-center justify-between mt-2">
<p className="text-[11px] text-[#555]">Will auto-publish on {new Date(editScheduledAt).toLocaleString()}</p>
<button
onClick={() => setEditScheduledAt('')}
className="text-[10px] text-[#555] hover:text-red-400 transition-colors">
Clear
</button>
</div>
)}
{!editScheduledAt && (
<p className="text-[11px] text-[#444] mt-2">Leave empty to keep as draft without a schedule</p>
)}
</div>
)}
{/* Source badge */}
<div className="flex items-center gap-2 pt-1">
<span className="text-xs text-[#555]">Source:</span>
<span className={`text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded ${
editingArticle.source === 'imported' ? 'text-purple-400 bg-purple-400/10' : 'text-[#ffb800] bg-[#ffb800]/10'
}`}>
{editingArticle.source === 'imported' ? 'WordPress Import' : 'Native'}
</span>
</div>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-4 border-t border-[#252525]">
<button
onClick={() => setEditingArticle(null)}
className="text-[#666] hover:text-[#999] text-sm transition-colors px-4 py-2">
Cancel
</button>
<button
onClick={handleEditSave}
disabled={editSaving || !editTitle.trim()}
className="flex items-center gap-2 bg-[#ffb800] hover:bg-[#d99700] disabled:bg-[#4a3500] disabled:cursor-not-allowed text-white text-sm font-bold px-5 py-2 rounded transition-colors">
{editSaving ? (
<div className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : null}
Save Changes
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}