Files
mkd/components/office/KnowledgeBase.tsx

1098 lines
48 KiB
TypeScript
Raw Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect, useRef } from 'react';
import { authFetch } from '../../services/apiClient';
import { BookOpen, Search, Folder, FileText, Plus, Edit, Eye, CheckCircle2, X, Upload, Image as ImageIcon, File, Trash2 } from 'lucide-react';
import { KnowledgeBaseArticle, KnowledgeBaseCategory } from '../../types';
import { CURRENT_USER_MOCK } from '../../constants';
import './KnowledgeBase.css';
// ReactQuill loaded dynamically when KnowledgeBase mounts to reduce initial bundle
type ReactQuillComponent = React.ComponentType<any>;
// Базовый URL бэкенда для загрузки файлов (как в PR фото отчётах)
const getBackendBase = () => {
const apiBase = import.meta.env.VITE_API_BASE_URL || 'http://localhost:4000/api';
return apiBase.replace(/\/api\/?$/, '');
};
// Подставить в HTML контента img src базовый URL бэкенда и обернуть в ссылку (клик — полный размер)
const rewriteContentImageUrls = (html: string): string => {
if (!html || !html.includes('<img')) return html;
const base = getBackendBase();
return html.replace(
/<img([^>]*)\ssrc=["']([^"']+)["']/gi,
(_, attrs, src) => {
const fullSrc = src.startsWith('http') || src.startsWith('data:') ? src : `${base}${src.startsWith('/') ? src : `/${src}`}`;
return `<a href="${fullSrc}" target="_blank" rel="noopener noreferrer" class="kb-article-img-link"><img${attrs} src="${fullSrc}" class="kb-article-img" /></a>`;
}
);
};
// Компонент для отображения изображения с обработкой ошибок (URL через бэкенд, как в PR фото)
const AttachmentImage: React.FC<{ url: string; filename: string }> = ({ url, filename }) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
const base = getBackendBase();
const imageUrl = url.startsWith('http') || url.startsWith('data:') ? url : `${base}${url.startsWith('/') ? url : `/${url}`}`;
const fileUrl = url.startsWith('http') || url.startsWith('data:') ? url : `${base}${url.startsWith('/') ? url : `/${url}`}`;
return (
<a href={fileUrl} target="_blank" rel="noopener noreferrer" className="block">
{!imageError ? (
<>
{imageLoading && (
<div className="w-full h-32 bg-slate-100 flex items-center justify-center">
<div className="text-slate-400 text-xs">Загрузка...</div>
</div>
)}
<img
src={imageUrl}
alt={filename}
className={`w-full h-32 object-cover hover:opacity-90 transition-opacity ${imageLoading ? 'hidden' : ''}`}
onError={() => {
setImageError(true);
setImageLoading(false);
}}
onLoad={() => setImageLoading(false)}
/>
</>
) : (
<div className="w-full h-32 bg-slate-100 flex items-center justify-center">
<div className="text-center">
<ImageIcon className="w-8 h-8 text-slate-400 mx-auto mb-1" />
<p className="text-xs text-slate-500 px-2 truncate max-w-full">{filename}</p>
</div>
</div>
)}
<p className="p-2 text-xs text-slate-600 truncate bg-slate-50">{filename}</p>
</a>
);
};
export const KnowledgeBase: React.FC = () => {
const [categories, setCategories] = useState<KnowledgeBaseCategory[]>([]);
const [articles, setArticles] = useState<KnowledgeBaseArticle[]>([]);
const [selectedCategory, setSelectedCategory] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [selectedArticle, setSelectedArticle] = useState<KnowledgeBaseArticle | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showCreateCategoryModal, setShowCreateCategoryModal] = useState(false);
const [newCategoryName, setNewCategoryName] = useState('');
const [formData, setFormData] = useState({
title: '',
categoryId: '',
content: '',
contentType: 'html' as 'markdown' | 'html',
tags: ''
});
const [uploadedFiles, setUploadedFiles] = useState<Array<{ filename: string; url: string; size: number; mimetype: string }>>([]);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const quillRef = useRef<any>(null);
const [QuillEditor, setQuillEditor] = useState<ReactQuillComponent | null>(null);
useEffect(() => {
let cancelled = false;
Promise.all([
import('react-quill-new'),
import('react-quill-new/dist/quill.snow.css')
]).then(([mod]) => {
if (!cancelled) setQuillEditor(() => mod.default);
}).catch(err => console.error('Failed to load Quill editor', err));
return () => { cancelled = true; };
}, []);
// Функция для очистки HTML и получения текстового превью
const stripHtml = (html: string): string => {
const tmp = document.createElement('DIV');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
};
useEffect(() => {
fetchCategories();
fetchArticles();
}, [selectedCategory, searchQuery]);
const fetchCategories = async () => {
try {
const response = await authFetch('/api/office/knowledge-base/categories');
if (response.ok) {
const data = await response.json();
setCategories(data);
}
} catch (error) {
console.error('Ошибка загрузки категорий:', error);
}
};
const fetchArticles = async () => {
try {
const params = new URLSearchParams();
if (selectedCategory) params.append('categoryId', selectedCategory.toString());
if (searchQuery) params.append('search', searchQuery);
params.append('isPublished', 'true');
const response = await authFetch(`/api/office/knowledge-base/articles?${params}`);
if (response.ok) {
const data = await response.json();
// Нормализуем данные из API
const normalizedData = data.map((article: any) => {
// Обрабатываем attachments - нормализуем URL
let attachments = Array.isArray(article.attachments)
? article.attachments
: (typeof article.attachments === 'string' ? JSON.parse(article.attachments || '[]') : []);
// Убеждаемся, что все URL начинаются с /uploads/ если они относительные
attachments = attachments.map((url: string) => {
if (!url) return url;
// Если URL уже абсолютный (http/https), оставляем как есть
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Если URL начинается с /, оставляем как есть
if (url.startsWith('/')) {
return url;
}
// Иначе добавляем / в начало
return `/${url}`;
});
return {
id: article.id,
title: article.title || '',
slug: article.slug || '',
categoryId: article.category_id || article.categoryId,
category: article.category_name ? {
id: article.category_id,
name: article.category_name
} : article.category,
content: article.content || '',
contentType: article.content_type || article.contentType || 'markdown',
author: article.author || '',
version: article.version || 1,
parentVersionId: article.parent_version_id || article.parentVersionId,
isPublished: article.is_published !== undefined ? article.is_published : (article.isPublished !== undefined ? article.isPublished : true),
tags: Array.isArray(article.tags) ? article.tags : (typeof article.tags === 'string' ? JSON.parse(article.tags || '[]') : []),
attachments: attachments,
viewCount: article.view_count || article.viewCount || 0,
createdAt: article.created_at || article.createdAt,
updatedAt: article.updated_at || article.updatedAt,
publishedAt: article.published_at || article.publishedAt
};
});
setArticles(normalizedData);
}
} catch (error) {
console.error('Ошибка загрузки статей:', error);
}
};
const handleCreateCategory = async () => {
try {
if (!newCategoryName || !newCategoryName.trim()) {
alert('Пожалуйста, укажите название категории');
return;
}
const response = await authFetch('/api/office/knowledge-base/categories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newCategoryName.trim()
})
});
if (response.ok) {
const newCategory = await response.json();
// Обновляем список категорий
await fetchCategories();
setShowCreateCategoryModal(false);
setNewCategoryName('');
alert('Категория успешно создана');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания категории: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания категории:', error);
alert(`Ошибка создания категории: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleViewArticle = (article: KnowledgeBaseArticle) => {
setSelectedArticle(article);
setShowViewModal(true);
};
const handleEditArticle = (article: KnowledgeBaseArticle) => {
setSelectedArticle(article);
setFormData({
title: article.title || '',
categoryId: article.categoryId ? article.categoryId.toString() : '',
content: article.content || '',
contentType: article.contentType || 'html',
tags: Array.isArray(article.tags) ? article.tags.join(', ') : ''
});
// Восстанавливаем загруженные файлы из attachments
if (article.attachments && Array.isArray(article.attachments) && article.attachments.length > 0) {
const files = article.attachments.map((url: string) => ({
filename: url.split('/').pop() || 'file',
url: url,
size: 0,
mimetype: url.match(/\.(jpg|jpeg|png|gif|webp)$/i) ? 'image/jpeg' : 'application/octet-stream'
}));
setUploadedFiles(files);
} else {
setUploadedFiles([]);
}
setShowEditModal(true);
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await authFetch('/api/office/knowledge-base/upload', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
setUploadedFiles([...uploadedFiles, result.file]);
// Если это изображение, вставляем его в редактор
if (file.type.startsWith('image/')) {
const quill = quillRef.current?.getEditor();
if (quill) {
const range = quill.getSelection();
const index = range ? range.index : quill.getLength();
quill.insertEmbed(index, 'image', result.file.url);
quill.setSelection(index + 1);
}
}
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка загрузки файла: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка загрузки файла:', error);
alert(`Ошибка загрузки файла: ${error.message || 'Неизвестная ошибка'}`);
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const handleRemoveFile = (index: number) => {
setUploadedFiles(uploadedFiles.filter((_, i) => i !== index));
};
const handleImageUpload = () => {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.onchange = async () => {
const file = input.files?.[0];
if (!file) return;
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await authFetch('/api/office/knowledge-base/upload', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
const quill = quillRef.current?.getEditor();
if (quill) {
const range = quill.getSelection();
const index = range ? range.index : quill.getLength();
quill.insertEmbed(index, 'image', result.file.url);
quill.setSelection(index + 1);
}
setUploadedFiles([...uploadedFiles, result.file]);
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка загрузки изображения: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка загрузки изображения:', error);
alert(`Ошибка загрузки изображения: ${error.message || 'Неизвестная ошибка'}`);
} finally {
setIsUploading(false);
}
};
input.click();
};
const handleUpdateArticle = async () => {
if (!selectedArticle) return;
try {
if (!formData.title || !formData.title.trim()) {
alert('Пожалуйста, укажите заголовок статьи');
return;
}
if (!formData.content || !formData.content.trim()) {
alert('Пожалуйста, укажите содержание статьи');
return;
}
const tagsArray = formData.tags ? formData.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const attachmentsArray = uploadedFiles.map(f => f.url);
const response = await authFetch(`/api/office/knowledge-base/articles/${selectedArticle.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
categoryId: formData.categoryId ? parseInt(formData.categoryId) : null,
content: formData.content,
contentType: formData.contentType,
tags: tagsArray,
attachments: attachmentsArray
})
});
if (response.ok) {
fetchArticles();
setShowEditModal(false);
setSelectedArticle(null);
setUploadedFiles([]);
alert('Статья успешно обновлена');
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка обновления статьи: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка обновления статьи:', error);
alert(`Ошибка обновления статьи: ${error.message || 'Неизвестная ошибка'}`);
}
};
const handleCreateArticle = async () => {
try {
if (!formData.title || !formData.title.trim()) {
alert('Пожалуйста, укажите заголовок статьи');
return;
}
if (!formData.content || !formData.content.trim()) {
alert('Пожалуйста, укажите содержание статьи');
return;
}
const tagsArray = formData.tags ? formData.tags.split(',').map(t => t.trim()).filter(Boolean) : [];
const attachmentsArray = uploadedFiles.map(f => f.url);
const response = await authFetch('/api/office/knowledge-base/articles', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: formData.title,
slug: formData.title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, ''),
categoryId: formData.categoryId ? parseInt(formData.categoryId) : null,
content: formData.content,
contentType: formData.contentType,
author: CURRENT_USER_MOCK.name,
tags: tagsArray,
attachments: attachmentsArray,
isPublished: true
})
});
if (response.ok) {
const newArticle = await response.json();
// Нормализуем данные
const normalizedArticle = {
id: newArticle.id,
title: newArticle.title || formData.title,
slug: newArticle.slug || '',
categoryId: newArticle.category_id || (formData.categoryId ? parseInt(formData.categoryId) : null),
category: categories.find(c => c.id === (formData.categoryId ? parseInt(formData.categoryId) : null)),
content: newArticle.content || formData.content,
contentType: newArticle.content_type || formData.contentType,
author: newArticle.author || CURRENT_USER_MOCK.name,
version: newArticle.version || 1,
isPublished: newArticle.is_published !== undefined ? newArticle.is_published : true,
tags: tagsArray,
attachments: [],
viewCount: 0,
createdAt: newArticle.created_at,
updatedAt: newArticle.updated_at,
publishedAt: newArticle.published_at
};
setArticles([normalizedArticle, ...articles]);
setShowCreateModal(false);
setFormData({
title: '',
categoryId: '',
content: '',
contentType: 'html',
tags: ''
});
setUploadedFiles([]);
} else {
const errorData = await response.json().catch(() => ({ error: 'Неизвестная ошибка' }));
alert(`Ошибка создания статьи: ${errorData.error || 'Неизвестная ошибка'}`);
}
} catch (error: any) {
console.error('Ошибка создания статьи:', error);
alert(`Ошибка создания статьи: ${error.message || 'Неизвестная ошибка'}`);
}
};
return (
<div className="space-y-6 animate-fade-in">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-slate-800">База знаний</h3>
<button
onClick={() => {
setFormData({
title: '',
categoryId: '',
content: '',
contentType: 'html',
tags: ''
});
setUploadedFiles([]);
setShowCreateModal(true);
}}
className="bg-primary-600 text-white px-4 py-2 rounded-lg text-sm font-bold hover:bg-primary-700 transition-colors flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Новая статья
</button>
</div>
{/* Поиск */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по статьям..."
className="w-full pl-10 pr-4 py-3 border border-slate-300 rounded-lg text-sm"
/>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Категории */}
<div className="lg:col-span-1">
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-4">
<div className="flex justify-between items-center mb-4">
<h4 className="font-bold text-slate-800 flex items-center gap-2">
<Folder className="w-4 h-4" />
Категории
</h4>
<button
onClick={() => setShowCreateCategoryModal(true)}
className="p-1.5 bg-primary-50 text-primary-600 rounded-lg hover:bg-primary-100 transition-colors"
title="Создать категорию"
>
<Plus className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
<button
onClick={() => setSelectedCategory(null)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
selectedCategory === null
? 'bg-primary-50 text-primary-700 font-bold'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
Все статьи
</button>
{categories.map((category) => (
<button
key={category.id}
onClick={() => setSelectedCategory(category.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-sm transition-colors ${
selectedCategory === category.id
? 'bg-primary-50 text-primary-700 font-bold'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
{category.name}
</button>
))}
</div>
</div>
</div>
{/* Список статей */}
<div className="lg:col-span-3">
{articles.length > 0 ? (
<div className="space-y-3">
{articles.map((article) => (
<div
key={article.id}
className="bg-white rounded-xl border border-slate-200 shadow-sm p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<h5 className="font-bold text-slate-800 mb-2">{article.title}</h5>
<p className="text-sm text-slate-600 line-clamp-2 mb-3">
{article.contentType === 'html'
? (() => {
const textContent = stripHtml(article.content || '');
return textContent.substring(0, 150) + (textContent.length > 150 ? '...' : '');
})()
: ((article.content || '').substring(0, 150) + (article.content && article.content.length > 150 ? '...' : ''))
}
</p>
<div className="flex items-center gap-4 text-xs text-slate-500">
{article.category && (
<span className="px-2 py-1 bg-slate-100 rounded">
{article.category.name}
</span>
)}
<span>Автор: {article.author}</span>
<span>{article.createdAt ? new Date(article.createdAt).toLocaleDateString('ru-RU') : 'Дата не указана'}</span>
<span>Просмотров: {article.viewCount}</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleViewArticle(article);
}}
className="p-2 bg-blue-50 text-blue-600 rounded-lg hover:bg-blue-100 transition-colors"
title="Просмотреть статью"
>
<Eye className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleEditArticle(article);
}}
className="p-2 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-colors"
title="Редактировать статью"
>
<Edit className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-12 text-center">
<BookOpen className="w-12 h-12 mx-auto text-slate-300 mb-4" />
<p className="text-slate-500">Статьи не найдены</p>
</div>
)}
</div>
</div>
{/* Create Article Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Создать статью</h3>
<button
onClick={() => setShowCreateModal(false)}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Заголовок *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Введите заголовок статьи"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Категория</label>
<select
value={formData.categoryId}
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Без категории</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id.toString()}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Содержание *</label>
<div className="border border-slate-300 rounded-lg overflow-hidden">
{QuillEditor ? (
<QuillEditor
ref={quillRef}
theme="snow"
value={formData.content}
onChange={(value: string) => setFormData({ ...formData, content: value })}
modules={{
toolbar: {
container: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'color': [] }, { 'background': [] }],
[{ 'align': [] }],
['link', 'image'],
['clean']
],
handlers: {
image: handleImageUpload
}
}
}}
formats={[
'header', 'bold', 'italic', 'underline', 'strike',
'list', 'bullet', 'script', 'indent',
'color', 'background', 'align',
'link', 'image'
]}
placeholder="Введите содержание статьи..."
style={{ minHeight: '300px' }}
/>
) : (
<div className="min-h-[300px] flex items-center justify-center bg-slate-50 text-slate-500 text-sm">Загрузка редактора...</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Загрузить файлы</label>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.txt,.zip,.rar"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
{isUploading ? 'Загрузка...' : 'Выбрать файл'}
</button>
<span className="text-xs text-slate-500">Изображения, PDF, документы, архивы (до 20MB)</span>
</div>
{uploadedFiles.length > 0 && (
<div className="mt-3 space-y-2">
{uploadedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-slate-50 rounded-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
{file.mimetype.startsWith('image/') ? (
<ImageIcon className="w-4 h-4 text-blue-600 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-slate-600 flex-shrink-0" />
)}
<span className="text-sm text-slate-700 truncate">{file.filename}</span>
<span className="text-xs text-slate-500 ml-2">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
<button
type="button"
onClick={() => handleRemoveFile(index)}
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Теги (через запятую)</label>
<input
type="text"
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="например: инструкция, ремонт, настройка"
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleCreateArticle}
disabled={!formData.title || !formData.content}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
{/* View Article Modal */}
{showViewModal && selectedArticle && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">{selectedArticle.title}</h3>
<div className="flex gap-2">
<button
onClick={() => {
setShowViewModal(false);
handleEditArticle(selectedArticle);
}}
className="p-2 bg-amber-50 text-amber-600 rounded-lg hover:bg-amber-100 transition-colors"
title="Редактировать"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => {
setShowViewModal(false);
setSelectedArticle(null);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-4 text-sm text-slate-600 pb-4 border-b border-slate-200">
{selectedArticle.category && (
<span className="px-2 py-1 bg-slate-100 rounded">
{selectedArticle.category.name}
</span>
)}
<span>Автор: {selectedArticle.author}</span>
<span>{selectedArticle.createdAt ? new Date(selectedArticle.createdAt).toLocaleDateString('ru-RU') : 'Дата не указана'}</span>
<span>Просмотров: {selectedArticle.viewCount}</span>
</div>
<div className="prose max-w-none">
{selectedArticle.contentType === 'html' ? (
<div
className="ql-editor"
dangerouslySetInnerHTML={{ __html: rewriteContentImageUrls(selectedArticle.content || '') }}
/>
) : (
<pre className="whitespace-pre-wrap font-sans text-sm text-slate-700">{selectedArticle.content || ''}</pre>
)}
</div>
{selectedArticle.attachments && selectedArticle.attachments.length > 0 && (
<div className="pt-4 border-t border-slate-200">
<p className="text-sm font-medium text-slate-700 mb-2">Прикрепленные файлы:</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{selectedArticle.attachments.map((url: string, index: number) => {
const isImage = /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
const filename = url.split('/').pop() || `Файл ${index + 1}`;
const base = getBackendBase();
const fileUrl = url.startsWith('http') || url.startsWith('data:') ? url : `${base}${url.startsWith('/') ? url : `/${url}`}`;
return (
<div key={index} className="border border-slate-200 rounded-lg overflow-hidden bg-white">
{isImage ? (
<AttachmentImage url={url} filename={filename} />
) : (
<a
href={fileUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 hover:bg-slate-50 transition-colors"
>
<File className="w-5 h-5 text-slate-400" />
<span className="text-sm text-slate-700 truncate flex-1">{filename}</span>
</a>
)}
</div>
);
})}
</div>
</div>
)}
{selectedArticle.tags && selectedArticle.tags.length > 0 && (
<div className="pt-4 border-t border-slate-200">
<p className="text-sm font-medium text-slate-700 mb-2">Теги:</p>
<div className="flex flex-wrap gap-2">
{selectedArticle.tags.map((tag, index) => (
<span key={index} className="px-2 py-1 bg-primary-50 text-primary-700 rounded text-xs">
{tag}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Edit Article Modal */}
{showEditModal && selectedArticle && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Редактировать статью</h3>
<button
onClick={() => {
setShowEditModal(false);
setSelectedArticle(null);
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Заголовок *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Категория</label>
<select
value={formData.categoryId}
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
>
<option value="">Без категории</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id.toString()}>
{cat.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Содержание *</label>
<div className="border border-slate-300 rounded-lg overflow-hidden">
{QuillEditor ? (
<QuillEditor
ref={quillRef}
theme="snow"
value={formData.content}
onChange={(value: string) => setFormData({ ...formData, content: value })}
modules={{
toolbar: {
container: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
[{ 'color': [] }, { 'background': [] }],
[{ 'align': [] }],
['link', 'image'],
['clean']
],
handlers: {
image: handleImageUpload
}
}
}}
formats={[
'header', 'bold', 'italic', 'underline', 'strike',
'list', 'bullet', 'script', 'indent',
'color', 'background', 'align',
'link', 'image'
]}
placeholder="Введите содержание статьи..."
style={{ minHeight: '300px' }}
/>
) : (
<div className="min-h-[300px] flex items-center justify-center bg-slate-50 text-slate-500 text-sm">Загрузка редактора...</div>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Загрузить файлы</label>
<div className="flex items-center gap-3">
<input
ref={fileInputRef}
type="file"
onChange={handleFileUpload}
className="hidden"
accept="image/*,.pdf,.doc,.docx,.txt,.zip,.rar"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-200 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Upload className="w-4 h-4" />
{isUploading ? 'Загрузка...' : 'Выбрать файл'}
</button>
<span className="text-xs text-slate-500">Изображения, PDF, документы, архивы (до 20MB)</span>
</div>
{uploadedFiles.length > 0 && (
<div className="mt-3 space-y-2">
{uploadedFiles.map((file, index) => (
<div key={index} className="flex items-center justify-between p-2 bg-slate-50 rounded-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
{file.mimetype.startsWith('image/') ? (
<ImageIcon className="w-4 h-4 text-blue-600 flex-shrink-0" />
) : (
<File className="w-4 h-4 text-slate-600 flex-shrink-0" />
)}
<span className="text-sm text-slate-700 truncate">{file.filename}</span>
<span className="text-xs text-slate-500 ml-2">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
<button
type="button"
onClick={() => handleRemoveFile(index)}
className="p-1 text-red-600 hover:bg-red-50 rounded transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Теги (через запятую)</label>
<input
type="text"
value={formData.tags}
onChange={(e) => setFormData({ ...formData, tags: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="например: инструкция, ремонт, настройка"
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleUpdateArticle}
disabled={!formData.title || !formData.content}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Сохранить
</button>
<button
onClick={() => {
setShowEditModal(false);
setSelectedArticle(null);
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
{/* Create Category Modal */}
{showCreateCategoryModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold text-slate-800">Создать категорию</h3>
<button
onClick={() => {
setShowCreateCategoryModal(false);
setNewCategoryName('');
}}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Название категории *</label>
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm"
placeholder="Введите название категории"
required
/>
</div>
<div className="flex gap-3 pt-4">
<button
onClick={handleCreateCategory}
disabled={!newCategoryName || !newCategoryName.trim()}
className="flex-1 bg-primary-600 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-primary-700 transition-colors disabled:bg-slate-300 disabled:cursor-not-allowed"
>
Создать
</button>
<button
onClick={() => {
setShowCreateCategoryModal(false);
setNewCategoryName('');
}}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-lg font-bold text-sm hover:bg-slate-200 transition-colors"
>
Отмена
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
};