Files
mkd/components/office/KnowledgeBase.tsx
2026-02-04 00:17:04 +05:00

1098 lines
48 KiB
TypeScript
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
};