Доработана вставка изображений с подписью. При импорте статей можно посмотреть их изображения и подписи, можно редактировать подписи. Создание пользователей без права входа в админку.
This commit is contained in:
parent
12b0011d57
commit
4663e0a300
@ -4,9 +4,9 @@ from datetime import datetime
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
# Файл дампа
|
# Файл дампа
|
||||||
input_directory = "D:/__TEMP/____4/__convert"
|
input_directory = "D:/__TEMP/____RUSS_CULT/__convert"
|
||||||
json_file = "1-2025"
|
json_file = "1-2025-p"
|
||||||
output_file = "2-2025-convert.json"
|
output_file = "1-2025-p-convert.json"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
|
@ -1,6 +1,17 @@
|
|||||||
|
import tailwindcss from 'tailwindcss';
|
||||||
|
import autoprefixer from 'autoprefixer';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: {
|
plugins: [
|
||||||
tailwindcss: {},
|
{
|
||||||
autoprefixer: {},
|
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({
|
export function ArticleForm({
|
||||||
editingId,
|
editingId,
|
||||||
articleId,
|
articleId,
|
||||||
initialFormState,
|
initialFormState,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
authors,
|
authors,
|
||||||
availableCategoryIds,
|
availableCategoryIds,
|
||||||
availableCityIds,
|
availableCityIds,
|
||||||
}: ArticleFormProps) {
|
}: ArticleFormProps) {
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const isAdmin = user?.permissions.isAdmin || false;
|
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 React, { useState, useRef, useEffect } from 'react';
|
||||||
import {mergeAttributes, Node, NodeViewProps } from '@tiptap/core';
|
import { mergeAttributes, Node, NodeViewProps } from '@tiptap/core';
|
||||||
import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react';
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
import {Trash2, ZoomIn, ZoomOut} from 'lucide-react';
|
import { GripVertical, Trash2, ZoomIn, ZoomOut } from 'lucide-react';
|
||||||
import {Node as ProseMirrorNode, DOMOutputSpec} from 'prosemirror-model';
|
import { Node as ProseMirrorNode, DOMOutputSpec } from 'prosemirror-model';
|
||||||
|
import ConfirmDeleteModal from "./ConfirmDeleteModal";
|
||||||
|
|
||||||
// Определяем тип для атрибутов узла customImage
|
|
||||||
interface CustomImageAttributes {
|
interface CustomImageAttributes {
|
||||||
src: string | undefined;
|
src: string | undefined;
|
||||||
alt: string | undefined;
|
alt: string | undefined;
|
||||||
title: string | undefined;
|
title: string | undefined;
|
||||||
scale: number;
|
scale: number;
|
||||||
caption: string; // Добавляем атрибут caption
|
caption: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ImageNodeView: React.FC<NodeViewProps> = ({ node, updateAttributes, editor, getPos, selected, deleteNode }) => {
|
const ImageNodeView: React.FC<NodeViewProps> = ({ node, editor, getPos, selected, deleteNode }) => {
|
||||||
// Утверждаем, что node имеет нужный тип
|
|
||||||
const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
|
const typedNode = node as ProseMirrorNode & { attrs: CustomImageAttributes };
|
||||||
|
|
||||||
// Проверяем, что узел корректен
|
const scale = typedNode.attrs.scale || 1;
|
||||||
if (node.type.name !== 'customImage') {
|
const caption = typedNode.attrs.caption || '';
|
||||||
console.error('Неподдерживаемый тип узла:', node.type.name);
|
const isEditable = editor.isEditable;
|
||||||
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: неподдерживаемый тип узла</NodeViewWrapper>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, что src существует
|
const captionRef = useRef<HTMLDivElement>(null);
|
||||||
if (!typedNode.attrs.src) {
|
|
||||||
console.error('Изображение не загружено, src отсутствует:', typedNode.attrs);
|
|
||||||
return <NodeViewWrapper className="relative flex justify-center my-4">Ошибка: изображение не загружено</NodeViewWrapper>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логируем атрибуты узла при загрузке
|
useEffect(() => {
|
||||||
//console.log('Атрибуты узла customImage при загрузке:', typedNode.attrs);
|
if (captionRef.current && caption.trim() !== captionRef.current.innerText.trim()) {
|
||||||
|
captionRef.current.innerText = caption;
|
||||||
|
}
|
||||||
|
}, [caption]);
|
||||||
|
|
||||||
|
const [isConfirmModalOpen, setConfirmModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleZoomIn = (e: React.MouseEvent) => {
|
const handleZoomIn = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
editor.commands.updateAttributes('customImage', {
|
||||||
if (!editor || editor.isDestroyed) {
|
scale: Math.min(scale + 0.1, 2),
|
||||||
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) => {
|
const handleZoomOut = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
editor.commands.updateAttributes('customImage', {
|
||||||
if (!editor || editor.isDestroyed) {
|
scale: Math.max(scale - 0.1, 0.5),
|
||||||
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) => {
|
const handleDelete = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
setConfirmModalOpen(true);
|
||||||
deleteNode();
|
};
|
||||||
} catch (error) {
|
|
||||||
console.error('Ошибка при удалении изображения:', error);
|
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 (
|
return (
|
||||||
//<NodeViewWrapper className="relative flex justify-center my-4">
|
|
||||||
<NodeViewWrapper className="relative flex flex-col items-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="flex-1">
|
||||||
<div
|
<div className="flex flex-col items-center w-full">
|
||||||
className="relative inline-block"
|
<div
|
||||||
style={{
|
className="relative flex flex-col items-center"
|
||||||
transform: `scale(${scale})`,
|
style={{ width: `${scale * 100}%`, transition: 'width 0.2s' }}
|
||||||
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})`}
|
|
||||||
>
|
>
|
||||||
<ZoomIn size={16} />
|
<img
|
||||||
</button>
|
src={typedNode.attrs.src}
|
||||||
<button
|
alt={typedNode.attrs.alt}
|
||||||
type="button"
|
title={typedNode.attrs.title}
|
||||||
onClick={handleZoomOut}
|
className={`max-w-full ${selected && isEditable ? 'ring-2 ring-blue-500' : ''}`}
|
||||||
className="p-1 rounded hover:bg-gray-200"
|
data-scale={scale}
|
||||||
title={`Zoom out (current scale: ${scale})`}
|
/>
|
||||||
>
|
|
||||||
<ZoomOut size={16} />
|
{selected && isEditable && (
|
||||||
</button>
|
<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
|
<button type="button" onClick={handleZoomIn} className="p-1 rounded hover:bg-gray-200" title="Увеличить">
|
||||||
type="button"
|
<ZoomIn size={16} />
|
||||||
onClick={handleDelete}
|
</button>
|
||||||
className="p-1 rounded hover:bg-red-200 text-red-600"
|
<button type="button" onClick={handleZoomOut} className="p-1 rounded hover:bg-gray-200" title="Уменьшить">
|
||||||
title="Delete image"
|
<ZoomOut size={16} />
|
||||||
>
|
</button>
|
||||||
<Trash2 size={16} />
|
<button type="button" onClick={handleDelete} className="p-1 rounded hover:bg-red-200 text-red-600" title="Удалить изображение">
|
||||||
</button>
|
<Trash2 size={16} />
|
||||||
<span className="text-xs text-gray-600 px-2 py-1">
|
</button>
|
||||||
Масштаб: {scale}
|
<div className="text-xs text-gray-500 ml-2">
|
||||||
</span>
|
{(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>
|
||||||
</div>
|
</div>
|
||||||
{caption && (
|
|
||||||
<p className="image-caption mt-2 text-sm text-gray-600 italic text-center">
|
<ConfirmDeleteModal
|
||||||
{caption}
|
isOpen={isConfirmModalOpen}
|
||||||
</p>
|
onConfirm={() => {
|
||||||
)}
|
deleteNode();
|
||||||
|
setConfirmModalOpen(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmModalOpen(false)}
|
||||||
|
/>
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -187,38 +163,29 @@ export const CustomImage = Node.create({
|
|||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
src: {
|
src: { default: undefined },
|
||||||
default: undefined,
|
alt: { default: undefined },
|
||||||
},
|
title: { default: undefined },
|
||||||
alt: {
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
scale: {
|
scale: {
|
||||||
default: 1,
|
default: 1,
|
||||||
parseHTML: (element) => {
|
parseHTML: (element) => {
|
||||||
const scale = Number(element.getAttribute('scale'));
|
const img = element.querySelector('img');
|
||||||
return isNaN(scale) || scale <= 0 ? 1 : Math.max(0.5, Math.min(2, Number(scale.toFixed(2))));
|
return img ? Number(img.getAttribute('data-scale')) || 1 : 1;
|
||||||
},
|
|
||||||
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 };
|
|
||||||
},
|
},
|
||||||
|
renderHTML: (attributes) => ({
|
||||||
|
'data-scale': Number(attributes.scale) || 1,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
caption: { // Добавляем атрибут caption
|
caption: {
|
||||||
default: '',
|
default: '',
|
||||||
parseHTML: (element) => element.getAttribute('data-caption') || '',
|
parseHTML: (element) => {
|
||||||
renderHTML: (attributes) => {
|
const captionElement = element.querySelector('div.image-caption');
|
||||||
if (!attributes.caption) {
|
const img = element.querySelector('img');
|
||||||
return {};
|
return captionElement
|
||||||
}
|
? captionElement.getAttribute('data-caption') || captionElement.textContent || ''
|
||||||
return {
|
: img?.getAttribute('data-caption') || '';
|
||||||
'data-caption': attributes.caption,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
renderHTML: (attributes) => (attributes.caption ? { 'data-caption': attributes.caption } : {}),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -228,53 +195,55 @@ export const CustomImage = Node.create({
|
|||||||
{
|
{
|
||||||
tag: 'div.image-container',
|
tag: 'div.image-container',
|
||||||
getAttrs: (element: HTMLElement) => {
|
getAttrs: (element: HTMLElement) => {
|
||||||
|
if (!(element instanceof HTMLElement)) return null;
|
||||||
const img = element.querySelector('img');
|
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 {
|
return {
|
||||||
src: img?.getAttribute('src'),
|
src: img.getAttribute('src') || undefined,
|
||||||
alt: img?.getAttribute('alt'),
|
alt: img.getAttribute('alt') || undefined,
|
||||||
title: img?.getAttribute('title'),
|
title: img.getAttribute('title') || undefined,
|
||||||
scale: img ? Number(img.getAttribute('scale')) || 1 : 1,
|
scale: Number(img.getAttribute('data-scale')) || 1,
|
||||||
caption,
|
caption: captionElement
|
||||||
};
|
? captionElement.getAttribute('data-caption') || captionElement.textContent || ''
|
||||||
},
|
: img.getAttribute('data-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,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }): DOMOutputSpec {
|
renderHTML({ node, HTMLAttributes }) {
|
||||||
const { scale, caption, ...rest } = HTMLAttributes;
|
const { src, alt, title, scale, caption } = node.attrs;
|
||||||
|
const actualScale = Number(scale) || 1;
|
||||||
|
|
||||||
const imgElement: DOMOutputSpec = [
|
const imageElement: DOMOutputSpec = [
|
||||||
'img',
|
'img',
|
||||||
mergeAttributes(rest, {
|
mergeAttributes(HTMLAttributes, {
|
||||||
scale: scale,
|
src,
|
||||||
'data-caption': caption,
|
alt,
|
||||||
style: `transform: scale(${scale})`,
|
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) {
|
return [
|
||||||
children.push(['p', { class: 'image-caption mt-2 text-sm text-gray-600 italic text-center' }, caption]);
|
'div',
|
||||||
}
|
{ class: 'image-container flex flex-col items-center my-4' },
|
||||||
|
imageElement,
|
||||||
return ['div', { class: 'image-container flex flex-col items-center my-4' }, ...children];
|
caption ? captionElement : '',
|
||||||
|
];
|
||||||
},
|
},
|
||||||
|
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
|
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 {
|
div.image-container {
|
||||||
@apply flex flex-col items-center my-4;
|
@apply flex flex-col items-center my-4 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.image-container img {
|
div.image-container img {
|
||||||
@apply max-w-full h-auto;
|
@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 {
|
p.image-caption {
|
||||||
@apply mt-2 text-lg text-gray-600 italic text-center max-w-[80%];
|
@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);
|
const jsonData = JSON.parse(text as string);
|
||||||
if (Array.isArray(jsonData)) {
|
if (Array.isArray(jsonData)) {
|
||||||
setArticles(jsonData);
|
setArticles(jsonData);
|
||||||
setCurrentPage(1); // Сброс на первую страницу
|
setCurrentPage(1);
|
||||||
setError(null);
|
setError(null);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Неправильный формат JSON. Ожидается массив статей.');
|
throw new Error('Неправильный формат JSON. Ожидается массив статей.');
|
||||||
@ -44,7 +44,7 @@ export function ImportArticlesPage() {
|
|||||||
reader.readAsText(file);
|
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 =>
|
setArticles(prev => prev.map(article =>
|
||||||
article.id === articleId ? { ...article, [field]: value } : article
|
article.id === articleId ? { ...article, [field]: value } : article
|
||||||
));
|
));
|
||||||
@ -71,7 +71,7 @@ export function ImportArticlesPage() {
|
|||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
alert('Статьи успешно сохранены');
|
alert('Статьи успешно сохранены');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Не удалось сохранить статьи. Попробуйте снова.');
|
setError('Не удалось сохранить статьи. Попробуйте снова.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@ -85,9 +85,7 @@ export function ImportArticlesPage() {
|
|||||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<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="px-4 py-6 sm:px-0">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">Импорт статей</h1>
|
||||||
Импорт статей
|
|
||||||
</h1>
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
@ -137,14 +135,12 @@ export function ImportArticlesPage() {
|
|||||||
onChange={(e) => handleEditField(article.id, 'title', e.target.value)}
|
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"
|
className="block w-full text-sm font-medium text-gray-900 border-0 focus:ring-0 p-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingArticle(article)}
|
onClick={() => setEditingArticle(article)}
|
||||||
className="ml-4 p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
className="ml-4 p-2 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
|
||||||
>
|
>
|
||||||
<Edit2 size={16} />
|
<Edit2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -152,11 +148,37 @@ export function ImportArticlesPage() {
|
|||||||
onChange={(e) => handleEditField(article.id, 'excerpt', e.target.value)}
|
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"
|
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">
|
<div className="text-sm text-gray-500">
|
||||||
<span className="truncate">
|
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
|
||||||
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
|
|
||||||
</span>
|
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@ -166,22 +188,6 @@ export function ImportArticlesPage() {
|
|||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{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="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 className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
@ -193,7 +199,7 @@ export function ImportArticlesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(page => Math.max(1, page - 1))}
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||||
disabled={currentPage === 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"
|
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>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(page => Math.min(totalPages, page + 1))}
|
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||||
disabled={currentPage === totalPages}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
|
@ -86,6 +86,8 @@ export function UserManagementPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAttributionOnly = formData.attributionOnly || (!selectedUser?.email && !selectedUser?.permissions.isAdmin);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@ -143,7 +145,7 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="font-medium">{user.displayName}</div>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@ -154,7 +156,8 @@ export function UserManagementPage() {
|
|||||||
password: '',
|
password: '',
|
||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
bio: user.bio,
|
bio: user.bio,
|
||||||
avatarUrl: user.avatarUrl
|
avatarUrl: user.avatarUrl,
|
||||||
|
attributionOnly: !user.email
|
||||||
});
|
});
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
}}
|
}}
|
||||||
@ -169,7 +172,7 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Permissions Editor */}
|
{/* Permissions Editor */}
|
||||||
{selectedUser && (
|
{selectedUser && selectedUser.email && (
|
||||||
<div className="md:col-span-2 bg-white rounded-lg shadow p-6">
|
<div className="md:col-span-2 bg-white rounded-lg shadow p-6">
|
||||||
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
<h2 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
Редактирование прав пользователя "{selectedUser.displayName}"
|
Редактирование прав пользователя "{selectedUser.displayName}"
|
||||||
@ -312,34 +315,51 @@ export function UserManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex items-center">
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
E-mail
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="checkbox"
|
||||||
value={formData.email}
|
id="attributionOnly"
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
checked={formData.attributionOnly}
|
||||||
autoComplete="off"
|
onChange={(e) => setFormData(prev => ({ ...prev, attributionOnly: e.target.checked }))}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
|
<label htmlFor="attributionOnly" className="ml-2 block text-sm text-gray-900">
|
||||||
|
Без возможности входа
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{!isAttributionOnly && (
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<>
|
||||||
Пароль
|
<div>
|
||||||
</label>
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
<input
|
E-mail
|
||||||
type="password"
|
</label>
|
||||||
value={formData.password}
|
<input
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
type="email"
|
||||||
autoComplete="new-password"
|
value={formData.email}
|
||||||
placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''}
|
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
autoComplete="off"
|
||||||
required={!showEditModal}
|
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>
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
|
@ -28,9 +28,10 @@ export interface Author {
|
|||||||
|
|
||||||
export interface UserFormData {
|
export interface UserFormData {
|
||||||
id: string
|
id: string
|
||||||
email: string;
|
email?: string;
|
||||||
password: string;
|
password?: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
bio: string;
|
bio: string;
|
||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
|
attributionOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ export interface Article {
|
|||||||
author: Author;
|
author: Author;
|
||||||
coverImage: string;
|
coverImage: string;
|
||||||
images?: string[];
|
images?: string[];
|
||||||
|
imageSubs?: string[];
|
||||||
gallery?: GalleryImage[];
|
gallery?: GalleryImage[];
|
||||||
publishedAt: string;
|
publishedAt: string;
|
||||||
readTime: number;
|
readTime: number;
|
||||||
|
Loading…
x
Reference in New Issue
Block a user