238 lines
11 KiB
TypeScript
238 lines
11 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
||
import axios from 'axios';
|
||
import { Article } from '../types';
|
||
import { CategoryTitles, CityTitles } from '../types';
|
||
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, ToggleLeft, ToggleRight, Plus } from 'lucide-react';
|
||
import MinutesWord from './Words/MinutesWord';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import { usePermissions } from '../hooks/usePermissions';
|
||
|
||
interface ArticleListProps {
|
||
articles: Article[];
|
||
setArticles: React.Dispatch<React.SetStateAction<Article[]>>;
|
||
onEdit: (id: string) => void;
|
||
onDelete: (id: string) => void;
|
||
onShowGallery: (id: string) => void;
|
||
onNewArticle: () => void;
|
||
refreshTrigger: number;
|
||
}
|
||
|
||
export const ArticleList = React.memo(function ArticleList({
|
||
articles,
|
||
setArticles,
|
||
onEdit,
|
||
onDelete,
|
||
onShowGallery,
|
||
onNewArticle,
|
||
refreshTrigger,
|
||
}: ArticleListProps) {
|
||
const { user } = useAuthStore();
|
||
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
|
||
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const [currentPage, setCurrentPage] = useState(1);
|
||
const [filterCategoryId, setFilterCategoryId] = useState(0);
|
||
const [filterCityId, setFilterCityId] = useState(0);
|
||
const [showDraftOnly, setShowDraftOnly] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
const fetchArticles = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await axios.get('/api/articles/', {
|
||
params: { page: currentPage, categoryId: filterCategoryId, cityId: filterCityId, isDraft: showDraftOnly, userId: user?.id, isAdmin },
|
||
});
|
||
setArticles(response.data.articles);
|
||
setTotalPages(response.data.totalPages);
|
||
} catch (error) {
|
||
setError('Не удалось загрузить статьи');
|
||
console.error(error);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
fetchArticles();
|
||
}, [currentPage, filterCategoryId, filterCityId, showDraftOnly, refreshTrigger, user, isAdmin, setArticles]);
|
||
|
||
const handleToggleActive = async (id: string) => {
|
||
const article = articles.find(a => a.id === id);
|
||
if (article) {
|
||
try {
|
||
await axios.put(
|
||
`/api/articles/active/${id}`,
|
||
{ isActive: !article.isActive },
|
||
{ headers: { Authorization: `Bearer ${localStorage.getItem('token')}` } }
|
||
);
|
||
setArticles(prev => prev.map(a => (a.id === id ? { ...a, isActive: !a.isActive } : a)));
|
||
// Обновляем список с сервера после изменения статуса
|
||
const response = await axios.get('/api/articles/', {
|
||
params: { page: currentPage, categoryId: filterCategoryId, cityId: filterCityId, isDraft: showDraftOnly, userId: user?.id, isAdmin },
|
||
});
|
||
setArticles(response.data.articles);
|
||
setTotalPages(response.data.totalPages);
|
||
} catch (error) {
|
||
setError('Не удалось переключить статус статьи');
|
||
console.error(error);
|
||
}
|
||
}
|
||
};
|
||
|
||
const hasNoPermissions = availableCategoryIds.length === 0 || availableCityIds.length === 0;
|
||
|
||
return (
|
||
<div className="bg-white rounded-lg shadow-sm mb-8">
|
||
<div className="px-6 py-4 border-b border-gray-200">
|
||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||
<h2 className="text-lg font-medium text-gray-900">Статьи</h2>
|
||
<div className="flex flex-wrap gap-4">
|
||
<select
|
||
value={filterCategoryId}
|
||
onChange={(e) => {
|
||
setFilterCategoryId(Number(e.target.value));
|
||
setCurrentPage(1);
|
||
}}
|
||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||
>
|
||
<option value="">Все категории</option>
|
||
{availableCategoryIds.map(cat => (
|
||
<option key={cat} value={cat}>
|
||
{CategoryTitles[cat]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<select
|
||
value={filterCityId}
|
||
onChange={(e) => {
|
||
setFilterCityId(Number(e.target.value));
|
||
setCurrentPage(1);
|
||
}}
|
||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||
>
|
||
<option value="">Все столицы</option>
|
||
{availableCityIds.map(c => (
|
||
<option key={c} value={c}>
|
||
{CityTitles[c]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
<label className="inline-flex items-center">
|
||
<input
|
||
type="checkbox"
|
||
checked={showDraftOnly}
|
||
onChange={(e) => setShowDraftOnly(e.target.checked)}
|
||
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||
/>
|
||
<span className="ml-2 text-sm text-gray-700">Только черновики</span>
|
||
</label>
|
||
{!hasNoPermissions && (
|
||
<button
|
||
onClick={onNewArticle}
|
||
className="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||
>
|
||
<Plus size={16} className="mr-2" /> Новая статья
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{loading ? (
|
||
<div className="flex justify-center p-6">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||
</div>
|
||
) : hasNoPermissions ? (
|
||
<div className="p-6 text-center">
|
||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Недостаточно прав</h1>
|
||
<p className="text-gray-600">У вас нет прав на создание и редактирование статей. Свяжитесь с администратором.</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<ul className="divide-y divide-gray-200">
|
||
{articles.map(article => (
|
||
<li key={article.id} className={`px-6 py-4 ${!article.isActive ? 'bg-gray-200' : ''}`}>
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="text-sm font-medium text-gray-900 truncate">{article.title}</h3>
|
||
<p className="text-sm text-gray-500">
|
||
· {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime}{' '}
|
||
<MinutesWord minutes={article.readTime} /> чтения
|
||
</p>
|
||
<div className="flex items-center text-xs text-gray-500 mt-1">
|
||
<img src={article.author.avatarUrl} alt={article.author.displayName} className="h-6 w-6 rounded-full mr-1" />
|
||
<span className="italic font-bold">{article.author.displayName}</span>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-4 ml-4">
|
||
<button
|
||
onClick={() => handleToggleActive(article.id)}
|
||
className={`p-2 rounded-full hover:bg-gray-100 ${article.isActive ? 'text-green-600' : 'text-gray-400'}`}
|
||
title={article.isActive ? 'Set as draft' : 'Publish article'}
|
||
>
|
||
{article.isActive ? <ToggleRight size={18} /> : <ToggleLeft size={18} />}
|
||
</button>
|
||
<button
|
||
onClick={() => onEdit(article.id)}
|
||
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||
>
|
||
<Pencil size={18} />
|
||
</button>
|
||
<button
|
||
onClick={() => onShowGallery(article.id)}
|
||
className="p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||
>
|
||
<ImagePlus size={18} />
|
||
</button>
|
||
<button
|
||
onClick={() => onDelete(article.id)}
|
||
className="p-2 text-gray-400 hover:text-red-600 rounded-full hover:bg-red-50"
|
||
>
|
||
<Trash2 size={18} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
{totalPages > 1 && (
|
||
<div className="px-6 py-4 border-t border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<div className="text-sm text-gray-500">Показано {articles.length} статей</div>
|
||
<div className="flex items-center space-x-2">
|
||
<button
|
||
onClick={() => setCurrentPage(prev => prev - 1)}
|
||
disabled={currentPage === 1}
|
||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<ChevronLeft size={20} />
|
||
</button>
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
|
||
<button
|
||
key={page}
|
||
onClick={() => setCurrentPage(page)}
|
||
className={`px-4 py-2 text-sm font-medium rounded-md ${
|
||
currentPage === page ? 'bg-blue-600 text-white' : 'text-gray-700 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
{page}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => setCurrentPage(prev => prev + 1)}
|
||
disabled={currentPage === totalPages}
|
||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
<ChevronRight size={20} />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
{error && isAdmin && (
|
||
<div className="p-4 text-red-700 bg-red-50 rounded-md">{error}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}); |