russ_react/src/components/ArticleList.tsx

238 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
});