Доработана вставка изображений с подписью. При импорте статей можно посмотреть их изображения и подписи, можно редактировать подписи. Создание пользователей без права входа в админку.
This commit is contained in:
parent
12b0011d57
commit
4663e0a300
@ -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:
|
||||
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
@ -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;
|
||||
|
||||
|
36
src/components/ConfirmDeleteModal.tsx
Normal file
36
src/components/ConfirmDeleteModal.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ConfirmDeleteModalProps {
|
||||
isOpen: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ isOpen, onConfirm, onCancel }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow p-6 w-80 text-center space-y-4">
|
||||
<h2 className="text-lg font-bold">Удалить изображение?</h2>
|
||||
<p className="text-gray-600 text-sm">Вы уверены, что хотите удалить это изображение?</p>
|
||||
<div className="flex justify-center space-x-4 mt-4">
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600"
|
||||
>
|
||||
Да, удалить
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmDeleteModal;
|
@ -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<NodeViewProps> = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => {
|
||||
// Утверждаем, что node имеет нужный тип
|
||||
const ImageNodeView: React.FC<NodeViewProps> = ({ node, editor, getPos, selected, deleteNode }) => {
|
||||
const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
|
||||
|
||||
// Проверяем, что узел корректен
|
||||
if (node.type.name !== 'customImage') {
|
||||
console.error('Неподдерживаемый тип узла:', node.type.name);
|
||||
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: неподдерживаемый тип узла</NodeViewWrapper>;
|
||||
}
|
||||
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 <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: изображение не загружено</NodeViewWrapper>;
|
||||
}
|
||||
const captionRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||
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 (
|
||||
//<NodeViewWrapper className="relative flex justify-center my-4">
|
||||
<NodeViewWrapper className="relative flex flex-col items-center my-4">
|
||||
<div className="flex w-full items-start">
|
||||
{isEditable && (
|
||||
<div
|
||||
className="drag-handle cursor-move mr-2 text-gray-400 hover:text-gray-600"
|
||||
data-drag-handle=""
|
||||
title="Перетащить"
|
||||
>
|
||||
<GripVertical size={16} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Оборачиваем изображение и панель в контейнер, который масштабируется */}
|
||||
<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=""
|
||||
/>
|
||||
{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 (current scale: ${scale})`}
|
||||
<div className="flex-1">
|
||||
<div className="flex flex-col items-center w-full">
|
||||
<div
|
||||
className="relative flex flex-col items-center"
|
||||
style={{ width: `${scale * 100}%`, transition: 'width 0.2s' }}
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 rounded hover:bg-gray-200"
|
||||
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>
|
||||
<img
|
||||
src={typedNode.attrs.src}
|
||||
alt={typedNode.attrs.alt}
|
||||
title={typedNode.attrs.title}
|
||||
className={`max-w-full ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
|
||||
data-scale={scale}
|
||||
/>
|
||||
|
||||
{selected && isEditable && (
|
||||
<div className="absolute top-2 right-2 flex items-center space-x-2 bg-white bg-opacity-80 p-1 rounded shadow z-10">
|
||||
<button type="button" onClick={handleZoomIn} className="p-1 rounded hover:bg-gray-200" title="Увеличить">
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={handleZoomOut} className="p-1 rounded hover:bg-gray-200" title="Уменьшить">
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<button type="button" onClick={handleDelete} className="p-1 rounded hover:bg-red-200 text-red-600" title="Удалить изображение">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
<div className="text-xs text-gray-500 ml-2">
|
||||
{(scale * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-full mt-2">
|
||||
<div
|
||||
ref={captionRef}
|
||||
className="image-caption w-[80%] mx-auto text-base font-bold text-gray-600 italic text-center outline-none relative empty:before:content-[attr(data-placeholder)] empty:before:text-gray-400 empty:before:absolute empty:before:top-1/2 empty:before:left-0 empty:before:-translate-y-1/2 empty:before:pl-4 empty:before:text-left empty:before:pointer-events-none empty:before:select-none"
|
||||
contentEditable={isEditable}
|
||||
suppressContentEditableWarning
|
||||
onBlur={handleCaptionBlur}
|
||||
data-caption={caption}
|
||||
data-placeholder="Введите подпись..."
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{caption && (
|
||||
<p className="image-caption mt-2 text-sm text-gray-600 italic text-center">
|
||||
{caption}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
onConfirm={() => {
|
||||
deleteNode();
|
||||
setConfirmModalOpen(false);
|
||||
}}
|
||||
onCancel={() => setConfirmModalOpen(false)}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
90
src/components/Test/CustomImage.ts
Normal file
90
src/components/Test/CustomImage.ts
Normal file
@ -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<string, any>
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
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<CustomImageOptions>({
|
||||
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)
|
||||
},
|
||||
})
|
83
src/components/Test/ImageInsertModal.tsx
Normal file
83
src/components/Test/ImageInsertModal.tsx
Normal file
@ -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<ImageInsertModalProps> = ({ editor, open, onClose }) => {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [caption, setCaption] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
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 (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-96 relative">
|
||||
<button
|
||||
className="absolute top-2 right-2 text-gray-600 hover:text-gray-900"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
<h2 className="text-lg font-medium mb-4">Insert Image</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Image File</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="block w-full text-sm text-gray-700"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Caption (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={caption}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
disabled={!file}
|
||||
onClick={handleInsert}
|
||||
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageInsertModal
|
88
src/components/Test/ImageNodeView.tsx
Normal file
88
src/components/Test/ImageNodeView.tsx
Normal file
@ -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<NodeViewProps> = ({
|
||||
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 (
|
||||
<NodeViewWrapper
|
||||
as="figure"
|
||||
className="relative mx-auto"
|
||||
onMouseEnter={() => setShowControls(true)}
|
||||
onMouseLeave={() => setShowControls(false)}
|
||||
style={{ display: 'inline-block' }}
|
||||
>
|
||||
<img
|
||||
src={node.attrs.src}
|
||||
alt={node.attrs.alt || ''}
|
||||
className="block"
|
||||
style={{
|
||||
transform: `scale(${scale})`,
|
||||
transformOrigin: 'center center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{showControls && (
|
||||
<div className="absolute top-1 right-1 flex space-x-1 bg-white bg-opacity-80 rounded shadow">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-1 hover:bg-gray-200 rounded"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1 hover:bg-red-200 rounded"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<figcaption className="mt-2 text-sm text-gray-500">
|
||||
<NodeViewContent
|
||||
as="div"
|
||||
className="outline-none"
|
||||
contentEditable
|
||||
/>
|
||||
</figcaption>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageNodeView
|
@ -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%];
|
||||
}
|
||||
|
@ -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 = <K extends keyof Article>(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() {
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Импорт статей
|
||||
</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Импорт статей</h1>
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
@ -137,14 +135,12 @@ export function ImportArticlesPage() {
|
||||
onChange={(e) => handleEditField(article.id, 'title', e.target.value)}
|
||||
className="block w-full text-sm font-medium text-gray-900 border-0 focus:ring-0 p-0"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={() => setEditingArticle(article)}
|
||||
className="ml-4 p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
@ -152,11 +148,37 @@ export function ImportArticlesPage() {
|
||||
onChange={(e) => handleEditField(article.id, 'excerpt', e.target.value)}
|
||||
className="block w-full text-sm text-gray-500 border-0 focus:ring-0 p-0"
|
||||
/>
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<span className="truncate">
|
||||
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
|
||||
</span>
|
||||
<div className="text-sm text-gray-500">
|
||||
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
|
||||
</div>
|
||||
|
||||
{(article.images?.length || 0) > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Изображения с подписями:</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
|
||||
{article.images?.map((imageUrl, index) => (
|
||||
<div key={index} className="border rounded-md p-2 bg-gray-50">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={`image-${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-md mb-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={article.imageSubs?.[index] || ''}
|
||||
onChange={(e) => {
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@ -166,22 +188,6 @@ export function ImportArticlesPage() {
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
@ -193,7 +199,7 @@ export function ImportArticlesPage() {
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
||||
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
@ -214,7 +220,7 @@ export function ImportArticlesPage() {
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
||||
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50"
|
||||
>
|
||||
@ -240,4 +246,4 @@ export function ImportArticlesPage() {
|
||||
</div>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -86,6 +86,8 @@ export function UserManagementPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const isAttributionOnly = formData.attributionOnly || (!selectedUser?.email && !selectedUser?.permissions.isAdmin);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
@ -143,7 +145,7 @@ export function UserManagementPage() {
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{user.displayName}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
<div className="text-sm text-gray-500">{user.email || 'Без входа'}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
@ -154,7 +156,8 @@ export function UserManagementPage() {
|
||||
password: '',
|
||||
displayName: user.displayName,
|
||||
bio: user.bio,
|
||||
avatarUrl: user.avatarUrl
|
||||
avatarUrl: user.avatarUrl,
|
||||
attributionOnly: !user.email
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
@ -169,7 +172,7 @@ export function UserManagementPage() {
|
||||
</div>
|
||||
|
||||
{/* Permissions Editor */}
|
||||
{selectedUser && (
|
||||
{selectedUser && selectedUser.email && (
|
||||
<div className="md:col-span-2 bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Редактирование прав пользователя "{selectedUser.displayName}"
|
||||
@ -312,34 +315,51 @@ export function UserManagementPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
E-mail
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<label htmlFor="attributionOnly" className="ml-2 block text-sm text-gray-900">
|
||||
Без возможности входа
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
{!isAttributionOnly && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
E-mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
|
@ -28,9 +28,10 @@ export interface Author {
|
||||
|
||||
export interface UserFormData {
|
||||
id: string
|
||||
email: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
password?: string;
|
||||
displayName: string;
|
||||
bio: string;
|
||||
avatarUrl: string;
|
||||
attributionOnly?: boolean;
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ export interface Article {
|
||||
author: Author;
|
||||
coverImage: string;
|
||||
images?: string[];
|
||||
imageSubs?: string[];
|
||||
gallery?: GalleryImage[];
|
||||
publishedAt: string;
|
||||
readTime: number;
|
||||
|
Loading…
x
Reference in New Issue
Block a user