diff --git a/package-lock.json b/package-lock.json index 2748dee..b6e84ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 75ba8a4..d7f9e77 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/images/pack/anniversary-bn.webp b/public/images/pack/anniversary-bn.webp new file mode 100644 index 0000000..f334258 Binary files /dev/null and b/public/images/pack/anniversary-bn.webp differ diff --git a/public/images/pack/legend-bn.webp b/public/images/pack/legend-bn.webp new file mode 100644 index 0000000..8c970f2 Binary files /dev/null and b/public/images/pack/legend-bn.webp differ diff --git a/public/images/pack/memory-bn.webp b/public/images/pack/memory-bn.webp new file mode 100644 index 0000000..6d64c80 Binary files /dev/null and b/public/images/pack/memory-bn.webp differ diff --git a/public/images/pack/music-bn.webp b/public/images/pack/music-bn.webp new file mode 100644 index 0000000..a33cd0c Binary files /dev/null and b/public/images/pack/music-bn.webp differ diff --git a/public/images/pack/theatre-bn.webp b/public/images/pack/theatre-bn.webp new file mode 100644 index 0000000..b231231 Binary files /dev/null and b/public/images/pack/theatre-bn.webp differ diff --git a/src/components/ArticleCard.tsx b/src/components/ArticleCard.tsx index c10740a..bc6d375 100644 --- a/src/components/ArticleCard.tsx +++ b/src/components/ArticleCard.tsx @@ -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.author.displayName} + {writerAuthors.map((authorLink) => ( + {authorLink.author.displayName} + ))}
-

{article.author.displayName}

+

+ {writerAuthors.map((a, i) => ( + + {a.author.displayName} + {i < writerAuthors.length - 1 ? ', ' : ''} + + ))} +

