diff --git a/.import/mysql_json_convert.py b/.import/mysql_json_convert.py index ddcd6d2..4778120 100644 --- a/.import/mysql_json_convert.py +++ b/.import/mysql_json_convert.py @@ -4,9 +4,9 @@ from datetime import datetime from bs4 import BeautifulSoup # Файл дампа -input_directory = "D:/__TEMP/____4/__convert" -json_file = "1-2025" -output_file = "2-2025-convert.json" +input_directory = "D:/__TEMP/____RUSS_CULT/__convert" +json_file = "1-2025-p" +output_file = "1-2025-p-convert.json" def main(): try: diff --git a/postcss.config.js b/postcss.config.js index 2aa7205..7731c9a 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,6 +1,17 @@ +import tailwindcss from 'tailwindcss'; +import autoprefixer from 'autoprefixer'; + export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, + plugins: [ + { + postcssPlugin: 'fix-from-warning', + Once(root, { result }) { + if (typeof result.opts.from === 'undefined') { + result.opts.from = 'unknown.css'; + } + }, + }, + tailwindcss, + autoprefixer, + ], }; diff --git a/src/components/ArticleForm.tsx b/src/components/ArticleForm.tsx index 1568ef5..3b621ae 100644 --- a/src/components/ArticleForm.tsx +++ b/src/components/ArticleForm.tsx @@ -33,15 +33,15 @@ interface ArticleFormProps { } export function ArticleForm({ - editingId, - articleId, - initialFormState, - onSubmit, - onCancel, - authors, - availableCategoryIds, - availableCityIds, -}: ArticleFormProps) { + editingId, + articleId, + initialFormState, + onSubmit, + onCancel, + authors, + availableCategoryIds, + availableCityIds, + }: ArticleFormProps) { const { user } = useAuthStore(); const isAdmin = user?.permissions.isAdmin || false; diff --git a/src/components/ConfirmDeleteModal.tsx b/src/components/ConfirmDeleteModal.tsx new file mode 100644 index 0000000..1f23af7 --- /dev/null +++ b/src/components/ConfirmDeleteModal.tsx @@ -0,0 +1,36 @@ +import React from 'react'; + +interface ConfirmDeleteModalProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const ConfirmDeleteModal: React.FC = ({ isOpen, onConfirm, onCancel }) => { + if (!isOpen) return null; + + return ( +
+
+

Удалить изображение?

+

Вы уверены, что хотите удалить это изображение?

