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 (
-
+ {writerAuthors.map((authorLink) => (
+
+ ))}
-
{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 && (
-
-
- Автор
-
-
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 =>
-
- {author.displayName}
- )}
-
+
+
+
+ Авторы статьи
+
+
+
+
+
+
+
+ {/* Таблица выбранных авторов */}
+
+
)}
@@ -400,6 +469,64 @@ export function ArticleForm({
onCancel={handleCloseModal}
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
/>
+
+ {/* Модальное окно выбора автора */}
+ {showAddAuthorModal && (
+ <>
+
+
+
Добавить автора
+
+
+
+ {Object.entries(ArticleAuthorRoleLabels).map(([key, label]) => (
+
+ {
+ setNewRole(key);
+ setNewAuthorId('');
+ }}
+ className="text-blue-600 focus:ring-blue-500 border-gray-300"
+ />
+ {label}
+
+ ))}
+
+
+
+
Выбор автора
+
setNewAuthorId(e.target.value)}
+ className="w-full mb-4 border rounded px-2 py-1"
+ >
+ Выберите автора
+ {filteredAuthors.map(a => (
+
+ {a.displayName}
+
+ ))}
+
+
+
+ setShowAddAuthorModal(false)} className="text-gray-500">Отмена
+
+ Добавить
+
+
+
+
+ >
+ )}
+
);
}
\ 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}
-
+ {(() => {
+ 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) => (
+
+ ))}
+
+
+
+ {writerAuthors.map((a, i) => (
+
+ {a.author.displayName}
+ {i < writerAuthors.length - 1 ? ', ' : ''}
+
+ ))}
+
+
+
+ );
+ })()}
{totalPages > 1 && (
-
-
-
Показано {articles.length} статей
-
- 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"
- >
-
-
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
- 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}
-
- ))}
- 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"
- >
-
-
-
-
-
+
)}
>
)}
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}
@@ -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 (