Переработана схема привязки авторов к статьям, авторы трех типов. Заложен поиск по дате статьи.
This commit is contained in:
parent
7dbfb0323c
commit
7e5f29eb40
45
package-lock.json
generated
45
package-lock.json
generated
@ -22,6 +22,7 @@
|
||||
"axios": "^1.6.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@ -29,6 +30,7 @@
|
||||
"lucide-react": "^0.344.0",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
@ -1279,6 +1281,12 @@
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@date-fns/tz": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
|
||||
"integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@ -4972,6 +4980,22 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns-jalali": {
|
||||
"version": "4.1.0-0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
|
||||
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
|
||||
@ -7497,6 +7521,27 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-day-picker": {
|
||||
"version": "9.7.0",
|
||||
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.7.0.tgz",
|
||||
"integrity": "sha512-urlK4C9XJZVpQ81tmVgd2O7lZ0VQldZeHzNejbwLWZSkzHH498KnArT0EHNfKBOWwKc935iMLGZdxXPRISzUxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@date-fns/tz": "1.2.0",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns-jalali": "4.1.0-0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/gpbl"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
|
@ -31,6 +31,7 @@
|
||||
"axios": "^1.6.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
@ -38,6 +39,7 @@
|
||||
"lucide-react": "^0.344.0",
|
||||
"multer": "1.4.5-lts.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^9.7.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
|
BIN
public/images/pack/anniversary-bn.webp
Normal file
BIN
public/images/pack/anniversary-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 37 KiB |
BIN
public/images/pack/legend-bn.webp
Normal file
BIN
public/images/pack/legend-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 38 KiB |
BIN
public/images/pack/memory-bn.webp
Normal file
BIN
public/images/pack/memory-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 69 KiB |
BIN
public/images/pack/music-bn.webp
Normal file
BIN
public/images/pack/music-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 82 KiB |
BIN
public/images/pack/theatre-bn.webp
Normal file
BIN
public/images/pack/theatre-bn.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 49 KiB |
@ -12,6 +12,10 @@ interface ArticleCardProps {
|
||||
export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
const location = useLocation();
|
||||
|
||||
const writerAuthors = article.authors
|
||||
.filter(a => a.role === 'WRITER')
|
||||
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
|
||||
|
||||
return (
|
||||
<article className={`overflow-hidden rounded-lg shadow-lg transition-transform hover:scale-[1.02] ${
|
||||
featured ? 'sm:col-span-2 sm:row-span-2' : ''
|
||||
@ -34,13 +38,23 @@ export function ArticleCard({ article, featured = false }: ArticleCardProps) {
|
||||
</div>
|
||||
<div className="p-6 flex flex-col flex-grow">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<img
|
||||
src={article.author.avatarUrl}
|
||||
alt={article.author.displayName}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
{writerAuthors.map((authorLink) => (
|
||||
<img
|
||||
key={authorLink.author.id}
|
||||
src={authorLink.author.avatarUrl}
|
||||
alt={authorLink.author.displayName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{article.author.displayName}</p>
|
||||
<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 className="flex items-center text-sm text-gray-500">
|
||||
<Clock size={14} className="mr-1" />
|
||||
{article.readTime} <MinutesWord minutes={article.readTime} /> ·{' '}
|
||||
|
@ -1,14 +1,23 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import { TipTapEditor } from './Editor/TipTapEditor';
|
||||
import { CoverImageUpload } from './ImageUpload/CoverImageUpload';
|
||||
import { ImageUploader } from './ImageUpload/ImageUploader';
|
||||
import { GalleryManager } from './GalleryManager';
|
||||
import { useGallery } from '../hooks/useGallery';
|
||||
import { ArticleData, Author, CategoryTitles, CityTitles, GalleryImage } from '../types';
|
||||
import { ArticleData, Author, AuthorRole, CategoryTitles, CityTitles, GalleryImage } from '../types';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import RolesWord from "./Words/RolesWord";
|
||||
import { Trash2, UserPlus } from "lucide-react";
|
||||
|
||||
|
||||
const ArticleAuthorRoleLabels: Record<AuthorRole, string> = {
|
||||
WRITER: 'Автор статьи',
|
||||
PHOTOGRAPHER: 'Фотограф',
|
||||
EDITOR: 'Редактор',
|
||||
TRANSLATOR: 'Переводчик',
|
||||
};
|
||||
|
||||
interface FormState {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
@ -17,7 +26,10 @@ interface FormState {
|
||||
coverImage: string;
|
||||
readTime: number;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authors: {
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}[];
|
||||
galleryImages: GalleryImage[];
|
||||
}
|
||||
|
||||
@ -52,7 +64,6 @@ export function ArticleForm({
|
||||
const [coverImage, setCoverImage] = useState('/images/cover-placeholder.webp');
|
||||
const [readTime, setReadTime] = useState(5);
|
||||
const [content, setContent] = useState('');
|
||||
const [authorId, setAuthorId] = useState<string>(authors[0]?.id || '');
|
||||
const [displayedImages, setDisplayedImages] = useState<GalleryImage[]>([]);
|
||||
const [formNewImageUrl, setFormNewImageUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@ -61,8 +72,18 @@ export function ArticleForm({
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false); // Добавляем флаг для отслеживания отправки
|
||||
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); // Состояние для модального окна
|
||||
|
||||
const [newRole, setNewRole] = useState('');
|
||||
const [newAuthorId, setNewAuthorId] = useState('');
|
||||
const [showAddAuthorModal, setShowAddAuthorModal] = useState(false);
|
||||
|
||||
const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || '');
|
||||
|
||||
const [selectedAuthors, setSelectedAuthors] = useState<Array<{
|
||||
authorId: string;
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}>>([]);
|
||||
|
||||
// Добавляем обработку ошибок
|
||||
useEffect(() => {
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
@ -101,16 +122,22 @@ export function ArticleForm({
|
||||
setCoverImage(initialFormState.coverImage);
|
||||
setReadTime(initialFormState.readTime);
|
||||
setContent(initialFormState.content);
|
||||
setAuthorId(initialFormState.authorId);
|
||||
setDisplayedImages(initialFormState.galleryImages || []);
|
||||
console.log('Содержимое статьи при загрузке:', initialFormState.content);
|
||||
setSelectedAuthors(
|
||||
(initialFormState.authors || []).map(a => ({
|
||||
authorId: a.author.id, // 👈 добавить вручную
|
||||
role: a.role,
|
||||
author: a.author
|
||||
}))
|
||||
);
|
||||
// console.log('Содержимое статьи при загрузке:', initialFormState.content);
|
||||
}
|
||||
}, [initialFormState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialFormState) return;
|
||||
|
||||
const currentState: FormState = { title, excerpt, categoryId, cityId, coverImage, readTime, content, authorId, galleryImages: displayedImages };
|
||||
const currentState: FormState = { title, excerpt, categoryId, cityId, coverImage, readTime, content, authors: selectedAuthors, galleryImages: displayedImages };
|
||||
const areRequiredFieldsFilled = title.trim() !== '' && excerpt.trim() !== '';
|
||||
const hasFormChanges = Object.keys(initialFormState).some(key => {
|
||||
if (key === 'galleryImages') {
|
||||
@ -131,16 +158,22 @@ export function ArticleForm({
|
||||
if (isInitialLoad) {
|
||||
setIsInitialLoad(false);
|
||||
}
|
||||
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, authorId, displayedImages, initialFormState, isInitialLoad]);
|
||||
}, [title, excerpt, categoryId, cityId, coverImage, readTime, content, selectedAuthors, displayedImages, initialFormState, isInitialLoad]);
|
||||
|
||||
const filteredAuthors = authors.filter(
|
||||
(a) =>
|
||||
a.roles.includes(newRole as AuthorRole) && // 🔹 автор имеет нужную роль
|
||||
!selectedAuthors.some(sel => sel.authorId === a.id && sel.role === newRole) // 🔹 не выбран уже
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => {
|
||||
e.preventDefault();
|
||||
|
||||
console.log('Вызов handleSubmit:', { closeForm });
|
||||
console.log('Содержимое статьи перед сохранением:', content);
|
||||
//console.log('Вызов handleSubmit:', { closeForm });
|
||||
//console.log('Содержимое статьи перед сохранением:', content);
|
||||
|
||||
if (isSubmitting) {
|
||||
console.log('Форма уже отправляется, игнорируем повторную отправку');
|
||||
//console.log('Форма уже отправляется, игнорируем повторную отправку');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -151,14 +184,6 @@ export function ArticleForm({
|
||||
|
||||
if (!hasChanges) return;
|
||||
|
||||
const selectedAuthor = editingId && isAdmin ? authors.find(a => a.id === authorId) || authors[0] : user;
|
||||
|
||||
// Проверяем, что selectedAuthor существует и соответствует типу Author
|
||||
if (!selectedAuthor) {
|
||||
setError('Пожалуйста, выберите автора или войдите в систему.');
|
||||
return;
|
||||
}
|
||||
|
||||
const articleData: ArticleData = {
|
||||
title,
|
||||
excerpt,
|
||||
@ -170,7 +195,10 @@ export function ArticleForm({
|
||||
content: content || '',
|
||||
importId: 0,
|
||||
isActive: false,
|
||||
author: selectedAuthor,
|
||||
authors: selectedAuthors.map(a => ({
|
||||
author: a.author,
|
||||
role: a.role,
|
||||
}))
|
||||
};
|
||||
|
||||
try {
|
||||
@ -201,10 +229,32 @@ export function ArticleForm({
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
setNewAuthorId('');
|
||||
setNewRole('WRITER');
|
||||
setShowAddAuthorModal(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsConfirmModalOpen(false);
|
||||
};
|
||||
|
||||
const handleAddAuthor = () => {
|
||||
const author = authors.find(a => a.id === newAuthorId);
|
||||
if (author) {
|
||||
setSelectedAuthors([...selectedAuthors, { authorId: author.id, role: newRole as AuthorRole, author }]);
|
||||
setShowAddAuthorModal(false);
|
||||
setNewAuthorId('');
|
||||
setNewRole('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAuthor = (authorId: string, role: AuthorRole) => {
|
||||
setSelectedAuthors(prev =>
|
||||
prev.filter(a => !(a.authorId === authorId && a.role === role))
|
||||
);
|
||||
};
|
||||
|
||||
if (editingId && galleryLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@ -287,21 +337,40 @@ export function ArticleForm({
|
||||
/>
|
||||
</div>
|
||||
{editingId && isAdmin && (
|
||||
<div>
|
||||
<label htmlFor="author" className="block text-sm font-medium text-gray-700">
|
||||
<span className="italic font-bold">Автор</span>
|
||||
</label>
|
||||
<select
|
||||
id="author"
|
||||
value={authorId}
|
||||
onChange={(e) => setAuthorId(e.target.value)}
|
||||
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
|
||||
>
|
||||
{authors.map(author =>
|
||||
<option key={author.id} value={author.id}>
|
||||
{author.displayName}
|
||||
</option>)}
|
||||
</select>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||
<span className="italic font-bold">Авторы статьи</span>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenModal}
|
||||
className="p-2 rounded-full hover:bg-gray-100 text-gray-500"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Таблица выбранных авторов */}
|
||||
<ul className="space-y-2">
|
||||
{selectedAuthors.map((a, index) => (
|
||||
<li key={index} className="flex items-center justify-between border p-2 rounded-md">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="font-semibold">{a.author.displayName}</span>
|
||||
<span className="text-sm text-gray-500">(<RolesWord role={a.role}/>)</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveAuthor(a.authorId, a.role)}
|
||||
className="p-2 rounded-full hover:bg-gray-100 text-red-400"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -400,6 +469,64 @@ export function ArticleForm({
|
||||
onCancel={handleCloseModal}
|
||||
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
|
||||
/>
|
||||
|
||||
{/* Модальное окно выбора автора */}
|
||||
{showAddAuthorModal && (
|
||||
<>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-30 flex justify-center items-center">
|
||||
<div className="bg-white p-6 rounded shadow-md max-w-md w-full">
|
||||
<h4 className="text-lg font-bold mb-4">Добавить автора</h4>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(ArticleAuthorRoleLabels).map(([key, label]) => (
|
||||
<label key={key} className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
name="author-role"
|
||||
value={key}
|
||||
checked={newRole === key}
|
||||
onChange={() => {
|
||||
setNewRole(key);
|
||||
setNewAuthorId('');
|
||||
}}
|
||||
className="text-blue-600 focus:ring-blue-500 border-gray-300"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Выбор автора</label>
|
||||
<select
|
||||
value={newAuthorId}
|
||||
onChange={(e) => setNewAuthorId(e.target.value)}
|
||||
className="w-full mb-4 border rounded px-2 py-1"
|
||||
>
|
||||
<option value="">Выберите автора</option>
|
||||
{filteredAuthors.map(a => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button onClick={() => setShowAddAuthorModal(false)} className="text-gray-500">Отмена</button>
|
||||
<button
|
||||
onClick={handleAddAuthor}
|
||||
disabled={!newAuthorId || !newRole}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
@ -2,10 +2,12 @@ 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 { 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[];
|
||||
@ -17,6 +19,8 @@ interface ArticleListProps {
|
||||
refreshTrigger: number;
|
||||
}
|
||||
|
||||
const ARTICLES_PER_PAGE = 6;
|
||||
|
||||
export const ArticleList = React.memo(function ArticleList({
|
||||
articles,
|
||||
setArticles,
|
||||
@ -29,8 +33,11 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
const { user } = useAuthStore();
|
||||
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [currentPage, setCurrentPage] = 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);
|
||||
@ -46,6 +53,7 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
});
|
||||
setArticles(response.data.articles);
|
||||
setTotalPages(response.data.totalPages);
|
||||
setTotalArticles(response.data.total);
|
||||
} catch (error) {
|
||||
setError('Не удалось загрузить статьи');
|
||||
console.error(error);
|
||||
@ -79,6 +87,12 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
@ -91,7 +105,8 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
value={filterCategoryId}
|
||||
onChange={(e) => {
|
||||
setFilterCategoryId(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
searchParams.set('page', '1');
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
@ -106,7 +121,8 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
value={filterCityId}
|
||||
onChange={(e) => {
|
||||
setFilterCityId(Number(e.target.value));
|
||||
setCurrentPage(1);
|
||||
searchParams.set('page', '1');
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
@ -121,7 +137,11 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDraftOnly}
|
||||
onChange={(e) => setShowDraftOnly(e.target.checked)}
|
||||
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>
|
||||
@ -135,6 +155,13 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
</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 ? (
|
||||
@ -158,10 +185,37 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
· {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>
|
||||
{(() => {
|
||||
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
|
||||
@ -195,38 +249,11 @@ export const ArticleList = React.memo(function ArticleList({
|
||||
))}
|
||||
</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>
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
@ -15,8 +15,9 @@ export function AuthorsSection() {
|
||||
useEffect(() => {
|
||||
const fetchAuthors = async () => {
|
||||
try {
|
||||
const response = await axios.get('/api/authors/');
|
||||
console.log(response);
|
||||
const response = await axios.get('/api/authors', {
|
||||
params: { role: 'WRITER' },
|
||||
});
|
||||
setAuthors(response.data);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки авторов:', error);
|
||||
@ -45,7 +46,7 @@ export function AuthorsSection() {
|
||||
<img
|
||||
src={author.avatarUrl}
|
||||
alt={author.displayName}
|
||||
className="w-24 h-24 rounded-full object-cover mr-4"
|
||||
className="w-14 h-14 rounded-full object-cover mr-4 transition-transform duration-300 hover:scale-150"
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">{author.displayName}</h3>
|
||||
@ -107,7 +108,7 @@ export function AuthorsSection() {
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to={`/search?author=${author.id}`}
|
||||
to={`/search?author=${author.id}&role=WRITER&authorName=${author.displayName}`}
|
||||
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
|
||||
>
|
||||
Статьи автора →
|
||||
|
@ -3,13 +3,13 @@ import { Palette } from 'lucide-react';
|
||||
export function DesignStudioLogo() {
|
||||
return (
|
||||
<a
|
||||
href="https://stackblitz.com"
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Palette size={16} />
|
||||
<span className="text-sm">Designed by StackBlitz Studio</span>
|
||||
<span className="text-sm">Дизайн - Студия Красный Холм</span>
|
||||
</a>
|
||||
);
|
||||
}
|
@ -104,14 +104,14 @@ export function Footer() {
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<ExternalLink size={16} className="mr-2" />
|
||||
<a href="https://royalalberthall.com" className="hover:text-white transition-colors">
|
||||
Royal Albert Hall
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Ваша ссылка
|
||||
</a>
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<ExternalLink size={16} className="mr-2" />
|
||||
<a href="https://nationaltheatre.org.uk" className="hover:text-white transition-colors">
|
||||
National Theatre
|
||||
<a href="#" className="hover:text-white transition-colors">
|
||||
Ваша ссылка
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@ -122,22 +122,11 @@ export function Footer() {
|
||||
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm text-gray-400">
|
||||
© {new Date().getFullYear()} Культура двух столиц. Все права защищены.
|
||||
© {new Date().getFullYear()} Новая Культура Двух Столиц. Все права защищены.
|
||||
</p>
|
||||
<span className="text-gray-600">•</span>
|
||||
<DesignStudioLogo />
|
||||
</div>
|
||||
<div className="flex space-x-6 mt-4 md:mt-0">
|
||||
<Link to="/privacy" className="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
<Link to="/terms" className="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link to="/sitemap" className="text-sm text-gray-400 hover:text-white transition-colors">
|
||||
Sitemap
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -44,6 +44,14 @@ export function Header() {
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white shadow-sm">
|
||||
{/* Название блога над навигацией */}
|
||||
<div className="bg-white border-b border-gray-100">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2">
|
||||
<h1 className="text-center text-xl sm:text-2xl font-semibold text-gray-800 tracking-tight bg-gradient-to-b from-white to-gray-100 border-b border-gray-100 pb-2">
|
||||
Новая Культура Двух Столиц
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex items-center">
|
||||
|
@ -1,13 +1,27 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Search, Calendar } from 'lucide-react';
|
||||
import { DayPicker } from 'react-day-picker';
|
||||
import { format } from 'date-fns';
|
||||
import 'react-day-picker/dist/style.css';
|
||||
|
||||
|
||||
export function SearchBar() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [selectedDate, setSelectedDate] = useState<Date>();
|
||||
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (selectedDate) {
|
||||
params.set('date', format(selectedDate, 'yyyy-MM-dd'));
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`);
|
||||
}
|
||||
@ -19,22 +33,76 @@ export function SearchBar() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
setSelectedDate(date);
|
||||
setShowDatePicker(false);
|
||||
};
|
||||
|
||||
// Close datepicker when clicking outside
|
||||
React.useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
||||
setShowDatePicker(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
className="w-40 lg:w-60 pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute left-3 top-2.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
</form>
|
||||
<form onSubmit={handleSearch} className="relative flex items-center space-x-2">
|
||||
{/* Обёртка для поля ввода и иконки поиска */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск###..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleSearchKeyPress}
|
||||
className="w-40 lg:w-60 pl-10 pr-4 py-2 text-sm border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute left-3 top-2.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<Search size={28} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Кнопка календаря */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDatePicker(!showDatePicker)}
|
||||
className={`px-3 py-2 border border-gray-300 rounded-full text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
selectedDate ? 'bg-blue-50' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<Calendar size={18} />
|
||||
</button>
|
||||
|
||||
{showDatePicker && (
|
||||
<div
|
||||
ref={datePickerRef}
|
||||
className="absolute right-0 mt-2 bg-white rounded-lg shadow-lg z-50 border border-gray-200"
|
||||
>
|
||||
<DayPicker
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
className="p-3"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Показ выбранной даты */}
|
||||
{selectedDate && (
|
||||
<div className="text-sm text-gray-600 ml-2">
|
||||
{format(selectedDate, 'dd.MM.yyyy')}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
138
src/components/ImageUpload/ImageUploaderModal.tsx
Normal file
138
src/components/ImageUpload/ImageUploaderModal.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import React, { ChangeEvent, useCallback, useState } from 'react';
|
||||
import Modal from '@/components/ui/Modal';
|
||||
import Button from '@/components/ui/Button';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface Props {
|
||||
/** Управляет открытием/закрытием модального окна */
|
||||
isOpen: boolean;
|
||||
|
||||
/** Колбэк, вызываемый, когда пользователь закрыл окно без сохранения */
|
||||
onClose: () => void;
|
||||
|
||||
/** Колбэк, срабатывающий после успешной загрузки файла */
|
||||
onSuccess: (uploaded: UploadedImage) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Тип данных, которые реально уходят на бэкенд.
|
||||
* Поля, которые не требуются при самой первой загрузке,
|
||||
* объявлены с ?. Компилятор теперь не требует заполнять их
|
||||
* каждый раз.
|
||||
*/
|
||||
export interface ImageBody {
|
||||
file: File;
|
||||
|
||||
/** Если файл уже загружен, бэкенд может вернуть готовый URL */
|
||||
url?: string;
|
||||
|
||||
title_ru?: string;
|
||||
title_en?: string;
|
||||
description_ru?: string;
|
||||
description_en?: string;
|
||||
}
|
||||
|
||||
/** Ответ от сервера после успешной загрузки */
|
||||
export interface UploadedImage {
|
||||
id: string;
|
||||
url: string;
|
||||
title_ru?: string;
|
||||
title_en?: string;
|
||||
description_ru?: string;
|
||||
description_en?: string;
|
||||
}
|
||||
|
||||
const ImageUploaderModal: React.FC<Props> = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const onFileChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
setError(null);
|
||||
const chosen = e.target.files?.[0];
|
||||
if (chosen) {
|
||||
setFile(chosen);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetState = () => {
|
||||
setFile(null);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
resetState();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const upload = async () => {
|
||||
if (!file) {
|
||||
setError('Выберите изображение перед загрузкой.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const body: ImageBody = { file }; // ничего лишнего не добавляем
|
||||
|
||||
// Формируем FormData, чтобы отправить файл «как есть»
|
||||
const form = new FormData();
|
||||
form.append('file', body.file);
|
||||
|
||||
// Если вам нужно сразу отправлять title/description,
|
||||
// их можно добавить в form.append(...) при наличии
|
||||
|
||||
const { data } = await api.post<UploadedImage>('/images', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
onSuccess(data);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Не удалось загрузить файл. Попробуйте ещё раз.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onClose={handleClose} title="Загрузка изображения">
|
||||
<div className="space-y-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={onFileChange}
|
||||
disabled={loading}
|
||||
className="block w-full"
|
||||
/>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={upload}
|
||||
loading={loading}
|
||||
disabled={!file || loading}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageUploaderModal;
|
@ -1,4 +1,5 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface PaginationProps {
|
||||
currentPage: number;
|
||||
@ -7,39 +8,123 @@ interface PaginationProps {
|
||||
}
|
||||
|
||||
export function Pagination({ currentPage, totalPages, onPageChange }: PaginationProps) {
|
||||
const pages = Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const [inputPage, setInputPage] = useState('');
|
||||
|
||||
const generatePages = (): (number | 'ellipsis')[] => {
|
||||
const pages: (number | 'ellipsis')[] = [];
|
||||
|
||||
if (totalPages <= 7) {
|
||||
for (let i = 1; i <= totalPages; i++) pages.push(i);
|
||||
return pages;
|
||||
}
|
||||
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 4) pages.push('ellipsis');
|
||||
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
|
||||
if (currentPage < totalPages - 3) pages.push('ellipsis');
|
||||
|
||||
pages.push(totalPages);
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
const handleInputSubmit = () => {
|
||||
const pageNum = parseInt(inputPage, 10);
|
||||
if (!isNaN(pageNum) && pageNum >= 1 && pageNum <= totalPages) {
|
||||
onPageChange(pageNum);
|
||||
setInputPage('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-center space-x-1 mt-12">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 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>
|
||||
|
||||
{pages.map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 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>
|
||||
</nav>
|
||||
<div className="mt-12 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex-1 hidden sm:block" />
|
||||
|
||||
<nav className="flex justify-center items-center space-x-1 flex-wrap">
|
||||
{/* В начало */}
|
||||
<button
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
title="Первая страница"
|
||||
>
|
||||
<ChevronsLeft size={20} />
|
||||
</button>
|
||||
|
||||
{/* Назад */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
title="Предыдущая страница"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
|
||||
{/* Номера страниц */}
|
||||
{generatePages().map((page, index) =>
|
||||
page === 'ellipsis' ? (
|
||||
<span key={`ellipsis-${index}`} className="px-3 py-2 text-gray-400">...</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Вперёд */}
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
title="Следующая страница"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
|
||||
{/* В конец */}
|
||||
<button
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="p-2 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 disabled:opacity-50"
|
||||
title="Последняя страница"
|
||||
>
|
||||
<ChevronsRight size={20} />
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Поле быстрого перехода */}
|
||||
<div className="flex items-center space-x-2 justify-center sm:justify-end flex-1">
|
||||
<span className="text-sm text-gray-600">Страница:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={inputPage}
|
||||
onChange={(e) => setInputPage(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleInputSubmit()}
|
||||
className="w-20 px-2 py-1 border rounded-md text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={handleInputSubmit}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
24
src/components/Words/RolesWord.tsx
Normal file
24
src/components/Words/RolesWord.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { AuthorRole } from "../../types";
|
||||
|
||||
// Описание типа для пропсов компонента
|
||||
interface RolesWordProps {
|
||||
role: AuthorRole;
|
||||
}
|
||||
|
||||
const RolesWord: React.FC<RolesWordProps> = ({ role }) => {
|
||||
const getRoleLabel = (role: string): string => {
|
||||
switch (role) {
|
||||
case 'WRITER': return 'Автор';
|
||||
case 'PHOTOGRAPHER': return 'Фотограф';
|
||||
case 'EDITOR': return 'Редактор';
|
||||
case 'TRANSLATOR': return 'Переводчик';
|
||||
default: return role;
|
||||
}
|
||||
}
|
||||
|
||||
return <>{getRoleLabel(role)}</>;
|
||||
};
|
||||
|
||||
export default RolesWord;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {Author, AuthorFormData, User} from '../types/auth';
|
||||
import { Author, AuthorFormData, User } from '../types/auth';
|
||||
import { authorService } from '../services/authorService';
|
||||
import { userService } from "../services/userService";
|
||||
|
||||
@ -8,18 +8,20 @@ export function useAuthorManagement() {
|
||||
const [authors, setAuthors] = useState<Author[]>([]);
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [selectedAuthor, setSelectedAuthor] = useState<Author | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuthors();
|
||||
fetchAuthors(selectedRole, currentPage);
|
||||
fetchUsers();
|
||||
}, []);
|
||||
}, [selectedRole, currentPage]);
|
||||
|
||||
const fetchAuthors = async () => {
|
||||
const fetchAuthors = async (role: string, page: number) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedAuthors = await authorService.getAuthors();
|
||||
const fetchedAuthors = await authorService.getAuthors(role, page);
|
||||
setAuthors(fetchedAuthors);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@ -125,6 +127,8 @@ export function useAuthorManagement() {
|
||||
selectedAuthor,
|
||||
loading,
|
||||
error,
|
||||
selectedRole,
|
||||
currentPage,
|
||||
setSelectedAuthor,
|
||||
createAuthor,
|
||||
updateAuthor,
|
||||
@ -136,5 +140,7 @@ export function useAuthorManagement() {
|
||||
deleteAuthor,
|
||||
fetchAuthors,
|
||||
fetchUsers,
|
||||
setSelectedRole,
|
||||
setCurrentPage
|
||||
};
|
||||
}
|
@ -5,10 +5,11 @@ import { ArticleList } from '../components/ArticleList';
|
||||
import { ArticleForm } from '../components/ArticleForm';
|
||||
import { GalleryModal } from '../components/GalleryManager/GalleryModal';
|
||||
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
|
||||
import { Article, Author, GalleryImage, ArticleData } from '../types';
|
||||
import { Article, Author, GalleryImage, ArticleData, AuthorRole } from '../types';
|
||||
import { usePermissions } from '../hooks/usePermissions';
|
||||
import { FileJson, Users, UserSquare2 } from "lucide-react";
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuthStore } from "../stores/authStore";
|
||||
|
||||
interface FormState {
|
||||
title: string;
|
||||
@ -18,13 +19,17 @@ interface FormState {
|
||||
coverImage: string;
|
||||
readTime: number;
|
||||
content: string;
|
||||
authorId: string;
|
||||
authors: {
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}[];
|
||||
galleryImages: GalleryImage[];
|
||||
}
|
||||
|
||||
const DEFAULT_COVER_IMAGE = '/images/cover-placeholder.webp';
|
||||
|
||||
export function AdminPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { availableCategoryIds, availableCityIds, isAdmin } = usePermissions();
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [articleId, setArticleId] = useState('');
|
||||
@ -44,6 +49,7 @@ export function AdminPage() {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('token')}`,
|
||||
},
|
||||
// params: { role: 'WRITER' },
|
||||
});
|
||||
setAuthors(response.data);
|
||||
} catch (err) {
|
||||
@ -57,6 +63,7 @@ export function AdminPage() {
|
||||
const handleEdit = useCallback(
|
||||
(id: string) => {
|
||||
const article = articles.find(a => a.id === id);
|
||||
|
||||
if (article) {
|
||||
setEditingId(id);
|
||||
setInitialFormState({
|
||||
@ -67,7 +74,7 @@ export function AdminPage() {
|
||||
coverImage: article.coverImage,
|
||||
readTime: article.readTime,
|
||||
content: article.content,
|
||||
authorId: article.author.id,
|
||||
authors: article.authors ?? [],
|
||||
galleryImages: [],
|
||||
});
|
||||
setArticleId(article.id);
|
||||
@ -129,7 +136,7 @@ export function AdminPage() {
|
||||
coverImage: articleData.coverImage,
|
||||
readTime: articleData.readTime,
|
||||
content: articleData.content,
|
||||
authorId: articleData.author.id,
|
||||
authors: articleData.authors,
|
||||
galleryImages: articleData.gallery || [],
|
||||
});
|
||||
}
|
||||
@ -153,6 +160,17 @@ export function AdminPage() {
|
||||
};
|
||||
|
||||
const handleNewArticle = () => {
|
||||
const writer = authors?.find(a => a.userId === user?.id);
|
||||
|
||||
// Формируем список авторов
|
||||
const initialAuthors = writer
|
||||
? [{
|
||||
authorId: writer.id,
|
||||
role: AuthorRole.WRITER,
|
||||
author: writer
|
||||
}]
|
||||
: [];
|
||||
|
||||
setInitialFormState({
|
||||
title: '',
|
||||
excerpt: '',
|
||||
@ -161,7 +179,7 @@ export function AdminPage() {
|
||||
coverImage: DEFAULT_COVER_IMAGE,
|
||||
readTime: 5,
|
||||
content: '',
|
||||
authorId: authors[0]?.id || '',
|
||||
authors: initialAuthors,
|
||||
galleryImages: [],
|
||||
});
|
||||
setArticleId('');
|
||||
|
@ -86,6 +86,10 @@ export function ArticlePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const writerAuthors = articleData.authors
|
||||
.filter(a => a.role === 'WRITER')
|
||||
.sort((a, b) => (a.author.order ?? 0) - (b.author.order ?? 0));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<SEO
|
||||
@ -109,13 +113,23 @@ export function ArticlePage() {
|
||||
{/* Article Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<img
|
||||
src={articleData.author.avatarUrl}
|
||||
alt={articleData.author.displayName}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
{writerAuthors.map((authorLink) => (
|
||||
<img
|
||||
key={authorLink.author.id}
|
||||
src={authorLink.author.avatarUrl}
|
||||
alt={authorLink.author.displayName}
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
))}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{articleData.author.displayName}</p>
|
||||
<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 className="flex items-center text-sm text-gray-500">
|
||||
<Clock size={14} className="mr-1" />
|
||||
{articleData.readTime} <MinutesWord minutes={articleData.readTime}/> на чтение ·{' '}
|
||||
@ -164,12 +178,41 @@ export function ArticlePage() {
|
||||
)}
|
||||
|
||||
{/* Article Footer */}
|
||||
<div className="border-t pt-8">
|
||||
<div className="border-t pt-4">
|
||||
{error && (
|
||||
<div className="mb-4 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Авторы */}
|
||||
<div className="text-sm text-gray-600 text-right">
|
||||
{(() => {
|
||||
const photographer = articleData.authors?.find(a => a.role === 'PHOTOGRAPHER');
|
||||
|
||||
return (
|
||||
<>
|
||||
{writerAuthors && (
|
||||
<p className="text-base font-medium text-gray-900">
|
||||
{writerAuthors.map((a, i) => (
|
||||
<span key={a.author.id}>
|
||||
{a.author.displayName}
|
||||
{i < writerAuthors.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{photographer && (
|
||||
<p className="text-base font-medium text-gray-900">
|
||||
<span className="font-semibold">Фото:</span>{' '}
|
||||
{photographer.author.displayName}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<ReactionButtons
|
||||
likes={articleData.likes}
|
||||
dislikes={articleData.dislikes}
|
||||
|
@ -12,13 +12,31 @@ import {
|
||||
Link2Off as LinkOff,
|
||||
ToggleLeft,
|
||||
ToggleRight,
|
||||
Trash2, ChevronDown, ChevronUp
|
||||
Trash2, ChevronDown, ChevronUp,
|
||||
Pen, Camera, BookOpen
|
||||
} from 'lucide-react';
|
||||
import { imageResolutions } from '../config/imageResolutions';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import RolesWord from "../components/Words/RolesWord";
|
||||
import axios from "axios";
|
||||
|
||||
|
||||
const roleIcons: Record<string, JSX.Element> = {
|
||||
WRITER: <span title="Автор статей"><Pen size={16} className="text-gray-500" /></span>,
|
||||
PHOTOGRAPHER: <span title="Фотограф"><Camera size={16} className="text-gray-500" /></span>,
|
||||
EDITOR: <span title="Редактор"><BookOpen size={16} className="text-gray-500" /></span>,
|
||||
};
|
||||
|
||||
const allRoles = ['WRITER', 'PHOTOGRAPHER', 'EDITOR'];
|
||||
|
||||
const roleOptions = [
|
||||
{ label: 'Все роли', value: '' },
|
||||
{ label: 'Автор статей', value: 'WRITER' },
|
||||
{ label: 'Фотограф', value: 'PHOTOGRAPHER' },
|
||||
{ label: 'Редактор', value: 'EDITOR' },
|
||||
];
|
||||
|
||||
|
||||
const initialFormData: AuthorFormData = {
|
||||
id: '',
|
||||
displayName: '',
|
||||
@ -30,7 +48,9 @@ const initialFormData: AuthorFormData = {
|
||||
vkUrl: '',
|
||||
websiteUrl: '',
|
||||
articlesCount: 0,
|
||||
isActive: true
|
||||
totalLikes: 0,
|
||||
isActive: true,
|
||||
roles: []
|
||||
};
|
||||
|
||||
export function AuthorManagementPage() {
|
||||
@ -40,6 +60,8 @@ export function AuthorManagementPage() {
|
||||
selectedAuthor,
|
||||
loading,
|
||||
error,
|
||||
selectedRole,
|
||||
currentPage,
|
||||
setSelectedAuthor,
|
||||
createAuthor,
|
||||
updateAuthor,
|
||||
@ -51,6 +73,8 @@ export function AuthorManagementPage() {
|
||||
deleteAuthor,
|
||||
fetchAuthors,
|
||||
fetchUsers,
|
||||
setSelectedRole,
|
||||
setCurrentPage
|
||||
} = useAuthorManagement();
|
||||
|
||||
const { user } = useAuthStore();
|
||||
@ -100,7 +124,7 @@ export function AuthorManagementPage() {
|
||||
} else if (showEditModal && selectedAuthor) {
|
||||
await updateAuthor(selectedAuthor.id, formData);
|
||||
setShowEditModal(false);
|
||||
await fetchAuthors();
|
||||
await fetchAuthors(selectedRole, currentPage);
|
||||
}
|
||||
setFormData(initialFormData);
|
||||
} catch (error) {
|
||||
@ -115,7 +139,7 @@ export function AuthorManagementPage() {
|
||||
await linkUser(authorId, userId);
|
||||
setShowLinkUserModal(false);
|
||||
setSelectedUserId('');
|
||||
await fetchAuthors();
|
||||
await fetchAuthors(selectedRole, currentPage);
|
||||
await fetchUsers();
|
||||
} catch {
|
||||
setFormError('Failed to link user to author');
|
||||
@ -125,7 +149,7 @@ export function AuthorManagementPage() {
|
||||
const handleUnlinkUser = async (authorId: string) => {
|
||||
try {
|
||||
await unlinkUser(authorId);
|
||||
await fetchAuthors();
|
||||
await fetchAuthors(selectedRole, currentPage);
|
||||
await fetchUsers();
|
||||
} catch {
|
||||
setFormError('Failed to unlink user from author');
|
||||
@ -134,18 +158,18 @@ export function AuthorManagementPage() {
|
||||
|
||||
const handleMoveUp = async (authorId: string) => {
|
||||
await orderMoveUp(authorId);
|
||||
fetchAuthors();
|
||||
fetchAuthors(selectedRole, currentPage);
|
||||
};
|
||||
|
||||
const handleMoveDown = async (authorId: string) => {
|
||||
await orderMoveDown(authorId);
|
||||
fetchAuthors();
|
||||
fetchAuthors(selectedRole, currentPage);
|
||||
};
|
||||
|
||||
const handleToggleActive = async (authorId: string, isActive: boolean) => {
|
||||
try {
|
||||
await toggleActive(authorId, isActive);
|
||||
await fetchAuthors();
|
||||
await fetchAuthors(selectedRole, currentPage);
|
||||
} catch {
|
||||
setFormError('Failed to toggle author status');
|
||||
}
|
||||
@ -193,6 +217,20 @@ export function AuthorManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<select
|
||||
value={selectedRole}
|
||||
onChange={(e) => setSelectedRole(e.target.value)}
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm"
|
||||
>
|
||||
{roleOptions.map((role) => (
|
||||
<option key={role.value} value={role.value}>
|
||||
{role.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{authors.map((author, index) => (
|
||||
@ -209,6 +247,11 @@ export function AuthorManagementPage() {
|
||||
{author.displayName} {author.userId ? `=> пользователь ${author.displayName}` : ``}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500">{author.bio}</p>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{author.roles.map(role => (
|
||||
<div key={role}>{roleIcons[role] ?? <span className="text-xs text-gray-400">{role}</span>}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex mt-1 space-x-4">
|
||||
{author.okUrl && (
|
||||
<a
|
||||
@ -303,7 +346,9 @@ export function AuthorManagementPage() {
|
||||
vkUrl: author.vkUrl || '',
|
||||
websiteUrl: author.websiteUrl || '',
|
||||
articlesCount: author.articlesCount,
|
||||
isActive: author.isActive || true
|
||||
totalLikes: author.totalLikes,
|
||||
isActive: author.isActive || true,
|
||||
roles: author.roles || []
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
@ -321,6 +366,24 @@ export function AuthorManagementPage() {
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<div className="mt-4 flex justify-center items-center space-x-4">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 border rounded disabled:opacity-50"
|
||||
>
|
||||
← Назад
|
||||
</button>
|
||||
<span className="text-sm">Страница {currentPage}</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
className="px-3 py-1 border rounded"
|
||||
>
|
||||
Вперёд →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -402,6 +465,31 @@ export function AuthorManagementPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Роли автора
|
||||
</label>
|
||||
<div className="mt-1 grid grid-cols-2 gap-2">
|
||||
{allRoles.map(role => (
|
||||
<label key={role} className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.roles.includes(role)}
|
||||
onChange={() => {
|
||||
setFormData(prev => {
|
||||
const roles = prev.roles.includes(role)
|
||||
? prev.roles.filter(r => r !== role)
|
||||
: [...prev.roles, role];
|
||||
return { ...prev, roles };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span><RolesWord role={role} /></span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
<span className="italic font-bold">Одноклассники URL</span>
|
||||
|
@ -16,6 +16,7 @@ export function ImportArticlesPage() {
|
||||
|
||||
const [editingArticle, setEditingArticle] = useState<Article | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@ -93,7 +94,7 @@ export function ImportArticlesPage() {
|
||||
}
|
||||
|
||||
setError(null);
|
||||
alert('Статьи успешно сохранены');
|
||||
setShowSuccessModal(true);
|
||||
} catch {
|
||||
setError('Не удалось сохранить статьи. Попробуйте снова.');
|
||||
} finally {
|
||||
@ -204,6 +205,32 @@ export function ImportArticlesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Соавтор:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={article.coAuthorName}
|
||||
onChange={(e) =>
|
||||
handleEditField(article.id, 'coAuthorName', e.target.value)
|
||||
}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
placeholder="Введите имя соавтора"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Фотограф:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={article.photographerName}
|
||||
onChange={(e) =>
|
||||
handleEditField(article.id, 'photographerName', e.target.value)
|
||||
}
|
||||
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
|
||||
placeholder="Введите имя фотографа"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(article.images?.length || 0) > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Изображения с подписями:</h4>
|
||||
@ -290,6 +317,21 @@ export function ImportArticlesPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Модальное окно подтверждения загрузки */}
|
||||
{showSuccessModal && (
|
||||
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-30 z-50">
|
||||
<div className="bg-white p-6 rounded shadow-lg max-w-sm w-full text-center">
|
||||
<h2 className="text-lg font-semibold mb-2">✅ Успешно!</h2>
|
||||
<p className="text-gray-700 mb-4">Статьи успешно сохранены на сервере.</p>
|
||||
<button
|
||||
onClick={() => setShowSuccessModal(false)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
ОК
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
|
@ -5,23 +5,28 @@ import { ArticleCard } from '../components/ArticleCard';
|
||||
import { Pagination } from '../components/Pagination';
|
||||
import { Article } from '../types';
|
||||
import api from '../utils/api';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
|
||||
|
||||
const ARTICLES_PER_PAGE = 9;
|
||||
|
||||
|
||||
export function SearchPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const query = searchParams.get('q') || '';
|
||||
const authorId = searchParams.get('author');
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
const authorId = searchParams.get('author') || '';
|
||||
const authorName = searchParams.get('authorName') || '';
|
||||
const date = searchParams.get('date');
|
||||
const currentPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
const role = searchParams.get('role') || '';
|
||||
|
||||
const [articles, setArticles] = useState<Article[]>([]);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalArticles, setTotalArticles] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchResults = async () => {
|
||||
if (!query && !authorId) return;
|
||||
if (!query && !authorId && !date) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
@ -29,12 +34,15 @@ export function SearchPage() {
|
||||
params: {
|
||||
q: query,
|
||||
author: authorId,
|
||||
page,
|
||||
date,
|
||||
role: role,
|
||||
page: currentPage,
|
||||
limit: ARTICLES_PER_PAGE
|
||||
}
|
||||
});
|
||||
setArticles(response.data.articles);
|
||||
setTotalPages(response.data.totalPages);
|
||||
setTotalArticles(response.data.total);
|
||||
} catch (error) {
|
||||
console.error('Ошибка поиска:', error);
|
||||
} finally {
|
||||
@ -45,24 +53,36 @@ export function SearchPage() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
fetchResults();
|
||||
}, [query, authorId, page]);
|
||||
}, [query, authorId, date, currentPage, role]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setSearchParams({ q: query, page: newPage.toString() });
|
||||
setSearchParams({ q: query, page: newPage.toString(), author: authorId, role: role, authorName: authorName });
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const getPageTitle = () => {
|
||||
if (authorId) {
|
||||
return `Статьи автора ${authorName}`;
|
||||
}
|
||||
if (date) {
|
||||
return `Статьи за ${format(parseISO(date), 'MMMM d, yyyy')}`;
|
||||
}
|
||||
return query ? `Результаты поиска "${query}"` : 'Поиск статей';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
{query ? `Результаты поиска "${query}"` : 'Статьи автора'}
|
||||
{getPageTitle()}
|
||||
</h1>
|
||||
{articles.length > 0 && (
|
||||
<p className="mt-2 text-gray-600">
|
||||
Найдено {articles.length} статей
|
||||
<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>
|
||||
@ -80,19 +100,21 @@ export function SearchPage() {
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<Pagination
|
||||
currentPage={page}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (query || authorId) ? (
|
||||
) : (query || authorId || date) ? (
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-medium text-gray-900 mb-2">
|
||||
Не найдено ни одной статьи
|
||||
</h2>
|
||||
<p className="text-gray-500">
|
||||
{authorId ? "Этот автор не опубликовал пока ни одной статьи" : "Ничего не найдено. Попытайтесь изменить сроку поиска"}
|
||||
{authorId ? "Этот автор не опубликовал пока ни одной статьи" :
|
||||
date ? "На эту дату не опубликовано ни одной статьи" :
|
||||
"Ничего не найдено. Попытайтесь изменить сроку поиска"}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
@ -14,9 +14,15 @@ interface AuthorFormData {
|
||||
}
|
||||
|
||||
export const authorService = {
|
||||
getAuthors: async (): Promise<Author[]> => {
|
||||
getAuthors: async (role: string, page: number): Promise<Author[]> => {
|
||||
try {
|
||||
const response = await axios.get('/authors');
|
||||
const response = await axios.get('/authors', {
|
||||
params: {
|
||||
role: role || undefined, // не отправлять параметр если пусто
|
||||
page: page || 1,
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения списка авторов:', error);
|
||||
|
@ -32,6 +32,7 @@ export interface Author {
|
||||
totalLikes: number;
|
||||
userId?: string;
|
||||
isActive?: boolean;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
export interface UserFormData {
|
||||
@ -56,5 +57,6 @@ export interface AuthorFormData {
|
||||
totalLikes: number;
|
||||
userId?: string;
|
||||
isActive: boolean;
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
|
@ -6,8 +6,13 @@ export interface Article {
|
||||
content: string;
|
||||
categoryId: number;
|
||||
cityId: number;
|
||||
author: Author;
|
||||
authors: {
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}[];
|
||||
authorName: string;
|
||||
coAuthorName: string;
|
||||
photographerName: string;
|
||||
coverImage: string;
|
||||
images?: string[];
|
||||
imageSubs?: string[];
|
||||
@ -20,6 +25,13 @@ export interface Article {
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export enum AuthorRole {
|
||||
WRITER = 'WRITER',
|
||||
PHOTOGRAPHER = 'PHOTOGRAPHER',
|
||||
EDITOR = 'EDITOR',
|
||||
TRANSLATOR = 'TRANSLATOR',
|
||||
}
|
||||
|
||||
// Определяем тип ArticleData для редактрования
|
||||
export interface ArticleData {
|
||||
title: string;
|
||||
@ -32,7 +44,10 @@ export interface ArticleData {
|
||||
content: string;
|
||||
importId: number;
|
||||
isActive: boolean;
|
||||
author: Author;
|
||||
authors: {
|
||||
role: AuthorRole;
|
||||
author: Author;
|
||||
}[];
|
||||
}
|
||||
|
||||
// Структура ответа на список статей
|
||||
@ -66,6 +81,8 @@ export interface Author {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isActive: boolean;
|
||||
userId?: string;
|
||||
roles: string;
|
||||
}
|
||||
|
||||
export const CategoryIds: number[] = [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8];
|
||||
|
Loading…
x
Reference in New Issue
Block a user