diff --git a/package-lock.json b/package-lock.json index 46448fb..0e46d34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "dotenv": "^16.4.5", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", "lucide-react": "^0.344.0", "multer": "1.4.5-lts.1", "react": "^18.3.1", @@ -40,6 +41,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", + "@types/lodash": "^4.17.16", "@types/multer": "^1.4.11", "@types/node": "^22.10.7", "@types/react": "^18.3.5", @@ -3642,6 +3644,13 @@ "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.16", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz", + "integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/markdown-it": { "version": "14.1.2", "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", @@ -6117,6 +6126,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", diff --git a/package.json b/package.json index 3399787..e620bce 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dotenv": "^16.4.5", "express": "^4.21.2", "jsonwebtoken": "^9.0.2", + "lodash": "^4.17.21", "lucide-react": "^0.344.0", "multer": "1.4.5-lts.1", "react": "^18.3.1", @@ -48,6 +49,7 @@ "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", + "@types/lodash": "^4.17.16", "@types/multer": "^1.4.11", "@types/node": "^22.10.7", "@types/react": "^18.3.5", diff --git a/public/images/pack/art-bn.webp b/public/images/pack/art-bn.webp new file mode 100644 index 0000000..b75bf99 Binary files /dev/null and b/public/images/pack/art-bn.webp differ diff --git a/public/images/pack/film-bn.webp b/public/images/pack/film-bn.webp new file mode 100644 index 0000000..5f71b8c Binary files /dev/null and b/public/images/pack/film-bn.webp differ diff --git a/public/images/pack/sport-bn.webp b/public/images/pack/sport-bn.webp new file mode 100644 index 0000000..7d93e3e Binary files /dev/null and b/public/images/pack/sport-bn.webp differ diff --git a/src/components/ArticleContent.tsx b/src/components/ArticleContent.tsx index d539baf..ce1a8f5 100644 --- a/src/components/ArticleContent.tsx +++ b/src/components/ArticleContent.tsx @@ -1,38 +1,12 @@ -//import { ResizableImage } from './TipTapEditor'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Blockquote from '@tiptap/extension-blockquote'; -import Image from '@tiptap/extension-blockquote'; +import { CustomImage } from './CustomImageExtension'; interface ArticleContentProps { content: string; } -const ResizableImage = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - src: { default: null, }, - alt: { default: null, }, - title: { default: null, }, - class: { default: 'max-w-full h-auto', }, - width: { - default: 'auto', - parseHTML: (element) => element.getAttribute('width') || 'auto', - renderHTML: (attributes) => ({ - width: attributes.width, - }), - }, - height: { - default: 'auto', - parseHTML: (element) => element.getAttribute('height') || 'auto', - renderHTML: (attributes) => ({ - height: attributes.height, - }), - }, - }; - }, -}); // Показ контента статьи в редакторе, в режиме "только чтение" export function ArticleContent({ content }: ArticleContentProps) { @@ -46,7 +20,7 @@ export function ArticleContent({ content }: ArticleContentProps) { class: 'border-l-4 border-gray-300 pl-4 italic', }, }), - ResizableImage + CustomImage ], content, editable: false, // Контент только для чтения diff --git a/src/components/ArticleForm.tsx b/src/components/ArticleForm.tsx index 6e678d7..9320a9d 100644 --- a/src/components/ArticleForm.tsx +++ b/src/components/ArticleForm.tsx @@ -1,11 +1,13 @@ -import React, { useState, useEffect } from 'react'; -import { TipTapEditor } from './TipTapEditor.tsx'; -import { CoverImageUpload } from './ImageUpload/CoverImageUpload.tsx'; -import { ImageUploader } from './ImageUpload/ImageUploader.tsx'; -import { GalleryManager } from './GalleryManager'; -import { useGallery } from '../hooks/useGallery'; -import { CategoryTitles, CityTitles, Author, GalleryImage, ArticleData } from '../types'; -import { useAuthStore } from '../stores/authStore'; +import React, {useEffect, useState} from 'react'; +import {TipTapEditor} from './TipTapEditor.tsx'; +import {CoverImageUpload} from './ImageUpload/CoverImageUpload.tsx'; +import {ImageUploader} from './ImageUpload/ImageUploader.tsx'; +import {GalleryManager} from './GalleryManager'; +import {useGallery} from '../hooks/useGallery'; +import {ArticleData, Author, CategoryTitles, CityTitles, GalleryImage} from '../types'; +import {useAuthStore} from '../stores/authStore'; +import ConfirmModal from './ConfirmModal'; + interface FormState { title: string; @@ -56,9 +58,32 @@ export function ArticleForm({ const [error, setError] = useState(null); const [hasChanges, setHasChanges] = useState(false); const [isInitialLoad, setIsInitialLoad] = useState(true); + const [isSubmitting, setIsSubmitting] = useState(false); // Добавляем флаг для отслеживания отправки + const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false); // Состояние для модального окна const { images: galleryImages, loading: galleryLoading, error: galleryError, addImage: addGalleryImage, updateImage: updateGalleryImage, deleteImage: deleteGalleryImage, reorderImages: reorderGalleryImages } = useGallery(editingId || ''); + // Добавляем обработку ошибок + useEffect(() => { + const handleUnhandledRejection = (event: PromiseRejectionEvent) => { + console.error('Unhandled promise rejection in ArticleForm:', event.reason); + event.preventDefault(); // Предотвращаем "всплытие" ошибки + }; + + const handleError = (event: ErrorEvent) => { + console.error('Unhandled error in ArticleForm:', event.error); + event.preventDefault(); // Предотвращаем "всплытие" ошибки + }; + + window.addEventListener('unhandledrejection', handleUnhandledRejection); + window.addEventListener('error', handleError); + + return () => { + window.removeEventListener('unhandledrejection', handleUnhandledRejection); + window.removeEventListener('error', handleError); + }; + }, []); + useEffect(() => { if (editingId) { setDisplayedImages(galleryImages); @@ -78,6 +103,7 @@ export function ArticleForm({ setContent(initialFormState.content); setAuthorId(initialFormState.authorId); setDisplayedImages(initialFormState.galleryImages || []); + console.log('Содержимое статьи при загрузке:', initialFormState.content); } }, [initialFormState]); @@ -93,13 +119,11 @@ export function ArticleForm({ return isDifferent; } if (key === 'content') { - const isDifferent = JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]); - return isDifferent; + return JSON.stringify(currentState[key]) !== JSON.stringify(initialFormState[key]); } const currentValue = typeof currentState[key as keyof FormState] === 'number' ? String(currentState[key as keyof FormState]) : currentState[key as keyof FormState]; const initialValue = typeof initialFormState[key as keyof FormState] === 'number' ? String(initialFormState[key as keyof FormState]) : initialFormState[key as keyof FormState]; - const isDifferent = currentValue !== initialValue; - return isDifferent; + return currentValue !== initialValue; }); setHasChanges(hasFormChanges && areRequiredFieldsFilled); @@ -112,6 +136,14 @@ export function ArticleForm({ const handleSubmit = async (e: React.FormEvent, closeForm: boolean = true) => { e.preventDefault(); + console.log('Вызов handleSubmit:', { closeForm }); + console.log('Содержимое статьи перед сохранением:', content); + + if (isSubmitting) { + console.log('Форма уже отправляется, игнорируем повторную отправку'); + return; + } + if (!title.trim() || !excerpt.trim()) { setError('Пожалуйста, заполните обязательные поля: Заголовок и Краткое описание.'); return; @@ -120,7 +152,14 @@ export function ArticleForm({ if (!hasChanges) return; const selectedAuthor = editingId && isAdmin ? authors.find(a => a.id === authorId) || authors[0] : user; - const articleData = { + + // Проверяем, что selectedAuthor существует и соответствует типу Author + if (!selectedAuthor) { + setError('Пожалуйста, выберите автора или войдите в систему.'); + return; + } + + const articleData: ArticleData = { title, excerpt, categoryId, @@ -134,13 +173,38 @@ export function ArticleForm({ author: selectedAuthor, }; - await onSubmit(articleData, closeForm); + try { + setIsSubmitting(true); + await onSubmit(articleData, closeForm); + } catch (error) { + console.error('Ошибка при сохранении статьи:', error); + setError('Не удалось сохранить статью. Пожалуйста, попробуйте снова.'); + } finally { + setIsSubmitting(false); + } }; const handleApply = (e: React.FormEvent) => { handleSubmit(e, false); }; + const handleCancel = () => { + if (hasChanges) { + setIsConfirmModalOpen(true); // Открываем модальное окно + } else { + onCancel(); + } + }; + + const handleConfirmCancel = () => { + setIsConfirmModalOpen(false); + onCancel(); + }; + + const handleCloseModal = () => { + setIsConfirmModalOpen(false); + }; + if (editingId && galleryLoading) { return (
@@ -184,7 +248,9 @@ export function ArticleForm({
- +
- + {editingId && isAdmin && (
- +
)} @@ -238,14 +313,27 @@ export function ArticleForm({ allowUpload={!!editingId} />
- - + +
- + {editingId ? ( <> - { setFormNewImageUrl(imageUrl); }} articleId={articleId} /> + { + setFormNewImageUrl(imageUrl); + }} + articleId={articleId} + /> )}
+
); } \ No newline at end of file diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx new file mode 100644 index 0000000..8e00a43 --- /dev/null +++ b/src/components/ConfirmModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +interface ConfirmModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + message: string; +} + +const ConfirmModal: React.FC = ({ isOpen, onConfirm, onCancel, message }) => { + if (!isOpen) return null; + + return ( +
+
+

{message}

+
+ + +
+
+
+ ); +}; + +export default ConfirmModal; \ No newline at end of file diff --git a/src/components/CustomImageExtension.tsx b/src/components/CustomImageExtension.tsx index dfb5900..e7b76e4 100644 --- a/src/components/CustomImageExtension.tsx +++ b/src/components/CustomImageExtension.tsx @@ -1,103 +1,175 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Node, mergeAttributes } from '@tiptap/core'; -import {Editor, ReactNodeViewRenderer } from '@tiptap/react'; -import { ZoomIn, ZoomOut } from 'lucide-react'; +import React from 'react'; +import { Node, mergeAttributes, NodeViewProps } from '@tiptap/core'; +import { ReactNodeViewRenderer, NodeViewWrapper } from '@tiptap/react'; +import { ZoomIn, ZoomOut, Trash2 } from 'lucide-react'; import { Node as ProseMirrorNode } from 'prosemirror-model'; - -interface ImageNodeViewProps { - node: ProseMirrorNode; - updateAttributes: (attrs: Record) => void; - editor: Editor; - getPos: () => number; +// Определяем тип для атрибутов узла customImage +interface CustomImageAttributes { + src: string | undefined; + alt: string | undefined; + title: string | undefined; + scale: number; } -// React component for the image node view -const ImageNodeView: React.FC = ({ node, updateAttributes, editor, getPos }) => { - const [isSelected, setIsSelected] = useState(false); - const [scale, setScale] = useState(1); - const imageRef = useRef(null); +const ImageNodeView: React.FC = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => { + // Утверждаем, что node имеет нужный тип + const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes }; - // Update selection state when the editor selection changes - useEffect(() => { - const handleSelectionUpdate = ({ editor }: { editor: Editor }) => { - const { state } = editor; - const { selection } = state; - const nodePos = getPos(); + // Проверяем, что узел корректен + if (node.type.name !== 'customImage') { + console.error('Неподдерживаемый тип узла:', node.type.name); + return Ошибка: неподдерживаемый тип узла; + } - // Check if this node is selected - const isNodeSelected = selection.$anchor.pos >= nodePos && - selection.$anchor.pos <= nodePos + node.nodeSize; + // Проверяем, что src существует + if (!typedNode.attrs.src) { + console.error('Изображение не загружено, src отсутствует:', typedNode.attrs); + return Ошибка: изображение не загружено; + } - setIsSelected(isNodeSelected); - }; + // Логируем атрибуты узла при загрузке + console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs); - editor.on('selectionUpdate', handleSelectionUpdate); - return () => { - editor.off('selectionUpdate', handleSelectionUpdate); - }; - }, [editor, getPos, node.nodeSize]); - - // Handle zoom in const handleZoomIn = (e: React.MouseEvent) => { + e.preventDefault(); e.stopPropagation(); - setScale(prev => Math.min(prev + 0.1, 2)); - updateAttributes({ scale: Math.min(scale + 0.1, 2) }); - }; + try { + if (!editor || editor.isDestroyed) { + console.error('Редактор уничтожен или недоступен'); + return; + } - // Handle zoom out - const handleZoomOut = (e: React.MouseEvent) => { - e.stopPropagation(); - setScale(prev => Math.max(prev - 0.1, 0.5)); - updateAttributes({ scale: Math.max(scale - 0.1, 0.5) }); - }; + const pos = getPos(); + const currentNode = editor.state.doc.nodeAt(pos); + if (!currentNode || currentNode.type.name !== 'customImage') { + console.error('Узел больше не существует или изменён:', { pos, currentNode }); + return; + } - // Track current scale from attributes - useEffect(() => { - if (node.attrs.scale) { - setScale(node.attrs.scale); + let currentScale = typedNode.attrs.scale; + if (currentScale <= 0 || isNaN(currentScale)) { + console.warn('Некорректное значение scale, устанавливаем значение по умолчанию:', { currentScale }); + currentScale = 1; + updateAttributes({ scale: 1 }); + } + + const newScale = Math.min(currentScale + 0.1, 2); + const roundedScale = Number(newScale.toFixed(2)); + updateAttributes({ scale: roundedScale }); + + console.log('Масштаб увеличен через updateAttributes:', { currentScale, newScale: roundedScale }); + } catch (error) { + console.error('Ошибка при увеличении масштаба:', error); } - }, [node.attrs.scale]); + }; + + const handleZoomOut = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + if (!editor || editor.isDestroyed) { + console.error('Редактор уничтожен или недоступен'); + return; + } + + const pos = getPos(); + const currentNode = editor.state.doc.nodeAt(pos); + if (!currentNode || currentNode.type.name !== 'customImage') { + console.error('Узел больше не существует или изменён:', { pos, currentNode }); + return; + } + + let currentScale = typedNode.attrs.scale; + if (currentScale <= 0 || isNaN(currentScale)) { + console.warn('Некорректное значение scale, устанавливаем значение по умолчанию:', { currentScale }); + currentScale = 1; + updateAttributes({ scale: 1 }); + } + + const newScale = Math.max(currentScale - 0.1, 0.5); + const roundedScale = Number(newScale.toFixed(2)); + updateAttributes({ scale: roundedScale }); + + console.log('Масштаб уменьшен через updateAttributes:', { currentScale, newScale: roundedScale }); + } catch (error) { + console.error('Ошибка при уменьшении масштаба:', error); + } + }; + + const handleDelete = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + try { + console.log('Перед вызовом deleteNode'); + deleteNode(); + console.log('После вызова deleteNode'); + } catch (error) { + console.error('Ошибка при удалении изображения:', error); + } + }; + + const scale = typedNode.attrs.scale || 1; + const isEditable = editor.isEditable; // Проверяем, находится ли редактор в режиме редактирования return ( -
- {/* The actual image */} + + {/* Оборачиваем изображение и панель в контейнер, который масштабируется */} +
{node.attrs.alt - - {/* Zoom controls - only visible when selected */} - {isSelected && ( -
- - -
+ {selected && isEditable && ( // Показываем панель только в режиме редактирования +
+ + + + + Масштаб: {scale} + +
)} -
+
+ ); }; -// Custom Image extension for TipTap export const CustomImage = Node.create({ name: 'customImage', group: 'block', @@ -109,16 +181,28 @@ export const CustomImage = Node.create({ addAttributes() { return { src: { - default: null, + default: undefined, }, alt: { - default: null, + default: undefined, }, title: { - default: null, + default: undefined, }, scale: { default: 1, + parseHTML: (element) => { + const scale = Number(element.getAttribute('scale')); + const parsedScale = isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2)))); + console.log('Парсинг scale из HTML:', { scale, parsedScale }); + return parsedScale; + }, + renderHTML: (attributes) => { + const scale = Number(attributes.scale); + const renderedScale = isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2)))); + console.log('Рендеринг scale в HTML:', { scale, renderedScale }); + return { scale: renderedScale }; + }, }, }; }, @@ -127,6 +211,15 @@ export const CustomImage = Node.create({ return [ { tag: 'img[src]', + getAttrs: (element) => { + const scale = element.getAttribute('scale'); + return { + src: element.getAttribute('src'), + alt: element.getAttribute('alt'), + title: element.getAttribute('title'), + scale: scale ? Number(scale) : 1, + }; + }, }, ]; }, @@ -136,11 +229,17 @@ export const CustomImage = Node.create({ return [ 'div', { class: 'flex justify-center my-4' }, - ['img', mergeAttributes(rest, { style: `transform: scale(${scale})` })], + [ + 'img', + mergeAttributes(rest, { + scale: scale, + style: `transform: scale(${scale})`, + }), + ], ]; }, addNodeView() { return ReactNodeViewRenderer(ImageNodeView); }, -}); +}); \ No newline at end of file diff --git a/src/components/ImageUpload/ImageUploader.tsx b/src/components/ImageUpload/ImageUploader.tsx index 9485bb9..18548fe 100644 --- a/src/components/ImageUpload/ImageUploader.tsx +++ b/src/components/ImageUpload/ImageUploader.tsx @@ -20,22 +20,26 @@ export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProp try { setUploadProgress(prev => ({ ...prev, - [file.name]: { progress: 0, status: 'uploading' } + [file.name]: { progress: 0, status: 'uploading' }, })); const resolution = imageResolutions.find(r => r.id === selectedResolution); - if (!resolution) throw new Error('Invalid resolution selected'); + if (!resolution) throw new Error('Выбрано неправильное разрешение'); const uploadedImage = await uploadImageToS3(file, resolution, articleId, (progress) => { setUploadProgress(prev => ({ ...prev, - [file.name]: { progress, status: 'uploading' } + [file.name]: { progress, status: 'uploading' }, })); }); setUploadProgress(prev => ({ ...prev, - [file.name]: { progress: 100, status: 'complete' } + [file.name]: { + progress: 100, + status: 'complete', + imageUrl: uploadedImage.url, // Сохраняем URL изображения + }, })); onUploadComplete(uploadedImage.url); @@ -45,7 +49,7 @@ export function ImageUploader({ onUploadComplete, articleId }: ImageUploaderProp [file.name]: { progress: 0, status: 'error', - error: error instanceof Error ? error.message : 'Upload failed' + error: error instanceof Error ? error.message : 'Upload failed', } })); } diff --git a/src/components/ImageUpload/UploadProgress.tsx b/src/components/ImageUpload/UploadProgress.tsx index 136e439..6c81c0a 100644 --- a/src/components/ImageUpload/UploadProgress.tsx +++ b/src/components/ImageUpload/UploadProgress.tsx @@ -21,13 +21,13 @@ export function UploadProgress({ progress, fileName }: UploadProgressProps) { const getStatusText = () => { switch (progress.status) { case 'uploading': - return 'Uploading...'; + return 'Загрузка...'; case 'processing': - return 'Processing...'; + return 'Обработка...'; case 'complete': - return 'Upload complete'; + return 'Загрузка завершена'; case 'error': - return progress.error || 'Upload failed'; + return progress.error || 'Сбой загрузки'; } }; @@ -35,9 +35,17 @@ export function UploadProgress({ progress, fileName }: UploadProgressProps) {
{getStatusIcon()}
-

- {fileName} -

+ {progress.status === 'complete' && progress.imageUrl ? ( +
+ Превью загруженного изображения +
+ ) : ( +

{fileName}

+ )}

{getStatusText()}

{(progress.status === 'uploading' || progress.status === 'processing') && ( diff --git a/src/components/TipTapEditor.tsx b/src/components/TipTapEditor.tsx index 73171d7..b4c928b 100644 --- a/src/components/TipTapEditor.tsx +++ b/src/components/TipTapEditor.tsx @@ -2,7 +2,6 @@ import { useEditor, EditorContent } from '@tiptap/react'; import Highlight from '@tiptap/extension-highlight'; import TextAlign from '@tiptap/extension-text-align'; import Blockquote from '@tiptap/extension-blockquote'; -import Image from '@tiptap/extension-image'; import StarterKit from '@tiptap/starter-kit'; import { Bold, @@ -14,87 +13,25 @@ import { Undo, AlignLeft, AlignCenter, - Plus, - Minus, SquareUser, - ImagePlus } from 'lucide-react'; -import { useEffect, useState } from "react"; -import { CustomImage } from "./CustomImageExtension"; -import TipTapImageUploadButton from "./TipTapImageUploadButton"; - +import { useEffect, useState } from 'react'; +import { CustomImage } from './CustomImageExtension'; +import TipTapImageUploadButton from './TipTapImageUploadButton'; interface TipTapEditorProps { initialContent: string; onContentChange: (content: string) => void; - atricleId: string; + articleId: string; } -/* -const ResizableImage = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - src: { default: null, }, - alt: { default: null, }, - title: { default: null, }, - class: { default: 'max-w-full h-auto', }, - width: { - default: 'auto', - parseHTML: (element) => element.getAttribute('width') || 'auto', - renderHTML: (attributes) => ({ - width: attributes.width, - class: selectedImage === attributes.src ? 'selected-image' : '', - }), - }, - height: { - default: 'auto', - parseHTML: (element) => element.getAttribute('height') || 'auto', - renderHTML: (attributes) => ({ - height: attributes.height, - }), - }, - }; - }, -}); -*/ - -export function TipTapEditor({ initialContent, onContentChange, atricleId }: TipTapEditorProps) { +export function TipTapEditor({ initialContent, onContentChange, articleId }: TipTapEditorProps) { const [selectedImage, setSelectedImage] = useState(null); -/* - const ResizableImage = Image.extend({ - addAttributes() { - return { - ...this.parent?.(), - src: { default: null, }, - alt: { default: null, }, - title: { default: null, }, - class: { default: 'max-w-full h-auto', }, - width: { - default: 'auto', - parseHTML: (element) => element.getAttribute('width') || 'auto', - renderHTML: (attributes) => ({ - width: attributes.width, - class: selectedImage === attributes.src ? 'selected-image' : '', - }), - }, - height: { - default: 'auto', - parseHTML: (element) => element.getAttribute('height') || 'auto', - renderHTML: (attributes) => ({ - height: attributes.height, - }), - }, - }; - }, - }); -*/ - const editor = useEditor({ extensions: [ StarterKit.configure({ - blockquote: false, // Отключаем дефолтный + blockquote: false, }), Blockquote.configure({ HTMLAttributes: { @@ -118,9 +55,8 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip let foundImage = false; editor.state.doc.nodesBetween(from, to, (node) => { - if (node.type.name === 'image') { + if (node.type.name === 'customImage') { setSelectedImage(node.attrs.src); - editor.commands.updateAttributes('image', { class: 'selected-image' }); foundImage = true; } }); @@ -133,7 +69,6 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip }, }); - // Обновление контента при изменении initialContent useEffect(() => { if (editor && editor.getHTML() !== initialContent) { editor.commands.setContent(initialContent); @@ -151,36 +86,132 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip return () => document.removeEventListener('mousedown', handleClickOutside); }, [editor]); - if (!editor) return null; - const resizeImage = (delta: number) => { - if (!editor.isActive('image')) return; + const handleClick = () => { + const { state, dispatch } = editor.view; + const tr = state.tr; - const attrs = editor?.getAttributes('image'); - const newWidth = Math.max((parseInt(attrs?.width, 10) || 100) + delta, 50); + state.doc.forEach((paragraphNode, paragraphOffset) => { + if (paragraphNode.type.name !== 'paragraph') return; - editor.chain().focus().updateAttributes('image', {width: `${newWidth}px`}).run(); + let paragraphText = ''; + const textPositions: { start: number; end: number }[] = []; + + paragraphNode.content.forEach((textNode, textOffset) => { + if (textNode.isText) { + const nodeText = textNode.text || ''; + const start = paragraphOffset + 1 + textOffset; + textPositions.push({ + start, + end: start + nodeText.length, + }); + paragraphText += nodeText; + } + }); + + if (!paragraphText) return; + + const sentenceRegex = /[^.!?]+[.!?]+/g; + const sentences: { text: string; start: number; end: number }[] = []; + let match; + let lastIndex = 0; + + while ((match = sentenceRegex.exec(paragraphText)) !== null) { + sentences.push({ + text: match[0], + start: match.index, + end: sentenceRegex.lastIndex, + }); + lastIndex = sentenceRegex.lastIndex; + } + + if (lastIndex < paragraphText.length) { + sentences.push({ + text: paragraphText.slice(lastIndex), + start: lastIndex, + end: paragraphText.length, + }); + } + + sentences.forEach((sentence) => { + const words = []; + const wordRegex = /\S+/g; + let wordMatch; + + while ((wordMatch = wordRegex.exec(sentence.text)) !== null) { + words.push({ + word: wordMatch[0], + start: sentence.start + wordMatch.index, + end: sentence.start + wordRegex.lastIndex, + }); + } + + if (words.length === 0) return; + + let isFirstWord = true; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + const currentWord = word.word; + + if (/^[A-ZА-ЯЁ]/u.test(currentWord)) { + if (isFirstWord) { + isFirstWord = false; + continue; + } + + let afterQuote = false; + + if (i > 0) { + const prevWord = words[i - 1].word; + if (/[""«»''`*]$/.test(prevWord)) { + afterQuote = true; + } else { + const betweenWords = sentence.text.substring( + words[i - 1].end - sentence.start, + word.start - sentence.start + ); + if (/\s[""«»''`]/.test(betweenWords) || /\s\*[«»]\*/.test(betweenWords) || /\s\*/.test(betweenWords)) { + afterQuote = true; + } + } + } else { + const sentencePrefix = sentence.text.substring(0, word.start - sentence.start).trim(); + if (/^[""«»''`]/.test(sentencePrefix) || /^\*[«»]\*/.test(sentencePrefix) || /^\*/.test(sentencePrefix)) { + afterQuote = true; + } + } + + if (!afterQuote) { + const docPositions = mapWordToDocumentPositions(word, textPositions, paragraphOffset); + if (docPositions) { + tr.addMark(docPositions.start, docPositions.end, state.schema.marks.bold.create()); + } + } + } + isFirstWord = false; + } + }); + }); + + dispatch(tr); }; - // Helper function to map paragraph word positions to document positions const mapWordToDocumentPositions = ( - word: { start: number, end: number }, - textPositions: { start: number, end: number }[], + word: { start: number; end: number }, + textPositions: { start: number; end: number }[], paragraphOffset: number ) => { let docStart = -1; let docEnd = -1; - // Find which text node(s) contain this word for (const pos of textPositions) { - // Word starts in this text node if (docStart === -1 && word.start < pos.end - paragraphOffset - 1) { const relativeStart = Math.max(0, word.start - (pos.start - paragraphOffset - 1)); docStart = pos.start + relativeStart; } - // Word ends in this text node if (docStart !== -1 && word.end <= pos.end - paragraphOffset - 1) { const relativeEnd = Math.min(pos.end - pos.start, word.end - (pos.start - paragraphOffset - 1)); docEnd = pos.start + relativeEnd; @@ -195,283 +226,111 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip return null; }; - const handleClick = () => { - const { state, dispatch } = editor.view; - const tr = state.tr; - - // Process each paragraph separately to avoid context issues - state.doc.forEach((paragraphNode, paragraphOffset) => { - if (paragraphNode.type.name !== 'paragraph') return; - - // Get the full text of this paragraph - let paragraphText = ''; - const textPositions: { start: number, end: number }[] = []; - - paragraphNode.content.forEach((textNode, textOffset) => { - if (textNode.isText) { - const nodeText = textNode.text || ""; // Handle undefined text - const start = paragraphOffset + 1 + textOffset; // +1 to account for paragraph tag - textPositions.push({ - start, - end: start + nodeText.length - }); - paragraphText += nodeText; - } - }); - - if (!paragraphText) return; - - // Find all valid sentences in the paragraph - const sentenceRegex = /[^.!?]+[.!?]+/g; - const sentences: { text: string, start: number, end: number }[] = []; - let match; - let lastIndex = 0; - - while ((match = sentenceRegex.exec(paragraphText)) !== null) { - sentences.push({ - text: match[0], - start: match.index, - end: sentenceRegex.lastIndex - }); - lastIndex = sentenceRegex.lastIndex; - } - - // Handle text after the last punctuation if it exists - if (lastIndex < paragraphText.length) { - sentences.push({ - text: paragraphText.slice(lastIndex), - start: lastIndex, - end: paragraphText.length - }); - } - - // For each sentence, find capitalized words NOT at the beginning and not after quotes - sentences.forEach(sentence => { - // Split into words, keeping track of positions - const words = []; - const wordRegex = /\S+/g; - let wordMatch; - - while ((wordMatch = wordRegex.exec(sentence.text)) !== null) { - words.push({ - word: wordMatch[0], - start: sentence.start + wordMatch.index, - end: sentence.start + wordRegex.lastIndex - }); - } - - if (words.length === 0) return; - - // Mark the first word as sentence start (shouldn't be bolded if capitalized) - let isFirstWord = true; - - for (let i = 0; i < words.length; i++) { - const word = words[i]; - const currentWord = word.word; - - // Check if the word starts with a capital letter - if (/^[A-ZА-ЯЁ]/u.test(currentWord)) { - // Skip if it's the first word in the sentence - if (isFirstWord) { - isFirstWord = false; - continue; - } - - // Check for various quotation patterns - let afterQuote = false; - - if (i > 0) { - const prevWord = words[i-1].word; - - // Check if previous word ends with quotation characters - // Including standard, curly, angular quotes and special formats like *«* - if (/["""«»''`*]$/.test(prevWord)) { - afterQuote = true; - } else { - // Check for whitespace + quotation mark combination in the text between words - const betweenWords = sentence.text.substring( - words[i-1].end - sentence.start, - word.start - sentence.start - ); - - // Check for standard quotes and special patterns like *«* - if (/\s["""«»''`]/.test(betweenWords) || - /\s\*[«»]\*/.test(betweenWords) || - /\s\*/.test(betweenWords)) { - afterQuote = true; - } - } - } else { - // If it's the first word but after the sentence detection, - // check if the sentence starts with a quotation mark or special pattern - const sentencePrefix = sentence.text.substring(0, word.start - sentence.start).trim(); - if (/^["""«»''`]/.test(sentencePrefix) || - /^\*[«»]\*/.test(sentencePrefix) || - /^\*/.test(sentencePrefix)) { - afterQuote = true; - } - } - - // If not after a quote, bold the word - if (!afterQuote) { - // Map the word position back to the document - const docPositions = mapWordToDocumentPositions(word, textPositions, paragraphOffset); - if (docPositions) { - tr.addMark( - docPositions.start, - docPositions.end, - state.schema.marks.bold.create() - ); - } - } - } - - // Always mark as no longer first word after processing a word - isFirstWord = false; - } - }); - }); - - dispatch(tr); - }; - return ( -
-
-
- - - - - - - - - - - - - - - +
+
+
+ + + + + + + + + + + +
+
+ +
+ {/* Добавляем отображение URL выбранного изображения */} + {selectedImage && ( +
+

+ Выбранное изображение: {selectedImage} +

+
+ )}
-
- -
-
); } \ No newline at end of file diff --git a/src/components/TipTapImageUploadButton.tsx b/src/components/TipTapImageUploadButton.tsx index 4768b45..a8ed242 100644 --- a/src/components/TipTapImageUploadButton.tsx +++ b/src/components/TipTapImageUploadButton.tsx @@ -1,12 +1,11 @@ import React, { useRef } from 'react'; -import { Image as ImageIcon } from 'lucide-react'; -import {Editor} from "@tiptap/react"; -import {imageResolutions} from "../config/imageResolutions.ts"; -import axios from "axios"; - +import { ImagePlus } from 'lucide-react'; +import { Editor } from '@tiptap/react'; +import { imageResolutions } from '../config/imageResolutions'; +import axios from 'axios'; interface TipTapImageUploadButtonProps { - editor: Editor; // Replace with your TipTap editor type + editor: Editor; articleId: string; } @@ -17,54 +16,57 @@ const TipTapImageUploadButton: React.FC = ({ edito return null; } - // Обработка выбранного изображения const handleFileChange = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; if (!file) return; try { - // Проверка разрешения изображения - const resolution = imageResolutions.find(r => r.id === 'large'); - if (!resolution) - throw new Error('Invalid resolution'); + const resolution = imageResolutions.find((r) => r.id === 'large'); + if (!resolution) throw new Error('Invalid resolution configuration'); const formData = new FormData(); formData.append('file', file); formData.append('resolutionId', resolution.id); - formData.append('folder', 'articles/' + articleId); + formData.append('folder', `articles/${articleId}`); - // Отправка запроса на сервер const response = await axios.post('/api/images/upload-url', formData, { headers: { 'Content-Type': 'multipart/form-data', - 'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации + Authorization: `Bearer ${localStorage.getItem('token')}`, }, }); - if (response.data?.fileUrl) { - editor.chain().focus().setImage({src: response.data.fileUrl}).run(); + const imageUrl = response.data?.fileUrl; + if (imageUrl) { + editor + .chain() + .focus() + .command(({ tr, dispatch }) => { + const node = editor.schema.nodes.customImage.create({ + src: imageUrl, + alt: file.name, + scale: 1, // Начальный масштаб + }); + if (dispatch) { + tr.insert(editor.state.selection.anchor, node); + dispatch(tr); + } + return true; + }) + .run(); + } else { + throw new Error('No file URL returned from server'); } } catch (error) { - console.error('Upload error:', error); + console.error('Image upload failed:', error); + alert('Не удалось загрузить изображение. Проверьте консоль для деталей.'); } -/* - const reader = new FileReader(); - reader.onload = (e) => { - const src = e.target?.result as string; - // Insert the image with the TipTap editor - editor.chain().focus().setImage({src}).run(); - }; - reader.readAsDataURL(file); -*/ - - // Reset the file input if (fileInputRef.current) { fileInputRef.current.value = ''; } }; - // Function to trigger file input click const handleButtonClick = () => { fileInputRef.current?.click(); }; @@ -77,7 +79,7 @@ const TipTapImageUploadButton: React.FC = ({ edito className="p-1 rounded hover:bg-gray-200" title="Вставить изображение" > - + = ({ edito ); }; -export default TipTapImageUploadButton; +export default TipTapImageUploadButton; \ No newline at end of file diff --git a/src/hooks/useBackgroundImage.ts b/src/hooks/useBackgroundImage.ts index a9f7bef..2c0580c 100644 --- a/src/hooks/useBackgroundImage.ts +++ b/src/hooks/useBackgroundImage.ts @@ -1,12 +1,12 @@ export const BackgroundImages: Record = { - 1: '/images/gpt_film.webp?auto=format&fit=crop&q=80&w=2070', + 1: '/images/pack/film-bn.webp?auto=format&fit=crop&q=80&w=2070', 2: '/images/gpt_theatre.webp?auto=format&fit=crop&q=80&w=2070', 3: '/images/bg-music.webp?auto=format&fit=crop&q=80&w=2070', - 4: '/images/bg-sport.webp?auto=format&fit=crop&q=80&w=2070', - 5: 'https://images.unsplash.com/photo-1547826039-bfc35e0f1ea8?auto=format&fit=crop&q=80&w=2070', + 4: '/images/pack/sport-bn.webp?auto=format&fit=crop&q=80&w=2070', + 5: '/images/pack/art-bn.webp?auto=format&fit=crop&q=80&w=2070', 6: 'https://images.unsplash.com/photo-1608376630927-31bc8a3c9ee4?auto=format&fit=crop&q=80&w=2070', 7: 'https://images.unsplash.com/photo-1530533718754-001d2668365a?auto=format&fit=crop&q=80&w=2070', 8: 'https://images.unsplash.com/photo-1494522358652-f30e61a60313?auto=format&fit=crop&q=80&w=2070', - 0: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&q=80&w=2070' + 0: 'https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8bу?auto=format&fit=crop&q=80&w=2070' }; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index aecef42..25b612b 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -30,7 +30,7 @@ export function HomePage() { const { main, sub, description } = getHeroTitle(); return ( -
+
diff --git a/src/types/image.ts b/src/types/image.ts index dc123fe..b6f5bac 100644 --- a/src/types/image.ts +++ b/src/types/image.ts @@ -18,4 +18,5 @@ export interface ImageUploadProgress { progress: number; status: 'uploading' | 'processing' | 'complete' | 'error'; error?: string; + imageUrl?: string; // Поле для URL изображения } \ No newline at end of file