russ_react/src/components/ArticleList.tsx

265 lines
12 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, ImagePlus, ToggleLeft, ToggleRight, Plus } from 'lucide-react';
import MinutesWord from './Words/MinutesWord';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';
import {Pagination} from "./Pagination.tsx";
import {useSearchParams} from "react-router-dom";
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;
}
const ARTICLES_PER_PAGE = 6;
export const ArticleList = React.memo(function ArticleList({
articles,
setArticles,
onEdit,
onDelete,
onShowGallery,
onNewArticle,
refreshTrigger,
}: ArticleListProps) {
const { user } = useAuthStore();
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
const [searchParams, setSearchParams] = useSearchParams();
const [totalPages, setTotalPages] = useState(1);
const [totalArticles, setTotalArticles] = useState(0);
const currentPage = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
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);
setTotalArticles(response.data.total);
} 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 handlePageChange = (page: number) => {
searchParams.set('page', page.toString());
setSearchParams(searchParams);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
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));
searchParams.set('page', '1');
setSearchParams(searchParams);
}}
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));
searchParams.set('page', '1');
setSearchParams(searchParams);
}}
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)
searchParams.set('page', '1');
setSearchParams(searchParams);
}}
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 className="text-sm text-gray-500">
<p className="font-bold text-gray-600">
Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)}
-
{Math.min(currentPage * ARTICLES_PER_PAGE, totalArticles)} из {totalArticles}
</p>
</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>
{(() => {
const writerAuthors = article.authors
.filter(a => a.role === 'WRITER')
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
if (!writerAuthors) return null;
return (
<div className="flex items-center text-xs text-gray-500 mt-1">
{writerAuthors.map((authorLink) => (
<img
key={authorLink.author.id}
src={authorLink.author.avatarUrl}
alt={authorLink.author.displayName}
className="h-6 w-6 rounded-full mr-1"
/>
))}
<div>
<p className="text-sm font-medium text-gray-900">
{writerAuthors.map((a, i) => (
<span key={a.author.id}>
{a.author.displayName}
{i < writerAuthors.length - 1 ? ', ' : ''}
</span>
))}
</p>
</div>
</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 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
/>
)}
</>
)}
{error && isAdmin && (
<div className="p-4 text-red-700 bg-red-50 rounded-md">{error}</div>
)}
</div>
);
});