print_add

This commit is contained in:
2026-09-01 11:47:15 +03:00
parent 1fe0d67731
commit 70a20665ae
38 changed files with 5494 additions and 889 deletions
+82 -82
View File
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
import 'quill/dist/quill.snow.css';
import '../App.css';
import DOMPurify from 'dompurify';
import SlidesManager from './SlidesManager';
function Editor() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -15,7 +16,6 @@ function Editor() {
const [editContent, setEditContent] = useState('');
const [editPreviewContent, setEditPreviewContent] = useState('');
const [showConfirmModal, setShowConfirmModal] = useState(false);
//const [csrfToken, setCsrfToken] = useState('');
const [notify, setNotify] = useState({ message: '', type: '', show: false });
const editorRef = useRef(null);
@@ -29,17 +29,6 @@ function Editor() {
setNotify({ message: '', type: '', show: false });
}, 2200);
}, []);
// Fetch CSRF token
//useEffect(() => {
// fetch('/moders/csrf-token')
// .then(res => res.json())
// .then(data => {
// setCsrfToken(data.csrfToken);
//})
//.catch(() => {});
//}, []);
// Check session on mount
useEffect(() => {
@@ -55,9 +44,9 @@ function Editor() {
.catch(() => setIsLoggedIn(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const authenticatedFetch = useCallback((url, options = {}) => {
return fetch(url, options);
}, []);
const authenticatedFetch = useCallback((url, options = {}) => {
return fetch(url, options);
}, []);
const loadBlocks = useCallback(async (tab) => {
try {
@@ -105,7 +94,7 @@ const authenticatedFetch = useCallback((url, options = {}) => {
const handleTabChange = (tab) => {
setActiveTab(tab);
if (isLoggedIn) {
if (isLoggedIn && tab !== 'slides') {
loadBlocks(tab);
}
};
@@ -138,41 +127,35 @@ const authenticatedFetch = useCallback((url, options = {}) => {
};
const handleSaveBlock = async (e) => {
e.preventDefault();
const content = DOMPurify.sanitize(editContent.trim());
if (!content || content === '<p><br></p>') return;
e.preventDefault();
const content = DOMPurify.sanitize(editContent.trim());
if (!content || content === '<p><br></p>') return;
const url = editingBlockId
? `/moders/content/${activeTab}/${editingBlockId}`
: `/moders/content/${activeTab}`;
const url = editingBlockId
? `/moders/content/${activeTab}/${editingBlockId}`
: `/moders/content/${activeTab}`;
const method = editingBlockId ? 'PUT' : 'POST';
const method = editingBlockId ? 'PUT' : 'POST';
console.log('➡️ Отправка:', method, url, { type: 'text', content });
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text', content })
});
try {
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'text', content })
});
const data = await response.json();
console.log('⬅️ Ответ сервера:', response.status, data);
if (response.ok) {
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
setShowEditModal(false);
loadBlocks(activeTab);
} else {
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
if (response.ok) {
showNotify(editingBlockId ? 'Блок обновлён' : 'Блок добавлен', 'success');
setShowEditModal(false);
loadBlocks(activeTab);
} else {
const data = await response.json();
showNotify(`Ошибка: ${data.message || response.status}`, 'error');
}
} catch (e) {
showNotify('Ошибка сохранения', 'error');
}
} catch (e) {
console.error('❌ Ошибка при сохранении:', e);
showNotify('Ошибка сохранения', 'error');
}
};
};
const handleTryCloseEditModal = () => {
if (editContent !== originalContentRef.current) {
@@ -194,7 +177,6 @@ const authenticatedFetch = useCallback((url, options = {}) => {
// Initialize Quill when modal opens
useEffect(() => {
if (showEditModal && editorRef.current && !quillRef.current) {
// Dynamically import Quill
import('quill').then((QuillModule) => {
const Quill = QuillModule.default || QuillModule;
if (editorRef.current) {
@@ -225,51 +207,68 @@ const authenticatedFetch = useCallback((url, options = {}) => {
});
}
// Cleanup Quill when modal closes
if (!showEditModal && quillRef.current) {
quillRef.current = null;
}
}, [showEditModal, editContent]);
// Если не авторизован — показываем форму входа
if (!isLoggedIn) {
return (
<div className="container">
<h1>Панель модератора</h1>
<div id="login-section">
<h2>Вход для модератора</h2>
<form id="moder-login-form" onSubmit={handleLogin}>
<div className="form-group">
<label htmlFor="username">Логин:</label>
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="password">Пароль:</label>
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<button type="submit">Войти</button>
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
</form>
</div>
</div>
);
}
// Авторизован — показываем редактор
return (
<>
<div id="notify" className={notify.show ? `show ${notify.type}` : ''}>{notify.message}</div>
<div className="container">
<h1>Панель модератора</h1>
{!isLoggedIn ? (
<div id="login-section">
<h2>Вход для модератора</h2>
<form id="moder-login-form" onSubmit={handleLogin}>
<div className="form-group">
<label htmlFor="username">Логин:</label>
<input type="text" id="username" name="username" required value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="form-group">
<label htmlFor="password">Пароль:</label>
<input type="password" id="password" name="password" required value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
<button type="submit">Войти</button>
{loginError && <p id="login-error" style={{ color: 'red' }}>{loginError}</p>}
</form>
</div>
<button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
<h2>Редактирование вкладок</h2>
<div className="tab-controls">
<button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
Общая информация
</button>
<button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
📅 Мероприятия
</button>
<button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
💰 Материальная помощь
</button>
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
🏠 Жилищные условия
</button>
{/* ✅ НОВАЯ ВКЛАДКА СЛАЙДЫ */}
<button className={`tab-btn ${activeTab === 'slides' ? 'active' : ''}`} onClick={() => setActiveTab('slides')} data-tab="slides">
📸 Слайды
</button>
</div>
{/* Рендеринг контента вкладок */}
{activeTab === 'slides' ? (
<SlidesManager />
) : (
<div id="editor-section">
<button id="logout-btn" style={{ float: 'right' }} onClick={handleLogout}>Выйти из системы</button>
<h2>Редактирование вкладок</h2>
<div className="tab-controls">
<button className={`tab-btn ${activeTab === 'info' ? 'active' : ''}`} onClick={() => handleTabChange('info')} data-tab="info">
Общая информация
</button>
<button className={`tab-btn ${activeTab === 'events' ? 'active' : ''}`} onClick={() => handleTabChange('events')} data-tab="events">
📅 Мероприятия
</button>
<button className={`tab-btn ${activeTab === 'help' ? 'active' : ''}`} onClick={() => handleTabChange('help')} data-tab="help">
💰 Материальная помощь
</button>
<button className={`tab-btn ${activeTab === 'housing' ? 'active' : ''}`} onClick={() => handleTabChange('housing')} data-tab="housing">
🏠 Жилищные условия
</button>
</div>
<>
<div style={{ margin: '18px 0' }}>
<button id="add-block-btn" onClick={handleAddBlock}>Добавить блок</button>
</div>
@@ -283,9 +282,10 @@ const authenticatedFetch = useCallback((url, options = {}) => {
</div>
))}
</div>
</div>
</>
)}
{/* Модалка редактирования блоков */}
{showEditModal && (
<div id="edit-modal" className="modal" style={{ display: 'block' }} onClick={(e) => {
if (e.target === e.currentTarget) handleTryCloseEditModal();
@@ -0,0 +1,395 @@
// src/components/SlidesManager.jsx
import React, { useState, useEffect, useCallback } from 'react';
import DOMPurify from 'dompurify';
function SlidesManager() {
const [slides, setSlides] = useState([]);
const [loading, setLoading] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingSlide, setEditingSlide] = useState(null);
const [formData, setFormData] = useState({
image_url: '',
title: '',
description: '',
link: '',
order: 0,
is_active: 1
});
const [uploading, setUploading] = useState(false);
const [notify, setNotify] = useState({ message: '', type: '', show: false });
const showNotify = useCallback((message, type = 'success') => {
setNotify({ message, type, show: true });
setTimeout(() => {
setNotify({ message: '', type: '', show: false });
}, 2500);
}, []);
// Загрузка слайдов
const loadSlides = useCallback(async () => {
setLoading(true);
try {
const res = await fetch('/moders/content/slides/all', {
credentials: 'include'
});
if (res.ok) {
const data = await res.json();
setSlides(Array.isArray(data) ? data : []);
} else {
showNotify('Ошибка загрузки слайдов', 'error');
}
} catch (e) {
showNotify('Ошибка загрузки слайдов', 'error');
} finally {
setLoading(false);
}
}, [showNotify]);
useEffect(() => {
loadSlides();
}, [loadSlides]);
// Открыть модалку для добавления
const handleAddSlide = () => {
setEditingSlide(null);
setFormData({
image_url: '',
title: '',
description: '',
link: '',
order: slides.length,
is_active: 1
});
setShowEditModal(true);
};
// Открыть модалку для редактирования
const handleEditSlide = (slide) => {
setEditingSlide(slide);
setFormData({
image_url: slide.image_url || '',
title: slide.title || '',
description: slide.description || '',
link: slide.link || '',
order: slide.order || 0,
is_active: slide.is_active !== undefined ? slide.is_active : 1
});
setShowEditModal(true);
};
// Сохранить слайд
// В функции handleSaveSlide
const handleSaveSlide = async (e) => {
e.preventDefault();
// ✅ ЛОГИ ДЛЯ ОТЛАДКИ
console.log('========================================');
console.log('📤 ОТПРАВКА ДАННЫХ НА СЕРВЕР');
console.log('📤 editingSlide:', editingSlide);
console.log('📤 formData:', JSON.stringify(formData, null, 2));
console.log('========================================');
if (!formData.image_url) {
showNotify('URL изображения обязателен', 'error');
return;
}
const url = editingSlide
? `/moders/content/slides/${editingSlide.id}`
: '/moders/content/slides';
const method = editingSlide ? 'PUT' : 'POST';
try {
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(formData)
});
const data = await res.json();
console.log('📥 ОТВЕТ СЕРВЕРА:', res.status, data);
if (res.ok) {
showNotify(editingSlide ? 'Слайд обновлён' : 'Слайд добавлен', 'success');
setShowEditModal(false);
loadSlides();
} else {
showNotify(data.message || `Ошибка: ${res.status}`, 'error');
}
} catch (e) {
console.error('❌ Ошибка:', e);
showNotify('Ошибка сохранения', 'error');
}
};
// Удалить слайд
const handleDeleteSlide = async (id) => {
if (!window.confirm('Удалить этот слайд?')) return;
try {
const res = await fetch(`/moders/content/slides/${id}`, {
method: 'DELETE',
credentials: 'include'
});
if (res.ok) {
showNotify('Слайд удалён', 'success');
loadSlides();
} else {
showNotify('Ошибка удаления', 'error');
}
} catch (e) {
showNotify('Ошибка удаления', 'error');
}
};
// Загрузка изображения
const handleUploadImage = async (file) => {
if (!file) return;
const formData = new FormData();
formData.append('image', file);
setUploading(true);
try {
const res = await fetch('/moders/content/slides/upload', {
method: 'POST',
credentials: 'include',
body: formData
});
if (res.ok) {
const data = await res.json();
setFormData(prev => ({ ...prev, image_url: data.path }));
showNotify('Изображение загружено', 'success');
} else {
showNotify('Ошибка загрузки изображения', 'error');
}
} catch (e) {
showNotify('Ошибка загрузки изображения', 'error');
} finally {
setUploading(false);
}
};
return (
<div id="slides-manager">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h2>📸 Слайды карусели</h2>
<button id="add-slide-btn" onClick={handleAddSlide}>+ Добавить слайд</button>
</div>
{/* Уведомления */}
{notify.show && (
<div className={`notify ${notify.type}`} style={{
padding: '10px 16px',
marginBottom: '15px',
borderRadius: '8px',
background: notify.type === 'success' ? '#d4edda' : '#f8d7da',
color: notify.type === 'success' ? '#155724' : '#721c24',
border: `1px solid ${notify.type === 'success' ? '#c3e6cb' : '#f5c6cb'}`
}}>
{notify.message}
</div>
)}
{/* Список слайдов */}
{loading ? (
<div style={{ textAlign: 'center', padding: '40px' }}>Загрузка...</div>
) : slides.length === 0 ? (
<p style={{ color: '#999', textAlign: 'center', padding: '40px' }}>Нет слайдов. Добавьте первый!</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))', gap: '20px' }}>
{slides.map(slide => (
<div key={slide.id} style={{
background: '#fff',
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: `2px solid ${slide.is_active ? '#28a745' : '#dc3545'}`,
position: 'relative'
}}>
<img
src={slide.image_url}
alt={slide.title || 'Слайд'}
style={{ width: '100%', height: '150px', objectFit: 'cover' }}
onError={(e) => {
e.target.src = 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="100%25" height="100%25"%3E%3Crect width="100%25" height="100%25" fill="%23ddd"/%3E%3Ctext x="50%25" y="50%25" text-anchor="middle" dy=".3em" fill="%23999" font-size="14"%3E%D0%9D%D0%B5%D1%82%20%D0%B8%D0%B7%D0%BE%D0%B1%D1%80%D0%B0%D0%B6%D0%B5%D0%BD%D0%B8%D1%8F%3C/text%3E%3C/svg%3E';
}}
/>
<div style={{ padding: '12px 16px' }}>
<h4 style={{ margin: '0 0 4px 0', fontSize: '16px' }}>{slide.title || 'Без названия'}</h4>
<p style={{ margin: '0 0 8px 0', fontSize: '13px', color: '#666', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{slide.description || ''}
</p>
<div style={{ display: 'flex', gap: '8px', fontSize: '12px', color: '#999', marginBottom: '10px' }}>
<span>Порядок: {slide.order}</span>
<span>•</span>
<span style={{ color: slide.is_active ? '#28a745' : '#dc3545' }}>
{slide.is_active ? 'Активен' : 'Неактивен'}
</span>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn-small" onClick={() => handleEditSlide(slide)}>✏️</button>
<button className="btn-small" onClick={() => handleDeleteSlide(slide.id)}>🗑️</button>
</div>
</div>
</div>
))}
</div>
)}
{/* Модалка редактирования */}
{showEditModal && (
<div className="modal" style={{
display: 'block',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0,0,0,0.5)',
backdropFilter: 'blur(4px)',
zIndex: 2000,
padding: '20px',
overflowY: 'auto'
}} onClick={(e) => {
if (e.target === e.currentTarget) setShowEditModal(false);
}}>
<div className="modal-content" style={{
maxWidth: '700px',
margin: '40px auto',
background: '#fff',
borderRadius: '16px',
padding: '30px',
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
position: 'relative'
}}>
<button className="close" style={{
position: 'absolute',
top: '12px',
right: '20px',
fontSize: '28px',
cursor: 'pointer',
background: 'none',
border: 'none',
color: '#999'
}} onClick={() => setShowEditModal(false)}>&times;</button>
<h3 style={{ marginBottom: '20px' }}>
{editingSlide ? 'Редактировать слайд' : 'Добавить слайд'}
</h3>
<form onSubmit={handleSaveSlide}>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>
Изображение URL или загрузить:
</label>
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
<input
type="text"
value={formData.image_url}
onChange={(e) => setFormData(prev => ({ ...prev, image_url: e.target.value }))}
placeholder="https://example.com/image.jpg"
style={{ flex: 1, padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
<label style={{
padding: '8px 16px',
background: '#6c757d',
color: '#fff',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '13px'
}}>
{uploading ? 'Загрузка...' : '📤 Загрузить'}
<input
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleUploadImage(file);
}}
/>
</label>
</div>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Заголовок:</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
placeholder="Заголовок слайда"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Описание:</label>
<textarea
value={formData.description}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Описание слайда"
rows="3"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div className="form-group" style={{ marginBottom: '15px' }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Ссылка (опционально):</label>
<input
type="text"
value={formData.link}
onChange={(e) => setFormData(prev => ({ ...prev, link: e.target.value }))}
placeholder="https://example.com"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div style={{ display: 'flex', gap: '20px', marginBottom: '15px' }}>
<div className="form-group" style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Порядок:</label>
<input
type="number"
value={formData.order}
onChange={(e) => setFormData(prev => ({ ...prev, order: parseInt(e.target.value) || 0 }))}
min="0"
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label style={{ display: 'block', marginBottom: '5px', fontWeight: 500 }}>Статус:</label>
<select
value={formData.is_active}
onChange={(e) => setFormData(prev => ({ ...prev, is_active: parseInt(e.target.value) }))}
style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #ddd' }}
>
<option value="1">Активен</option>
<option value="0">Неактивен</option>
</select>
</div>
</div>
<button type="submit" style={{
padding: '10px 30px',
background: '#6c5ce7',
color: '#fff',
border: 'none',
borderRadius: '10px',
cursor: 'pointer',
fontSize: '15px',
fontWeight: 500
}}>
{editingSlide ? 'Обновить' : 'Добавить'}
</button>
</form>
</div>
</div>
)}
</div>
);
}
export default SlidesManager;