{article.readTime} ·{' '} diff --git a/src/components/ArticleForm.tsx b/src/components/ArticleForm.tsx index 6322b20..581e918 100644 --- a/src/components/ArticleForm.tsx +++ b/src/components/ArticleForm.tsx @@ -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 = { + 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(authors[0]?.id || ''); const [displayedImages, setDisplayedImages] = useState([]); const [formNewImageUrl, setFormNewImageUrl] = useState(''); const [error, setError] = useState(null); @@ -61,8 +72,18 @@ export function ArticleForm({ const [isSubmitting, setIsSubmitting] = useState(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>([]); + // Добавляем обработку ошибок 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 (
@@ -287,21 +337,40 @@ export function ArticleForm({ />
{editingId && isAdmin && ( -
- - +
+
+ + + +
+ + {/* Таблица выбранных авторов */} +
    + {selectedAuthors.map((a, index) => ( +
  • +
    + {a.author.displayName} + () +
    + +
  • + ))} +
+
)}
@@ -400,6 +469,64 @@ export function ArticleForm({ onCancel={handleCloseModal} message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?" /> + + {/* Модальное окно выбора автора */} + {showAddAuthorModal && ( + <> +
+
+

Добавить автора

+ +
+
+ {Object.entries(ArticleAuthorRoleLabels).map(([key, label]) => ( + + ))} +
+
+ + + + +
+ + +
+
+
+ + )} +
); } \ No newline at end of file diff --git a/src/components/ArticleList.tsx b/src/components/ArticleList.tsx index ce378e8..4f6eea9 100644 --- a/src/components/ArticleList.tsx +++ b/src/components/ArticleList.tsx @@ -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({ 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" /> Только черновики @@ -135,6 +155,13 @@ export const ArticleList = React.memo(function ArticleList({ )}
+
+

+ Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)} + - + {Math.min(currentPage * ARTICLES_PER_PAGE, totalArticles)} из {totalArticles} +

+
{loading ? ( @@ -158,10 +185,37 @@ export const ArticleList = React.memo(function ArticleList({ · {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime}{' '} чтения

-
- {article.author.displayName} - {article.author.displayName} -
+ {(() => { + 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 ( +
+ + {writerAuthors.map((authorLink) => ( + {authorLink.author.displayName} + ))} + +
+

+ {writerAuthors.map((a, i) => ( + + {a.author.displayName} + {i < writerAuthors.length - 1 ? ', ' : ''} + + ))} +

+
+
+ ); + })()}
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( - - ))} - -
- - + )} )} diff --git a/src/components/AuthorsSection.tsx b/src/components/AuthorsSection.tsx index 5b0c22b..e98dd28 100644 --- a/src/components/AuthorsSection.tsx +++ b/src/components/AuthorsSection.tsx @@ -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() { {author.displayName}

{author.displayName}

@@ -107,7 +108,7 @@ export function AuthorsSection() {
Статьи автора → diff --git a/src/components/Footer/DesignStudioLogo.tsx b/src/components/Footer/DesignStudioLogo.tsx index afc50c3..008dc18 100644 --- a/src/components/Footer/DesignStudioLogo.tsx +++ b/src/components/Footer/DesignStudioLogo.tsx @@ -3,13 +3,13 @@ import { Palette } from 'lucide-react'; export function DesignStudioLogo() { return ( - Designed by StackBlitz Studio + Дизайн - Студия Красный Холм ); } \ No newline at end of file diff --git a/src/components/Footer/index.tsx b/src/components/Footer/index.tsx index d499663..1cef5eb 100644 --- a/src/components/Footer/index.tsx +++ b/src/components/Footer/index.tsx @@ -104,14 +104,14 @@ export function Footer() {
  • - - Royal Albert Hall + + Ваша ссылка
  • - - National Theatre + + Ваша ссылка
  • @@ -122,22 +122,11 @@ export function Footer() {

    - © {new Date().getFullYear()} Культура двух столиц. Все права защищены. + © {new Date().getFullYear()} Новая Культура Двух Столиц. Все права защищены.

    -
    - - Privacy Policy - - - Terms of Service - - - Sitemap - -
    diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a88762c..e7939ba 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -44,6 +44,14 @@ export function Header() { return (
    + {/* Название блога над навигацией */} +
    +
    +

    + Новая Культура Двух Столиц +

    +
    +
    diff --git a/src/components/Header/SearchBar.tsx b/src/components/Header/SearchBar.tsx index 00ae56a..9b61e9c 100644 --- a/src/components/Header/SearchBar.tsx +++ b/src/components/Header/SearchBar.tsx @@ -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(); + const datePickerRef = useRef(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 ( -
    - 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" - /> - -
    +
    + {/* Обёртка для поля ввода и иконки поиска */} +
    + 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" + /> + +
    + + {/* Кнопка календаря */} +
    + + + {showDatePicker && ( +
    + +
    + )} +
    + + {/* Показ выбранной даты */} + {selectedDate && ( +
    + {format(selectedDate, 'dd.MM.yyyy')} +
    + )} +
    ); } \ No newline at end of file diff --git a/src/components/ImageUpload/ImageUploaderModal.tsx b/src/components/ImageUpload/ImageUploaderModal.tsx new file mode 100644 index 0000000..83d3ee5 --- /dev/null +++ b/src/components/ImageUpload/ImageUploaderModal.tsx @@ -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 = ({ isOpen, onClose, onSuccess }) => { + const [file, setFile] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const onFileChange = useCallback((e: ChangeEvent) => { + 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('/images', form, { + headers: { 'Content-Type': 'multipart/form-data' } + }); + + onSuccess(data); + handleClose(); + } catch (err) { + console.error(err); + setError('Не удалось загрузить файл. Попробуйте ещё раз.'); + } finally { + setLoading(false); + } + }; + + return ( + +
    + + + {error &&

    {error}

    } + +
    + + +
    +
    +
    + ); +}; + +export default ImageUploaderModal; \ No newline at end of file diff --git a/src/components/Pagination.tsx b/src/components/Pagination.tsx index ee6177d..26f8c1b 100644 --- a/src/components/Pagination.tsx +++ b/src/components/Pagination.tsx @@ -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 ( - +
    +
    + + + + {/* Поле быстрого перехода */} +
    + Страница: + setInputPage(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleInputSubmit()} + className="w-20 px-2 py-1 border rounded-md text-sm" + /> + +
    +
    ); -} \ No newline at end of file +} diff --git a/src/components/Words/RolesWord.tsx b/src/components/Words/RolesWord.tsx new file mode 100644 index 0000000..b56dde9 --- /dev/null +++ b/src/components/Words/RolesWord.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { AuthorRole } from "../../types"; + +// Описание типа для пропсов компонента +interface RolesWordProps { + role: AuthorRole; +} + +const RolesWord: React.FC = ({ 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; + diff --git a/src/hooks/useAuthorManagement.ts b/src/hooks/useAuthorManagement.ts index a1d0880..fa3d504 100644 --- a/src/hooks/useAuthorManagement.ts +++ b/src/hooks/useAuthorManagement.ts @@ -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([]); const [users, setUsers] = useState([]); const [selectedAuthor, setSelectedAuthor] = useState(null); + const [selectedRole, setSelectedRole] = useState(''); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 }; } \ No newline at end of file diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index 0458f30..0283e8d 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -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(''); diff --git a/src/pages/ArticlePage.tsx b/src/pages/ArticlePage.tsx index 33769d6..5639dd2 100644 --- a/src/pages/ArticlePage.tsx +++ b/src/pages/ArticlePage.tsx @@ -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 (
    - {articleData.author.displayName} + {writerAuthors.map((authorLink) => ( + {authorLink.author.displayName} + ))}
    -

    {articleData.author.displayName}

    +

    + {writerAuthors.map((a, i) => ( + + {a.author.displayName} + {i < writerAuthors.length - 1 ? ', ' : ''} + + ))} +

    {articleData.readTime} на чтение ·{' '} @@ -164,12 +178,41 @@ export function ArticlePage() { )} {/* Article Footer */} -
    +
    {error && (
    {error}
    )} + + {/* Авторы */} +
    + {(() => { + const photographer = articleData.authors?.find(a => a.role === 'PHOTOGRAPHER'); + + return ( + <> + {writerAuthors && ( +

    + {writerAuthors.map((a, i) => ( + + {a.author.displayName} + {i < writerAuthors.length - 1 ? ', ' : ''} + + ))} +

    + )} + {photographer && ( +

    + Фото:{' '} + {photographer.author.displayName} +

    + )} + + ); + })()} +
    + = { + WRITER: , + PHOTOGRAPHER: , + EDITOR: , +}; + +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() {
    )} +
    + +
    +
    @@ -402,6 +465,31 @@ export function AuthorManagementPage() { />
    +
    + +
    + {allRoles.map(role => ( + + ))} +
    +
    +
    +
    + + + handleEditField(article.id, 'coAuthorName', e.target.value) + } + className="w-full border border-gray-300 rounded px-3 py-1 text-sm" + placeholder="Введите имя соавтора" + /> +
    + +
    + + + handleEditField(article.id, 'photographerName', e.target.value) + } + className="w-full border border-gray-300 rounded px-3 py-1 text-sm" + placeholder="Введите имя фотографа" + /> +
    + {(article.images?.length || 0) > 0 && (

    Изображения с подписями:

    @@ -290,6 +317,21 @@ export function ImportArticlesPage() {
    )}
    + {/* Модальное окно подтверждения загрузки */} + {showSuccessModal && ( +
    +
    +

    ✅ Успешно!

    +

    Статьи успешно сохранены на сервере.

    + +
    +
    + )}
    diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 3277d4e..3dc98a6 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -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([]); 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 (
    -
    +

    - {query ? `Результаты поиска "${query}"` : 'Статьи автора'} + {getPageTitle()}

    {articles.length > 0 && ( -

    - Найдено {articles.length} статей +

    + Статьи {Math.min((currentPage - 1) * ARTICLES_PER_PAGE + 1, totalArticles)} + - + {Math.min(currentPage * ARTICLES_PER_PAGE, totalArticles)} из {totalArticles}

    )}
    @@ -80,19 +100,21 @@ export function SearchPage() {
    {totalPages > 1 && ( )} - ) : (query || authorId) ? ( + ) : (query || authorId || date) ? (

    Не найдено ни одной статьи

    - {authorId ? "Этот автор не опубликовал пока ни одной статьи" : "Ничего не найдено. Попытайтесь изменить сроку поиска"} + {authorId ? "Этот автор не опубликовал пока ни одной статьи" : + date ? "На эту дату не опубликовано ни одной статьи" : + "Ничего не найдено. Попытайтесь изменить сроку поиска"}

    ) : null} diff --git a/src/services/authorService.ts b/src/services/authorService.ts index 6f996bd..c40ea17 100644 --- a/src/services/authorService.ts +++ b/src/services/authorService.ts @@ -14,9 +14,15 @@ interface AuthorFormData { } export const authorService = { - getAuthors: async (): Promise => { + getAuthors: async (role: string, page: number): Promise => { 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); diff --git a/src/types/auth.ts b/src/types/auth.ts index 6e98b2f..30dd362 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -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[]; } diff --git a/src/types/index.ts b/src/types/index.ts index 4204296..7f8ed13 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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];