Доработана вставка изображений с подписью. При импорте статей можно посмотреть их изображения и подписи, можно редактировать подписи. Создание пользователей без права входа в админку.

This commit is contained in:
anibilag 2025-05-02 23:49:36 +03:00
parent 12b0011d57
commit 4663e0a300
13 changed files with 592 additions and 276 deletions

View File

@ -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:

View File

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

View File

@ -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;

View 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;

View File

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

View 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)
},
})

View 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

View 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

View File

@ -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%];
}

View File

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

View File

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

View File

@ -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;
}

View File

@ -9,6 +9,7 @@ export interface Article {
author: Author;
coverImage: string;
images?: string[];
imageSubs?: string[];
gallery?: GalleryImage[];
publishedAt: string;
readTime: number;