diff --git a/package-lock.json b/package-lock.json index b6e84ac..a245878 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vite-react-typescript-starter", - "version": "0.0.0", + "version": "1.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vite-react-typescript-starter", - "version": "0.0.0", + "version": "1.1.5", "dependencies": { "@aws-sdk/client-s3": "^3.525.0", "@aws-sdk/s3-request-presigner": "^3.525.0", @@ -35,6 +35,7 @@ "react-dropzone": "^14.2.3", "react-helmet-async": "^2.0.5", "react-router-dom": "^6.22.3", + "react-transition-group": "^4.4.5", "sitemap": "^8.0.0", "uuid": "^9.0.1", "winston": "^3.11.0", @@ -52,6 +53,7 @@ "@types/node": "^22.10.7", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "@types/react-transition-group": "^4.4.12", "@types/uuid": "^9.0.8", "@types/winston": "^2.4.4", "@vitejs/plugin-react": "^4.3.4", @@ -1203,6 +1205,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", @@ -3977,6 +3988,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.12", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/sax": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz", @@ -4977,7 +4998,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/date-fns": { @@ -5063,6 +5083,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -7640,6 +7670,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/package.json b/package.json index 18e31bf..b26c59b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vite-react-typescript-starter", "private": true, - "version": "1.1.5", + "version": "1.2.0", "type": "module", "scripts": { "dev": "vite", @@ -44,6 +44,7 @@ "react-dropzone": "^14.2.3", "react-helmet-async": "^2.0.5", "react-router-dom": "^6.22.3", + "react-transition-group": "^4.4.5", "sitemap": "^8.0.0", "uuid": "^9.0.1", "winston": "^3.11.0", @@ -61,6 +62,7 @@ "@types/node": "^22.10.7", "@types/react": "^18.3.5", "@types/react-dom": "^18.3.0", + "@types/react-transition-group": "^4.4.12", "@types/uuid": "^9.0.8", "@types/winston": "^2.4.4", "@vitejs/plugin-react": "^4.3.4", diff --git a/src/components/ArticleForm.tsx b/src/components/ArticleForm.tsx index d24b9e8..e136488 100644 --- a/src/components/ArticleForm.tsx +++ b/src/components/ArticleForm.tsx @@ -20,15 +20,9 @@ import RolesWord from "./Words/RolesWord"; import { ArrowLeft, Trash2, UserPlus } from "lucide-react"; import isEqual from 'lodash/isEqual'; import { NewGalleryManager } from "./Gallery"; +import AuthorSelectionModal from './AuthorSelectionModal'; -const ArticleAuthorRoleLabels: Record = { - WRITER: 'Автор статьи', - PHOTOGRAPHER: 'Фотограф', - EDITOR: 'Редактор', - TRANSLATOR: 'Переводчик', -}; - interface FormState { title: string; excerpt: string; @@ -92,9 +86,8 @@ 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 [isAuthorModalOpen, setIsAuthorModalOpen] = useState(false); // Изменяем логику отслеживания состояния загрузки const [dataLoaded, setDataLoaded] = useState(false); @@ -240,12 +233,6 @@ export function ArticleForm({ isInitialized ]); - 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(); @@ -311,23 +298,19 @@ export function ArticleForm({ onCancel(); }; + // Обновляем обработчики const handleOpenModal = () => { - setNewAuthorId(''); - setNewRole('WRITER'); - setShowAddAuthorModal(true); + setIsAuthorModalOpen(true); }; const handleCloseModal = () => { - setIsConfirmModalOpen(false); + setIsAuthorModalOpen(false); }; - const handleAddAuthor = () => { - const author = authors.find(a => a.id === newAuthorId); + const handleAddAuthor = (authorId: string, role: AuthorRole) => { + const author = authors.find(a => a.id === authorId); if (author) { - setSelectedAuthors([...selectedAuthors, { authorId: author.id, role: newRole as AuthorRole, author }]); - setShowAddAuthorModal(false); - setNewAuthorId(''); - setNewRole(''); + setSelectedAuthors([...selectedAuthors, { authorId: author.id, role, author }]); } }; @@ -578,60 +561,14 @@ export function ArticleForm({ /> {/* Модальное окно выбора автора */} - {showAddAuthorModal && ( - <> -
-
-

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

- -
-
- {Object.entries(ArticleAuthorRoleLabels).map(([key, label]) => ( - - ))} -
-
- - - - -
- - -
-
-
- + {isAuthorModalOpen && ( + )} diff --git a/src/components/AuthorSelectionModal.tsx b/src/components/AuthorSelectionModal.tsx new file mode 100644 index 0000000..e0f16ec --- /dev/null +++ b/src/components/AuthorSelectionModal.tsx @@ -0,0 +1,332 @@ +import React, { useState, useMemo } from 'react'; +import { Author, AuthorRole } from '../types'; +import { X, ChevronLeft, ChevronRight } from 'lucide-react'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +interface AuthorSelectionModalProps { + isOpen: boolean; + onClose: () => void; + onAddAuthor: (authorId: string, role: AuthorRole) => void; + authors: Author[]; + existingAuthors: { authorId: string; role: AuthorRole }[]; +} + +const roleLabels: Record = { + WRITER: 'Авторы', + PHOTOGRAPHER: 'Фотографы', + EDITOR: 'Редакторы', + TRANSLATOR: 'Переводчики', +}; + +const AuthorSelectionModal: React.FC = ({ + isOpen, + onClose, + onAddAuthor, + authors, + existingAuthors, + }) => { + const [activeTab, setActiveTab] = useState(AuthorRole.WRITER); + const [selectedAuthorId, setSelectedAuthorId] = useState(null); + const [currentPage, setCurrentPage] = useState(1); + const itemsPerPage = 10; + + // Используем useMemo для оптимальной фильтрации и сортировки авторов + const { popularAuthors, otherAuthors } = useMemo(() => { + // Фильтруем авторов по активной вкладке и исключаем уже добавленных + const filteredAuthors = authors.filter(author => + author.roles.includes(activeTab) && + !existingAuthors.some(existing => existing.authorId === author.id && existing.role === activeTab) + ); + + // Сортируем по полю order (по возрастанию) и берем первые 6 + const sortedByOrder = [...filteredAuthors].sort((a, b) => a.order - b.order); + const popularAuthors = sortedByOrder.slice(0, 6); + + // Остальных авторов сортируем по алфавиту + const otherAuthors = filteredAuthors + .filter(author => !popularAuthors.includes(author)) + .sort((a, b) => a.displayName.localeCompare(b.displayName, 'ru')); + + return { popularAuthors, otherAuthors }; + }, [authors, existingAuthors, activeTab]); + + // Вычисляем данные для пагинации только для остальных авторов + const paginationData = useMemo(() => { + const showPagination = otherAuthors.length > itemsPerPage; + + // Если пагинация не нужна, показываем всех остальных авторов + if (!showPagination) { + return { + showPagination: false, + displayedOtherAuthors: otherAuthors, + totalPages: 1 + }; + } + + // Если пагинация нужна + const totalPages = Math.ceil(otherAuthors.length / itemsPerPage); + const startIndex = (currentPage - 1) * itemsPerPage; + const endIndex = startIndex + itemsPerPage; + const displayedOtherAuthors = otherAuthors.slice(startIndex, endIndex); + + return { + showPagination: true, + displayedOtherAuthors, + totalPages + }; + }, [otherAuthors, currentPage, itemsPerPage]); + + const handleAddAuthor = () => { + if (selectedAuthorId) { + onAddAuthor(selectedAuthorId, activeTab); + setSelectedAuthorId(null); + onClose(); + } + }; + + const handleTabChange = (role: AuthorRole) => { + setActiveTab(role); + setCurrentPage(1); + setSelectedAuthorId(null); + }; + + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Прокручиваем к началу списка остальных авторов + document.getElementById('other-authors-section')?.scrollIntoView({ behavior: 'smooth' }); + }; + + if (!isOpen) return null; + + const { showPagination, displayedOtherAuthors, totalPages } = paginationData; + + return ( +
+
+ {/* Заголовок */} +
+

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

+ +
+ + {/* Вкладки */} +
+ {Object.entries(roleLabels).map(([role, label]) => { + const roleAuthorsCount = authors.filter(author => + author.roles.includes(role as AuthorRole) && + !existingAuthors.some(existing => existing.authorId === author.id && existing.role === role) + ).length; + + return ( + + ); + })} +
+ + {/* Содержимое вкладки */} +
+ {/* Популярные авторы (всегда отображаются, если есть) */} + {popularAuthors.length > 0 && ( +
+
+

Популярные

+ + По рейтингу + +
+ + {popularAuthors.map(author => ( + + setSelectedAuthorId(author.id)} + showOrder={true} + /> + + ))} + +
+ )} + + {/* Все остальные авторы с пагинацией */} +
+
+

+ Остальные {roleLabels[activeTab].toLowerCase()} +

+ {showPagination && ( + + Страница {currentPage} из {totalPages} + + )} +
+ + {displayedOtherAuthors.length > 0 ? ( + <> +
+ {displayedOtherAuthors.map(author => ( + setSelectedAuthorId(author.id)} + showOrder={false} + /> + ))} +
+ + {/* Пагинация */} + {showPagination && totalPages > 1 && ( +
+
+ + + {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( + + ))} + + +
+
+ )} + + ) : ( +

+ Нет доступных +

+ )} +
+
+ + {/* Кнопки действий */} +
+ + +
+
+
+ ); +}; + +// Компонент карточки автора (без изменений) +interface AuthorCardProps { + author: Author; + isSelected: boolean; + onSelect: () => void; + showOrder: boolean; +} + +const AuthorCard: React.FC = ({ author, isSelected, onSelect, showOrder }) => { + return ( +
+
+ {author.avatarUrl ? ( + {author.displayName} + ) : ( +
+ + {author.displayName.charAt(0)} + +
+ )} +
+
+

{author.displayName}

+ {showOrder && ( + + #{author.order} + + )} +
+ {author.email && ( +

{author.email}

+ )} +
+
+
+ ); +}; + +export default AuthorSelectionModal; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 2864eeb..b913e57 100644 --- a/src/index.css +++ b/src/index.css @@ -43,6 +43,29 @@ p.image-caption { @apply mt-2 text-lg text-gray-600 italic text-center max-w-[80%]; } + + + .author-card-enter { + opacity: 0; + transform: scale(0.9); + } + + .author-card-enter-active { + opacity: 1; + transform: scale(1); + transition: opacity 300ms, transform 300ms; + } + + .author-card-exit { + opacity: 1; + transform: scale(1); + } + + .author-card-exit-active { + opacity: 0; + transform: scale(0.9); + transition: opacity 300ms, transform 300ms; + } } @layer utilities {