249 lines
No EOL
9.9 KiB
TypeScript
249 lines
No EOL
9.9 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { CheckCircle, XCircle, ExternalLink, Calendar, Tag } from 'lucide-react';
|
|
import { apiClient, Article } from '../lib/api';
|
|
|
|
const Articles: React.FC = () => {
|
|
const [page, setPage] = useState(1);
|
|
const queryClient = useQueryClient();
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['articles', page],
|
|
queryFn: () => apiClient.getArticles(page),
|
|
});
|
|
|
|
const approveMutation = useMutation({
|
|
mutationFn: (articleId: number) => apiClient.approveArticle(articleId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
|
},
|
|
});
|
|
|
|
const rejectMutation = useMutation({
|
|
mutationFn: (articleId: number) => apiClient.rejectArticle(articleId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
|
},
|
|
});
|
|
|
|
const handleApprove = (articleId: number) => {
|
|
approveMutation.mutate(articleId);
|
|
};
|
|
|
|
const handleReject = (articleId: number) => {
|
|
rejectMutation.mutate(articleId);
|
|
};
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case 'approved':
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
<CheckCircle className="h-3 w-3 mr-1" />
|
|
Approved
|
|
</span>
|
|
);
|
|
case 'rejected':
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
<XCircle className="h-3 w-3 mr-1" />
|
|
Rejected
|
|
</span>
|
|
);
|
|
default:
|
|
return (
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
|
<Calendar className="h-3 w-3 mr-1" />
|
|
Pending
|
|
</span>
|
|
);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-6">
|
|
<div className="animate-pulse">
|
|
<div className="h-8 bg-gray-200 rounded w-1/4 mb-6"></div>
|
|
<div className="space-y-4">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div key={i} className="bg-white p-6 rounded-lg shadow">
|
|
<div className="h-4 bg-gray-200 rounded w-3/4 mb-2"></div>
|
|
<div className="h-3 bg-gray-200 rounded w-1/2 mb-4"></div>
|
|
<div className="flex space-x-2">
|
|
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
|
<div className="h-8 bg-gray-200 rounded w-20"></div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="p-6">
|
|
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
|
<p className="text-red-600">Failed to load articles</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const articles = data?.articles || [];
|
|
const pagination = data?.pagination;
|
|
const settings = data?.settings;
|
|
|
|
return (
|
|
<div className="p-6">
|
|
<div className="mb-8">
|
|
<h1 className="text-2xl font-bold text-gray-900">Articles</h1>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
Manage and review articles from your feeds
|
|
</p>
|
|
{settings?.publishing_approvals_enabled && (
|
|
<div className="mt-2 inline-flex items-center px-3 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
|
<Tag className="h-3 w-3 mr-1" />
|
|
Approval system enabled
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-6">
|
|
{articles.map((article: Article) => (
|
|
<div key={article.id} className="bg-white rounded-lg shadow p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1 min-w-0">
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
|
{article.title || 'Untitled Article'}
|
|
</h3>
|
|
<p className="text-sm text-gray-600 mb-3 line-clamp-2">
|
|
{article.description || 'No description available'}
|
|
</p>
|
|
<div className="flex items-center space-x-4 text-xs text-gray-500">
|
|
<span>Feed: {article.feed?.name || 'Unknown'}</span>
|
|
<span>•</span>
|
|
<span>{new Date(article.created_at).toLocaleDateString()}</span>
|
|
{article.is_valid !== null && (
|
|
<>
|
|
<span>•</span>
|
|
<span className={article.is_valid ? 'text-green-600' : 'text-red-600'}>
|
|
{article.is_valid ? 'Valid' : 'Invalid'}
|
|
</span>
|
|
</>
|
|
)}
|
|
{article.is_duplicate && (
|
|
<>
|
|
<span>•</span>
|
|
<span className="text-orange-600">Duplicate</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-3 ml-4">
|
|
{getStatusBadge(article.approval_status)}
|
|
<a
|
|
href={article.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="p-2 text-gray-400 hover:text-gray-600 rounded-md"
|
|
title="View original article"
|
|
>
|
|
<ExternalLink className="h-4 w-4" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{article.approval_status === 'pending' && settings?.publishing_approvals_enabled && (
|
|
<div className="mt-4 flex space-x-3">
|
|
<button
|
|
onClick={() => handleApprove(article.id)}
|
|
disabled={approveMutation.isPending}
|
|
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50"
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-1" />
|
|
Approve
|
|
</button>
|
|
<button
|
|
onClick={() => handleReject(article.id)}
|
|
disabled={rejectMutation.isPending}
|
|
className="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50"
|
|
>
|
|
<XCircle className="h-4 w-4 mr-1" />
|
|
Reject
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{articles.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<FileText className="mx-auto h-12 w-12 text-gray-400" />
|
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No articles</h3>
|
|
<p className="mt-1 text-sm text-gray-500">
|
|
No articles have been fetched yet.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{pagination && pagination.last_page > 1 && (
|
|
<div className="flex items-center justify-between bg-white px-4 py-3 border-t border-gray-200 sm:px-6 rounded-lg shadow">
|
|
<div className="flex-1 flex justify-between sm:hidden">
|
|
<button
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page <= 1}
|
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<button
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page >= pagination.last_page}
|
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="text-sm text-gray-700">
|
|
Showing{' '}
|
|
<span className="font-medium">{pagination.from}</span> to{' '}
|
|
<span className="font-medium">{pagination.to}</span> of{' '}
|
|
<span className="font-medium">{pagination.total}</span> results
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
|
<button
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page <= 1}
|
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Previous
|
|
</button>
|
|
<span className="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
|
|
{page} of {pagination.last_page}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page >= pagination.last_page}
|
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
|
>
|
|
Next
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Articles; |