Переработана схема привязки авторов к статьям, авторы трех типов. Заложен поиск по дате статьи.

This commit is contained in:
anibilag 2025-06-03 22:34:32 +03:00
parent 7dbfb0323c
commit 7e5f29eb40
27 changed files with 974 additions and 202 deletions

45
package-lock.json generated
View File

@ -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",

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

View File

@ -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} /> ·{' '}

View File

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

View File

@ -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}
/>
)}
</>
)}

View File

@ -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"
>
Статьи автора

View File

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

View File

@ -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>

View File

@ -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">

View File

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

View 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;

View File

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

View 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;

View File

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

View File

@ -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('');

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

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

View File

@ -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[];
}

View File

@ -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];