+
+ + +
+
+
+ ); +}; + +export default ConfirmDeleteModal; diff --git a/src/components/CustomImageExtension.tsx b/src/components/CustomImageExtension.tsx index e3ba487..72cbd8d 100644 --- a/src/components/CustomImageExtension.tsx +++ b/src/components/CustomImageExtension.tsx @@ -1,178 +1,154 @@ -import React from 'react'; -import {mergeAttributes, Node, NodeViewProps } from '@tiptap/core'; -import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react'; -import {Trash2, ZoomIn, ZoomOut} from 'lucide-react'; -import {Node as ProseMirrorNode, DOMOutputSpec} from 'prosemirror-model'; +import React, { useState, useRef, useEffect } from 'react'; +import { mergeAttributes, Node, NodeViewProps } from '@tiptap/core'; +import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'; +import { GripVertical, Trash2, ZoomIn, ZoomOut } from 'lucide-react'; +import { Node as ProseMirrorNode, DOMOutputSpec } from 'prosemirror-model'; +import ConfirmDeleteModal from "./ConfirmDeleteModal"; -// Определяем тип для атрибутов узла customImage interface CustomImageAttributes { src: string | undefined; alt: string | undefined; title: string | undefined; scale: number; - caption: string; // Добавляем атрибут caption + caption: string; } -const ImageNodeView: React.FC = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => { - // Утверждаем, что node имеет нужный тип +const ImageNodeView: React.FC = ({ node, editor, getPos, selected, deleteNode }) => { const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes }; - // Проверяем, что узел корректен - if (node.type.name !== 'customImage') { - console.error('Неподдерживаемый тип узла:', node.type.name); - return Ошибка: неподдерживаемый тип узла; - } + const scale = typedNode.attrs.scale || 1; + const caption = typedNode.attrs.caption || ''; + const isEditable = editor.isEditable; - // Проверяем, что src существует - if (!typedNode.attrs.src) { - console.error('Изображение не загружено, src отсутствует:', typedNode.attrs); - return Ошибка: изображение не загружено; - } + const captionRef = useRef(null); - // Логируем атрибуты узла при загрузке - //console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs); + useEffect(() => { + if (captionRef.current && caption.trim() !== captionRef.current.innerText.trim()) { + captionRef.current.innerText = caption; + } + }, [caption]); + + const [isConfirmModalOpen, setConfirmModalOpen] = useState(false); 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); - } + editor.commands.updateAttributes('customImage', { + scale: Math.min(scale + 0.1, 2), + }); }; 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); - } + editor.commands.updateAttributes('customImage', { + scale: Math.max(scale - 0.1, 0.5), + }); }; const handleDelete = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - try { - deleteNode(); - } catch (error) { - console.error('Ошибка при удалении изображения:', error); + setConfirmModalOpen(true); + }; + + const handleCaptionBlur = (e: React.FocusEvent) => { + const newCaption = e.currentTarget.innerText; + if (newCaption !== caption) { + editor.commands.command(({ tr, state }) => { + const pos = getPos(); + if (pos == null) return false; + + const nodeAtPos = state.doc.nodeAt(pos); + if (!nodeAtPos || nodeAtPos.type.name !== 'customImage') { + return false; + } + + tr.setNodeMarkup(pos, undefined, { + ...node.attrs, + caption: newCaption, + }); + editor.view.dispatch(tr); + + editor.emit('update', { + editor, + transaction: tr, + }); + + return true; + }); } }; - const scale = typedNode.attrs.scale || 1; - const caption = typedNode.attrs.caption || ''; - const isEditable = editor.isEditable; // Проверяем, находится ли редактор в режиме редактирования - return ( - // +
+ {isEditable && ( +
+ +
+ )} - {/* Оборачиваем изображение и панель в контейнер, который масштабируется */} -
- {typedNode.attrs.alt} - {selected && isEditable && ( // Показываем панель только в режиме редактирования -
- - - - - Масштаб: {scale} - + {typedNode.attrs.alt} + + {selected && isEditable && ( +
+ + + +
+ {(scale * 100).toFixed(0)}% +
+
+ )} + +
+
+
+
+
- )} +
- {caption && ( -

- {caption} -

- )} + + { + deleteNode(); + setConfirmModalOpen(false); + }} + onCancel={() => setConfirmModalOpen(false)} + />
); }; @@ -187,38 +163,29 @@ export const CustomImage = Node.create({ addAttributes() { return { - src: { - default: undefined, - }, - alt: { - default: undefined, - }, - title: { - default: undefined, - }, + src: { default: undefined }, + alt: { default: undefined }, + title: { default: undefined }, scale: { default: 1, parseHTML: (element) => { - const scale = Number(element.getAttribute('scale')); - return isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2)))); - }, - 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)))); - return { scale: renderedScale }; + const img = element.querySelector('img'); + return img ? Number(img.getAttribute('data-scale')) || 1 : 1; }, + renderHTML: (attributes) => ({ + 'data-scale': Number(attributes.scale) || 1, + }), }, - caption: { // Добавляем атрибут caption + caption: { default: '', - parseHTML: (element) => element.getAttribute('data-caption') || '', - renderHTML: (attributes) => { - if (!attributes.caption) { - return {}; - } - return { - 'data-caption': attributes.caption, - }; + parseHTML: (element) => { + const captionElement = element.querySelector('div.image-caption'); + const img = element.querySelector('img'); + return captionElement + ? captionElement.getAttribute('data-caption') || captionElement.textContent || '' + : img?.getAttribute('data-caption') || ''; }, + renderHTML: (attributes) => (attributes.caption ? { 'data-caption': attributes.caption } : {}), }, }; }, @@ -228,56 +195,58 @@ export const CustomImage = Node.create({ { tag: 'div.image-container', getAttrs: (element: HTMLElement) => { + if (!(element instanceof HTMLElement)) return null; const img = element.querySelector('img'); - const caption = element.querySelector('p.image-caption')?.textContent || ''; + const captionElement = element.querySelector('div.image-caption'); + if (!img) return null; return { - src: img?.getAttribute('src'), - alt: img?.getAttribute('alt'), - title: img?.getAttribute('title'), - scale: img ? Number(img.getAttribute('scale')) || 1 : 1, - caption, - }; - }, - }, - { - tag: 'img[src]', - getAttrs: (element) => { - const scale = element.getAttribute('scale'); - const caption = element.getAttribute('data-caption') || ''; - return { - src: element.getAttribute('src'), - alt: element.getAttribute('alt'), - title: element.getAttribute('title'), - scale: scale ? Number(scale) : 1, - caption, + src: img.getAttribute('src') || undefined, + alt: img.getAttribute('alt') || undefined, + title: img.getAttribute('title') || undefined, + scale: Number(img.getAttribute('data-scale')) || 1, + caption: captionElement + ? captionElement.getAttribute('data-caption') || captionElement.textContent || '' + : img.getAttribute('data-caption') || '', }; }, }, ]; }, - renderHTML({ HTMLAttributes }): DOMOutputSpec { - const { scale, caption, ...rest } = HTMLAttributes; + renderHTML({ node, HTMLAttributes }) { + const { src, alt, title, scale, caption } = node.attrs; + const actualScale = Number(scale) || 1; - const imgElement: DOMOutputSpec = [ + const imageElement: DOMOutputSpec = [ 'img', - mergeAttributes(rest, { - scale: scale, - 'data-caption': caption, - style: `transform: scale(${scale})`, + mergeAttributes(HTMLAttributes, { + src, + alt, + title, + 'data-scale': actualScale, + style: `width: ${actualScale * 100}%`, }), ]; - const children: DOMOutputSpec[] = [imgElement]; + const captionElement: DOMOutputSpec = [ + 'div', + { + class: 'image-caption w-full text-sm font-bold text-gray-600 italic text-center mt-2', + 'data-caption': caption, + 'data-placeholder': 'Введите подпись...', + }, + caption, + ]; - if (caption) { - children.push(['p', { class: 'image-caption mt-2 text-sm text-gray-600 italic text-center' }, caption]); - } - - return ['div', { class: 'image-container flex flex-col items-center my-4' }, ...children]; + return [ + 'div', + { class: 'image-container flex flex-col items-center my-4' }, + imageElement, + caption ? captionElement : '', + ]; }, addNodeView() { return ReactNodeViewRenderer(ImageNodeView); }, -}); \ No newline at end of file +}); diff --git a/src/components/Test/CustomImage.ts b/src/components/Test/CustomImage.ts new file mode 100644 index 0000000..10f1f81 --- /dev/null +++ b/src/components/Test/CustomImage.ts @@ -0,0 +1,90 @@ +import { Node, mergeAttributes, Command, CommandProps } from '@tiptap/core' +import { ReactNodeViewRenderer } from '@tiptap/react' +import ImageNodeView from './ImageNodeView' + +export interface CustomImageOptions { + HTMLAttributes: Record +} + +declare module '@tiptap/core' { + interface Commands { + customImage: { + /** + * Insert a custom image node with optional initial caption. + */ + insertCustomImage: (options: { + src: string + alt?: string + scale?: number + caption?: string + }) => ReturnType + } + } +} + +export const CustomImage = Node.create({ + name: 'customImage', + group: 'block', + draggable: true, + inline: false, + atom: true, // we handle our own caption, toolbar, etc. + addOptions() { + return { + HTMLAttributes: {}, + } + }, + addAttributes() { + return { + src: { + default: '', + }, + alt: { + default: null, + }, + scale: { + default: 1, + }, + caption: { + default: '', + }, + } + }, + parseHTML() { + return [ + { + tag: 'figure[data-type="customImage"]', + }, + ] + }, + renderHTML({ HTMLAttributes }) { + return [ + 'figure', + mergeAttributes(HTMLAttributes, { 'data-type': 'customImage', class: 'relative' }), + ['img', { src: HTMLAttributes.src, alt: HTMLAttributes.alt }], + ['figcaption', { class: 'text-sm text-gray-500 mt-2' }, 0], + ] + }, + addCommands() { + return { + insertCustomImage: + (options) => + ({ commands }) => { + return commands.insertContent({ + type: this.name, + attrs: { + src: options.src, + alt: options.alt, + scale: options.scale ?? 1, + caption: options.caption ?? '', + }, + content: options.caption + ? [{ type: 'text', text: options.caption }] + : [], + }) + }, + } + }, + addNodeView() { + return ReactNodeViewRenderer(ImageNodeView) + }, +}) \ No newline at end of file diff --git a/src/components/Test/ImageInsertModal.tsx b/src/components/Test/ImageInsertModal.tsx new file mode 100644 index 0000000..758bee2 --- /dev/null +++ b/src/components/Test/ImageInsertModal.tsx @@ -0,0 +1,83 @@ +import React, { useState, useRef, ChangeEvent } from 'react' +import { Editor } from '@tiptap/react' +import { X } from 'lucide-react' + +interface ImageInsertModalProps { + editor: Editor | null + open: boolean + onClose: () => void +} + +const ImageInsertModal: React.FC = ({ editor, open, onClose }) => { + const [file, setFile] = useState(null) + const [caption, setCaption] = useState('') + const fileInputRef = useRef(null) + + if (!open) return null + + const handleFileChange = (e: ChangeEvent) => { + setFile(e.target.files?.[0] ?? null) + } + + const handleInsert = () => { + if (!file || !editor) return + + const url = URL.createObjectURL(file) + editor + .chain() + .focus() + .insertCustomImage({ src: url, caption }) + .run() + + setFile(null) + setCaption('') + onClose() + } + + return ( +
+
+ +

Insert Image

+
+
+ + +
+
+ + setCaption(e.target.value)} + placeholder="Type caption…" + className="block w-full border border-gray-300 rounded px-2 py-1 text-sm outline-none focus:ring-2 focus:ring-blue-400" + /> +
+ +
+
+
+ ) +} + +export default ImageInsertModal diff --git a/src/components/Test/ImageNodeView.tsx b/src/components/Test/ImageNodeView.tsx new file mode 100644 index 0000000..235fc8c --- /dev/null +++ b/src/components/Test/ImageNodeView.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react' +import { NodeViewWrapper, NodeViewContent, NodeViewProps } from '@tiptap/react' +import { ZoomIn, ZoomOut, Trash2 } from 'lucide-react' + +const MIN_SCALE = 0.5 +const MAX_SCALE = 2.0 +const SCALE_STEP = 0.1 + +const ImageNodeView: React.FC = ({ + node, + updateAttributes, + deleteNode, + editor, + }) => { + const [showControls, setShowControls] = useState(false) + + const scale = node.attrs.scale as number + + const handleZoomIn = () => { + const next = Math.min(MAX_SCALE, scale + SCALE_STEP) + updateAttributes({ scale: next }) + } + + const handleZoomOut = () => { + const next = Math.max(MIN_SCALE, scale - SCALE_STEP) + updateAttributes({ scale: next }) + } + + const handleDelete = () => { + deleteNode() + } + + return ( + setShowControls(true)} + onMouseLeave={() => setShowControls(false)} + style={{ display: 'inline-block' }} + > + {node.attrs.alt + + {showControls && ( +
+ + + +
+ )} + +
+ +
+
+ ) +} + +export default ImageNodeView diff --git a/src/index.css b/src/index.css index c24eaa6..2864eeb 100644 --- a/src/index.css +++ b/src/index.css @@ -22,13 +22,24 @@ } div.image-container { - @apply flex flex-col items-center my-4; + @apply flex flex-col items-center my-4 !important; } div.image-container img { @apply max-w-full h-auto; } + div.image-container .image-caption { + @apply text-sm text-gray-600 italic text-center max-w-[80%]; + } + + /* */ + div.image-container .absolute { + position: absolute !important; + left: 0 !important; + right: 0 !important; + } + p.image-caption { @apply mt-2 text-lg text-gray-600 italic text-center max-w-[80%]; } diff --git a/src/pages/ImportArticlesPage.tsx b/src/pages/ImportArticlesPage.tsx index 43d6271..7393918 100644 --- a/src/pages/ImportArticlesPage.tsx +++ b/src/pages/ImportArticlesPage.tsx @@ -32,7 +32,7 @@ export function ImportArticlesPage() { const jsonData = JSON.parse(text as string); if (Array.isArray(jsonData)) { setArticles(jsonData); - setCurrentPage(1); // Сброс на первую страницу + setCurrentPage(1); setError(null); } else { throw new Error('Неправильный формат JSON. Ожидается массив статей.'); @@ -44,7 +44,7 @@ export function ImportArticlesPage() { reader.readAsText(file); }; - const handleEditField = (articleId: string, field: keyof Article, value: any) => { + const handleEditField = (articleId: string, field: K, value: Article[K]) => { setArticles(prev => prev.map(article => article.id === articleId ? { ...article, [field]: value } : article )); @@ -71,7 +71,7 @@ export function ImportArticlesPage() { setError(null); alert('Статьи успешно сохранены'); - } catch { + } catch { setError('Не удалось сохранить статьи. Попробуйте снова.'); } finally { setIsSaving(false); @@ -85,9 +85,7 @@ export function ImportArticlesPage() {
-

- Импорт статей -

+

Импорт статей

-
handleEditField(article.id, 'excerpt', e.target.value)} className="block w-full text-sm text-gray-500 border-0 focus:ring-0 p-0" /> -
- - {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read - +
+ {CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
+ + {(article.images?.length || 0) > 0 && ( +
+

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

+
+ {article.images?.map((imageUrl, index) => ( +
+ {`image-${index + { + const newSubs = [...(article.imageSubs || [])]; + newSubs[index] = e.target.value; + handleEditField(article.id, 'imageSubs', newSubs); + }} + placeholder={`Подпись ${index + 1}`} + className="w-full border border-gray-300 rounded px-2 py-1 text-sm" + /> +
+ ))} +
+
+ )}
@@ -166,22 +188,6 @@ export function ImportArticlesPage() { {/* Pagination */} {totalPages > 1 && (
-
- - -

@@ -193,7 +199,7 @@ export function ImportArticlesPage() {

); -} \ No newline at end of file +} diff --git a/src/pages/UserManagementPage.tsx b/src/pages/UserManagementPage.tsx index 3d5be62..0ddf42f 100644 --- a/src/pages/UserManagementPage.tsx +++ b/src/pages/UserManagementPage.tsx @@ -86,6 +86,8 @@ export function UserManagementPage() { } }; + const isAttributionOnly = formData.attributionOnly || (!selectedUser?.email && !selectedUser?.permissions.isAdmin); + if (loading) { return (
@@ -143,7 +145,7 @@ export function UserManagementPage() { />
{user.displayName}
-
{user.email}
+
{user.email || 'Без входа'}
{/* Permissions Editor */} - {selectedUser && ( + {selectedUser && selectedUser.email && (

Редактирование прав пользователя "{selectedUser.displayName}" @@ -312,34 +315,51 @@ export function UserManagementPage() { />

-
- +
setFormData(prev => ({ ...prev, email: e.target.value }))} - autoComplete="off" - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" - required + type="checkbox" + id="attributionOnly" + checked={formData.attributionOnly} + onChange={(e) => setFormData(prev => ({ ...prev, attributionOnly: e.target.checked }))} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" /> +
-
- - setFormData(prev => ({ ...prev, password: e.target.value }))} - autoComplete="new-password" - placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''} - className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" - required={!showEditModal} - /> -
+ {!isAttributionOnly && ( + <> +
+ + setFormData(prev => ({ ...prev, email: e.target.value }))} + autoComplete="off" + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + required={!isAttributionOnly} + /> +
+ +
+ + setFormData(prev => ({ ...prev, password: e.target.value }))} + autoComplete="new-password" + placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''} + className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500" + required={!isAttributionOnly && !showEditModal} + /> +
+ + )}