fedi-feed-router/frontend/src/pages/Articles.tsx

249 lines
9.9 KiB
TypeScript
Raw Normal View History

2025-08-03 01:34:11 +02:00
import React, { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { CheckCircle, XCircle, ExternalLink, Calendar, Tag, FileText } from 'lucide-react';
import { apiClient, type 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;