Теперь работает вставка изображения в редактор с масштабированием.

This commit is contained in:
anibilag 2025-04-06 00:20:12 +03:00
parent a08eb412e4
commit 5a2bc6dbd3
16 changed files with 641 additions and 547 deletions

15
package-lock.json generated
View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@ -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, // Контент только для чтения

View File

@ -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<string | null>(null);
const [hasChanges, setHasChanges] = useState<boolean>(false);
const [isInitialLoad, setIsInitialLoad] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState<boolean>(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,
};
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 (
<div className="min-h-screen flex items-center justify-center">
@ -184,7 +248,9 @@ export function ArticleForm({
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Категория</span></label>
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Категория</span>
</label>
<select
id="category"
value={categoryId}
@ -195,7 +261,9 @@ export function ArticleForm({
</select>
</div>
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Столица</span></label>
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Столица</span>
</label>
<select
id="city"
value={cityId}
@ -206,7 +274,9 @@ export function ArticleForm({
</select>
</div>
<div>
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Время чтения (минуты)</span></label>
<label htmlFor="readTime" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Время чтения (минуты)</span>
</label>
<input
type="number"
id="readTime"
@ -218,14 +288,19 @@ export function ArticleForm({
</div>
{editingId && isAdmin && (
<div>
<label htmlFor="author" className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Автор</span></label>
<label htmlFor="author" className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Автор</span>
</label>
<select
id="author"
value={authorId}
onChange={(e) => setAuthorId(e.target.value)}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
>
{authors.map(author => <option key={author.id} value={author.id}>{author.displayName}</option>)}
{authors.map(author =>
<option key={author.id} value={author.id}>
{author.displayName}
</option>)}
</select>
</div>
)}
@ -238,14 +313,27 @@ export function ArticleForm({
allowUpload={!!editingId}
/>
<div>
<label className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Статья</span></label>
<TipTapEditor initialContent={content} onContentChange={setContent} atricleId={articleId} />
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Статья</span>
</label>
<TipTapEditor
initialContent={content}
onContentChange={setContent}
articleId={articleId}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700"><span className="italic font-bold">Галерея</span></label>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Галерея</span>
</label>
{editingId ? (
<>
<ImageUploader onUploadComplete={(imageUrl) => { setFormNewImageUrl(imageUrl); }} articleId={articleId} />
<ImageUploader
onUploadComplete={(imageUrl) => {
setFormNewImageUrl(imageUrl);
}}
articleId={articleId}
/>
<GalleryManager
images={displayedImages}
imageUrl={formNewImageUrl}
@ -282,7 +370,7 @@ export function ArticleForm({
<div className="flex justify-end gap-4">
<button
type="button"
onClick={onCancel}
onClick={handleCancel}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Отмена
@ -291,21 +379,27 @@ export function ArticleForm({
<button
type="button"
onClick={handleApply}
disabled={!hasChanges}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
disabled={!hasChanges || isSubmitting}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500`}
>
Применить
</button>
)}
<button
type="submit"
disabled={!hasChanges}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
disabled={!hasChanges || isSubmitting}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${hasChanges && !isSubmitting ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'} focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500`}
>
{editingId ? 'Изменить' : 'Сохранить черновик'}
</button>
</div>
</form>
<ConfirmModal
isOpen={isConfirmModalOpen}
onConfirm={handleConfirmCancel}
onCancel={handleCloseModal}
message="У вас есть несохранённые изменения. Вы уверены, что хотите отменить?"
/>
</div>
);
}

View File

@ -0,0 +1,36 @@
import React from 'react';
interface ConfirmModalProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
message: string;
}
const ConfirmModal: React.FC<ConfirmModalProps> = ({ isOpen, onConfirm, onCancel, message }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-sm w-full">
<p className="text-gray-700 mb-4">{message}</p>
<div className="flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none"
>
Отмена
</button>
<button
onClick={onConfirm}
className="px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none"
>
Подтвердить
</button>
</div>
</div>
</div>
);
};
export default ConfirmModal;

View File

@ -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<string, number>) => 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<ImageNodeViewProps> = ({ node, updateAttributes, editor, getPos }) => {
const [isSelected, setIsSelected] = useState(false);
const [scale, setScale] = useState(1);
const imageRef = useRef<HTMLImageElement>(null);
const ImageNodeView: React.FC<NodeViewProps> = ({ 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();
// Check if this node is selected
const isNodeSelected = selection.$anchor.pos >= nodePos &&
selection.$anchor.pos <= nodePos + node.nodeSize;
setIsSelected(isNodeSelected);
};
editor.on('selectionUpdate', handleSelectionUpdate);
return () => {
editor.off('selectionUpdate', handleSelectionUpdate);
};
}, [editor, getPos, node.nodeSize]);
// Handle zoom in
const handleZoomIn = (e: React.MouseEvent) => {
e.stopPropagation();
setScale(prev => Math.min(prev + 0.1, 2));
updateAttributes({ scale: Math.min(scale + 0.1, 2) });
};
// 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) });
};
// Track current scale from attributes
useEffect(() => {
if (node.attrs.scale) {
setScale(node.attrs.scale);
// Проверяем, что узел корректен
if (node.type.name !== 'customImage') {
console.error('Неподдерживаемый тип узла:', node.type.name);
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: неподдерживаемый тип узла</NodeViewWrapper>;
}
}, [node.attrs.scale]);
// Проверяем, что src существует
if (!typedNode.attrs.src) {
console.error('Изображение не загружено, src отсутствует:', typedNode.attrs);
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: изображение не загружено</NodeViewWrapper>;
}
// Логируем атрибуты узла при загрузке
console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs);
const handleZoomIn = (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.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);
}
};
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 (
<div className="relative flex justify-center my-4">
{/* The actual image */}
<img
ref={imageRef}
src={node.attrs.src}
alt={node.attrs.alt || ''}
className={`max-w-full transition-transform ${isSelected ? 'ring-2 ring-blue-500' : ''}`}
<NodeViewWrapper className="relative flex justify-center my-4">
{/* Оборачиваем изображение и панель в контейнер, который масштабируется */}
<div
className="relative inline-block"
style={{
transform: `scale(${scale})`,
transformOrigin: 'center',
}}
>
<img
src={typedNode.attrs.src}
alt={typedNode.attrs.alt}
className={`max-w-full transition-transform ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
data-drag-handle=""
/>
{/* Zoom controls - only visible when selected */}
{isSelected && (
<div className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow">
{selected && isEditable && ( // Показываем панель только в режиме редактирования
<div
className="absolute top-2 right-2 flex space-x-2 bg-white bg-opacity-75 p-1 rounded shadow"
style={{
transform: `scale(${1 / scale})`, // Компенсируем масштабирование панели
transformOrigin: 'top right', // Устанавливаем точку масштабирования в верхний правый угол
}}
>
<button
type="button"
onClick={handleZoomIn}
className="p-1 rounded hover:bg-gray-200"
title="Zoom in"
title={`Zoom in (current scale: ${scale})`}
>
<ZoomIn size={16} />
</button>
<button
type="button"
onClick={handleZoomOut}
className="p-1 rounded hover:bg-gray-200"
title="Zoom out"
title={`Zoom out (current scale: ${scale})`}
>
<ZoomOut size={16} />
</button>
<button
type="button"
onClick={handleDelete}
className="p-1 rounded hover:bg-red-200 text-red-600"
title="Delete image"
>
<Trash2 size={16} />
</button>
<span className="text-xs text-gray-600 px-2 py-1">
Масштаб: {scale}
</span>
</div>
)}
</div>
</NodeViewWrapper>
);
};
// 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,7 +229,13 @@ 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})`,
}),
],
];
},

View File

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

View File

@ -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) {
<div className="flex items-center space-x-4 p-4 bg-gray-50 rounded-lg">
{getStatusIcon()}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{fileName}
</p>
{progress.status === 'complete' && progress.imageUrl ? (
<div className="relative w-48 h-32 rounded-lg overflow-hidden group">
<img
src={progress.imageUrl}
alt="Превью загруженного изображения"
className="w-full h-full object-cover rounded-md"
/>
</div>
) : (
<p className="text-sm font-medium text-gray-900 truncate">{fileName}</p>
)}
<p className="text-sm text-gray-500">{getStatusText()}</p>
</div>
{(progress.status === 'uploading' || progress.status === 'processing') && (

View File

@ -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<string | null>(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,146 +226,6 @@ 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 (
<div className="border rounded-lg overflow-hidden">
<div className="border-b bg-gray-50 px-4 py-2">
@ -342,9 +233,7 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
<button
type="button"
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('bold') ? 'bg-gray-200' : ''
}`}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bold') ? 'bg-gray-200' : ''}`}
title="Выделение слов жирным шрифтом"
>
<Bold size={18} />
@ -360,9 +249,7 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
<button
type="button"
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('italic') ? 'bg-gray-200' : ''
}`}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('italic') ? 'bg-gray-200' : ''}`}
title="Выделение слов наклонным шрифтом"
>
<Italic size={18} />
@ -396,9 +283,7 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
<button
type="button"
onClick={() => editor?.chain().focus().toggleBulletList().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('bulletList') ? 'bg-gray-200' : ''
}`}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('bulletList') ? 'bg-gray-200' : ''}`}
title="Создать список"
>
<List size={18} />
@ -406,28 +291,22 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
<button
type="button"
onClick={() => editor?.chain().focus().toggleOrderedList().run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('orderedList') ? 'bg-gray-200' : ''
}`}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('orderedList') ? 'bg-gray-200' : ''}`}
title="Создать упорядоченный список"
>
<ListOrdered size={18} />
</button>
<button
type="button"
onClick={() => {
editor?.chain().focus().toggleBlockquote().run()}}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.isActive('blockquote') ? 'bg-gray-200' : ''
}`}
onClick={() => editor?.chain().focus().toggleBlockquote().run()}
className={`p-1 rounded hover:bg-gray-200 ${editor?.isActive('blockquote') ? 'bg-gray-200' : ''}`}
title="Цитирование (параграф)"
>
<Quote size={18} />
</button>
<button
type="button"
onClick={() => {
editor?.chain().focus().setTextAlign('center').run()}}
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
}`}
@ -435,43 +314,23 @@ export function TipTapEditor({ initialContent, onContentChange, atricleId }: Tip
>
<AlignCenter size={18} />
</button>
<button
type="button"
onClick={() => {
const url = prompt('Введите URL изображения');
if (url) {
editor?.chain().focus().setImage({ src: url }).run();
}
}}
title="Вставить изображение (старое)"
>
<ImagePlus size={18} />
</button>
<TipTapImageUploadButton editor={editor} articleId={atricleId} />
<button
type="button"
onClick={() => editor?.chain().focus().setTextAlign('center').run()}
className={`p-1 rounded hover:bg-gray-200 ${
editor?.getAttributes('textAlign')?.textAlign === 'center' ? 'bg-gray-200' : ''
}`}
>
🏛 Центр
</button>
<button
type="button"
onClick={() => resizeImage(20)} className="p-1 rounded hover:bg-gray-200">
<Plus size={18} />
</button>
<button
type="button"
onClick={() => resizeImage(-20)} className="p-1 rounded hover:bg-gray-200">
<Minus size={18} />
</button>
<TipTapImageUploadButton
editor={editor}
articleId={articleId}
/>
</div>
</div>
<div className="p-4">
<EditorContent editor={editor} />
</div>
{/* Добавляем отображение URL выбранного изображения */}
{selectedImage && (
<div className="p-4 border-t bg-gray-50">
<p className="text-sm text-gray-600">
Выбранное изображение: <a href={selectedImage} target="_blank" rel="noopener noreferrer" className="text-blue-500">{selectedImage}</a>
</p>
</div>
)}
</div>
);
}

View File

@ -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<TipTapImageUploadButtonProps> = ({ edito
return null;
}
// Обработка выбранного изображения
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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<TipTapImageUploadButtonProps> = ({ edito
className="p-1 rounded hover:bg-gray-200"
title="Вставить изображение"
>
<ImageIcon size={18} />
<ImagePlus size={18} />
</button>
<input
ref={fileInputRef}

View File

@ -1,12 +1,12 @@
export const BackgroundImages: Record<number, string> = {
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'
};

View File

@ -30,7 +30,7 @@ export function HomePage() {
const { main, sub, description } = getHeroTitle();
return (
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/main-bg.jpg')` }}>
<div className="min-h-screen bg-cover bg-center bg-fixed bg-white/95" style={{ backgroundImage: `url('/images/gpt_main-bg.webp')` }}>
<Header />
<main>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">

View File

@ -18,4 +18,5 @@ export interface ImageUploadProgress {
progress: number;
status: 'uploading' | 'processing' | 'complete' | 'error';
error?: string;
imageUrl?: string; // Поле для URL изображения
}