continue
This commit is contained in:
parent
f093791c14
commit
833d125f64
|
|
@ -0,0 +1,41 @@
|
|||
/* Увеличиваем специфичность для кнопок Quill */
|
||||
.ql-toolbar .ql-picker-label,
|
||||
.ql-toolbar button,
|
||||
.ql-toolbar [role="button"] {
|
||||
all: unset; /* Сбрасываем все стили Pico (background, border, padding и т.д.) */
|
||||
display: inline-block; /* Восстанавливаем базовые стили Quill */
|
||||
cursor: pointer;
|
||||
padding: 0; /* Quill кнопки не имеют padding */
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit; /* Наследуем цвет от Quill */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
text-decoration: none; /* Убираем подчёркивание, если это <a> */
|
||||
}
|
||||
|
||||
/* Восстанавливаем hover/active стили Quill (если они сломались) */
|
||||
.ql-toolbar button:hover,
|
||||
.ql-toolbar [role="button"]:hover {
|
||||
color: #06c; /* Пример из Quill snow theme; адаптируйте */
|
||||
background: none; /* Без фона */
|
||||
}
|
||||
|
||||
.ql-toolbar button.ql-active,
|
||||
.ql-toolbar [role="button"].ql-active {
|
||||
color: #06c;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Для иконок (SVG в Quill) */
|
||||
.ql-toolbar .ql-icon {
|
||||
fill: currentColor; /* Убедимся, что иконки наследуют цвет */
|
||||
}
|
||||
|
||||
/* Если Quill использует <span> для кнопок */
|
||||
.ql-toolbar .ql-picker-item,
|
||||
.ql-toolbar .ql-picker-options [role="button"] {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
1211
assets/css/style.css
1211
assets/css/style.css
File diff suppressed because it is too large
Load Diff
|
|
@ -1,28 +1,69 @@
|
|||
// assets/js/autosave.js
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const contentTextarea = document.getElementById('content');
|
||||
const titleInput = document.getElementById('title');
|
||||
const statusSelect = document.getElementById('status');
|
||||
// Ждем инициализации редактора
|
||||
setTimeout(() => {
|
||||
initializeAutoSave();
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Проверяем, что это редактирование существующей главы
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const isEditMode = urlParams.has('id');
|
||||
function initializeAutoSave() {
|
||||
console.log('AutoSave: Initializing...');
|
||||
|
||||
if (!contentTextarea || !isEditMode) {
|
||||
console.log('Автосохранение отключено: создание новой главы');
|
||||
// Ищем активные редакторы Quill
|
||||
const quillEditors = document.querySelectorAll('.ql-editor');
|
||||
const textareas = document.querySelectorAll('textarea.writer-editor');
|
||||
|
||||
if (quillEditors.length === 0 || textareas.length === 0) {
|
||||
console.log('AutoSave: No Quill editors found, retrying in 1s...');
|
||||
setTimeout(initializeAutoSave, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`AutoSave: Found ${quillEditors.length} Quill editor(s)`);
|
||||
|
||||
// Для каждого редактора настраиваем автосейв
|
||||
quillEditors.forEach((quillEditor, index) => {
|
||||
const textarea = textareas[index];
|
||||
if (!textarea) return;
|
||||
|
||||
setupAutoSaveForEditor(quillEditor, textarea, index);
|
||||
});
|
||||
}
|
||||
|
||||
function setupAutoSaveForEditor(quillEditor, textarea, editorIndex) {
|
||||
let saveTimeout;
|
||||
let isSaving = false;
|
||||
let lastSavedContent = contentTextarea.value;
|
||||
let lastSavedContent = textarea.value;
|
||||
let changeCount = 0;
|
||||
|
||||
// Получаем экземпляр Quill из контейнера
|
||||
const quillContainer = quillEditor.closest('.ql-container');
|
||||
const quillInstance = quillContainer ? Quill.find(quillContainer) : null;
|
||||
|
||||
if (!quillInstance) {
|
||||
console.error(`AutoSave: Could not find Quill instance for editor ${editorIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`AutoSave: Setting up for editor ${editorIndex}`);
|
||||
|
||||
function showSaveMessage(message) {
|
||||
let messageEl = document.getElementById('autosave-message');
|
||||
if (!messageEl) {
|
||||
messageEl = document.createElement('div');
|
||||
messageEl.id = 'autosave-message';
|
||||
messageEl.style.cssText = 'position: fixed; top: 10px; right: 10px; padding: 8px 12px; background: #333; color: white; border-radius: 3px; z-index: 1000; font-size: 0.8rem;';
|
||||
messageEl.style.cssText = `
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
z-index: 10000;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
`;
|
||||
document.body.appendChild(messageEl);
|
||||
}
|
||||
|
||||
|
|
@ -31,51 +72,120 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
|
||||
setTimeout(() => {
|
||||
messageEl.style.display = 'none';
|
||||
}, 1500);
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
let messageEl = document.getElementById('autosave-message');
|
||||
if (!messageEl) {
|
||||
messageEl = document.createElement('div');
|
||||
messageEl.id = 'autosave-message';
|
||||
messageEl.style.cssText = `
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
right: 10px;
|
||||
padding: 8px 12px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border-radius: 3px;
|
||||
z-index: 10000;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
`;
|
||||
document.body.appendChild(messageEl);
|
||||
}
|
||||
|
||||
messageEl.textContent = message;
|
||||
messageEl.style.background = '#dc3545';
|
||||
messageEl.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
messageEl.style.display = 'none';
|
||||
messageEl.style.background = '#28a745';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function autoSave() {
|
||||
if (isSaving) return;
|
||||
if (isSaving) {
|
||||
console.log('AutoSave: Already saving, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentContent = contentTextarea.value;
|
||||
const currentTitle = titleInput ? titleInput.value : '';
|
||||
const currentStatus = statusSelect ? statusSelect.value : 'draft';
|
||||
const currentContent = textarea.value;
|
||||
|
||||
if (currentContent === lastSavedContent) return;
|
||||
// Проверяем, изменилось ли содержимое
|
||||
if (currentContent === lastSavedContent) {
|
||||
console.log('AutoSave: No changes detected');
|
||||
return;
|
||||
}
|
||||
|
||||
changeCount++;
|
||||
console.log(`AutoSave: Changes detected (${changeCount}), saving...`);
|
||||
|
||||
isSaving = true;
|
||||
|
||||
// Показываем индикатор сохранения
|
||||
showSaveMessage('Сохранение...');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('content', currentContent);
|
||||
formData.append('title', currentTitle);
|
||||
formData.append('status', currentStatus);
|
||||
formData.append('autosave', 'true');
|
||||
formData.append('csrf_token', document.querySelector('input[name="csrf_token"]').value);
|
||||
|
||||
fetch(window.location.href, {
|
||||
// Добавляем title если есть
|
||||
const titleInput = document.querySelector('input[name="title"]');
|
||||
if (titleInput) {
|
||||
formData.append('title', titleInput.value);
|
||||
}
|
||||
|
||||
// Добавляем status если есть
|
||||
const statusSelect = document.querySelector('select[name="status"]');
|
||||
if (statusSelect) {
|
||||
formData.append('status', statusSelect.value);
|
||||
}
|
||||
|
||||
formData.append('autosave', 'true');
|
||||
formData.append('csrf_token', document.querySelector('input[name="csrf_token"]')?.value || '');
|
||||
|
||||
const currentUrl = window.location.href;
|
||||
|
||||
fetch(currentUrl, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
lastSavedContent = currentContent;
|
||||
showSaveMessage('Сохранено: ' + new Date().toLocaleTimeString());
|
||||
showSaveMessage('Автосохранено: ' + new Date().toLocaleTimeString());
|
||||
console.log('AutoSave: Successfully saved');
|
||||
} else {
|
||||
throw new Error(data.error || 'Unknown error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Ошибка автосохранения:', error);
|
||||
console.error('AutoSave Error:', error);
|
||||
showError('Ошибка автосохранения: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
isSaving = false;
|
||||
});
|
||||
}
|
||||
|
||||
contentTextarea.addEventListener('input', function() {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(autoSave, 2000);
|
||||
// Слушаем изменения в Quill редакторе
|
||||
quillInstance.on('text-change', function(delta, oldDelta, source) {
|
||||
if (source === 'user') {
|
||||
console.log('AutoSave: Text changed by user');
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(autoSave, 2000); // Сохраняем через 2 секунды после изменения
|
||||
}
|
||||
});
|
||||
|
||||
// Также слушаем изменения в title и status
|
||||
const titleInput = document.querySelector('input[name="title"]');
|
||||
if (titleInput) {
|
||||
titleInput.addEventListener('input', function() {
|
||||
clearTimeout(saveTimeout);
|
||||
|
|
@ -83,16 +193,30 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
}
|
||||
|
||||
const statusSelect = document.querySelector('select[name="status"]');
|
||||
if (statusSelect) {
|
||||
statusSelect.addEventListener('change', autoSave);
|
||||
statusSelect.addEventListener('change', function() {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(autoSave, 1000);
|
||||
});
|
||||
}
|
||||
|
||||
// Предупреждение при закрытии страницы с несохраненными изменениями
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (contentTextarea.value !== lastSavedContent) {
|
||||
if (textarea.value !== lastSavedContent && !isSaving) {
|
||||
e.preventDefault();
|
||||
e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?';
|
||||
return e.returnValue;
|
||||
}
|
||||
});
|
||||
|
||||
//console.log('Автосохранение включено для редактирования главы');
|
||||
});
|
||||
// Периодическое сохранение каждые 30 секунд (на всякий случай)
|
||||
setInterval(() => {
|
||||
if (textarea.value !== lastSavedContent && !isSaving) {
|
||||
console.log('AutoSave: Periodic save triggered');
|
||||
autoSave();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
console.log(`AutoSave: Successfully set up for editor ${editorIndex}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
// assets/js/editor.js
|
||||
class WriterEditor {
|
||||
constructor() {
|
||||
this.editors = [];
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Инициализируем редакторы для текстовых областей с классом .writer-editor
|
||||
document.querySelectorAll('textarea.writer-editor').forEach(textarea => {
|
||||
this.initEditor(textarea);
|
||||
});
|
||||
}
|
||||
|
||||
initEditor(textarea) {
|
||||
// Создаем контейнер для Quill
|
||||
const editorContainer = document.createElement('div');
|
||||
editorContainer.className = 'writer-editor-container';
|
||||
editorContainer.style.height = '500px';
|
||||
editorContainer.style.marginBottom = '20px';
|
||||
|
||||
// Вставляем контейнер перед textarea
|
||||
textarea.parentNode.insertBefore(editorContainer, textarea);
|
||||
|
||||
// Скрываем оригинальный textarea
|
||||
textarea.style.display = 'none';
|
||||
|
||||
// Настройки Quill
|
||||
const quill = new Quill(editorContainer, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
[{ 'header': [1, 2, 3, false] }],
|
||||
['bold', 'italic', 'underline', 'strike'],
|
||||
['blockquote', 'code-block'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||
[{ 'direction': 'rtl' }],
|
||||
[{ 'size': ['small', false, 'large', 'huge'] }],
|
||||
[{ 'color': [] }, { 'background': [] }],
|
||||
[{ 'font': [] }],
|
||||
[{ 'align': [] }],
|
||||
['link', 'image', 'video'],
|
||||
['clean']
|
||||
],
|
||||
history: {
|
||||
delay: 1000,
|
||||
maxStack: 100,
|
||||
userOnly: true
|
||||
}
|
||||
},
|
||||
placeholder: 'Начните писать вашу главу...',
|
||||
formats: [
|
||||
'header', 'bold', 'italic', 'underline', 'strike',
|
||||
'blockquote', 'code-block', 'list', 'bullet',
|
||||
'script', 'indent', 'direction', 'size',
|
||||
'color', 'background', 'font', 'align',
|
||||
'link', 'image', 'video'
|
||||
]
|
||||
});
|
||||
|
||||
// Устанавливаем начальное содержимое
|
||||
if (textarea.value) {
|
||||
quill.root.innerHTML = textarea.value;
|
||||
}
|
||||
|
||||
// Обновляем textarea при изменении содержимого
|
||||
quill.on('text-change', () => {
|
||||
textarea.value = quill.root.innerHTML;
|
||||
});
|
||||
|
||||
// Сохраняем ссылку на редактор
|
||||
this.editors.push({
|
||||
quill: quill,
|
||||
textarea: textarea
|
||||
});
|
||||
|
||||
return quill;
|
||||
}
|
||||
|
||||
// Метод для получения HTML содержимого
|
||||
getContent(editorIndex = 0) {
|
||||
if (this.editors[editorIndex]) {
|
||||
return this.editors[editorIndex].quill.root.innerHTML;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
// Метод для установки содержимого
|
||||
setContent(content, editorIndex = 0) {
|
||||
if (this.editors[editorIndex]) {
|
||||
this.editors[editorIndex].quill.root.innerHTML = content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Инициализация редактора при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
window.writerEditor = new WriterEditor();
|
||||
});
|
||||
|
|
@ -1,575 +0,0 @@
|
|||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const contentTextarea = document.getElementById('content');
|
||||
const previewForm = document.getElementById('preview-form');
|
||||
|
||||
if (!contentTextarea) return;
|
||||
|
||||
let isFullscreen = false;
|
||||
let originalStyles = {};
|
||||
let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||||
|
||||
|
||||
function normalizeContent(text) {
|
||||
// Заменяем множественные переносы на двойные
|
||||
text = text.replace(/\n{3,}/g, '\n\n');
|
||||
// Убираем пустые строки в начале и конце
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
initEditor();
|
||||
|
||||
function initEditor() {
|
||||
// Нормализуем контент при загрузке
|
||||
if (contentTextarea.value) {
|
||||
contentTextarea.value = normalizeContent(contentTextarea.value);
|
||||
}
|
||||
autoResize();
|
||||
contentTextarea.addEventListener('input', autoResize);
|
||||
contentTextarea.addEventListener('input', processDialogues);
|
||||
contentTextarea.addEventListener('keydown', handleTab);
|
||||
contentTextarea.addEventListener('input', updatePreviewContent);
|
||||
|
||||
updatePreviewContent();
|
||||
addControlButtons();
|
||||
|
||||
// На мобильных устройствах добавляем обработчик изменения ориентации
|
||||
if (isMobile) {
|
||||
window.addEventListener('orientationchange', function() {
|
||||
if (isFullscreen) {
|
||||
setTimeout(adjustForMobileKeyboard, 300);
|
||||
}
|
||||
});
|
||||
|
||||
// Обработчик для виртуальной клавиатуры
|
||||
window.addEventListener('resize', function() {
|
||||
if (isFullscreen && isMobile) {
|
||||
adjustForMobileKeyboard();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function autoResize() {
|
||||
if (isFullscreen) return;
|
||||
|
||||
contentTextarea.style.height = 'auto';
|
||||
contentTextarea.style.height = contentTextarea.scrollHeight + 'px';
|
||||
}
|
||||
|
||||
function processDialogues() {
|
||||
const lines = contentTextarea.value.split('\n');
|
||||
let changed = false;
|
||||
|
||||
const processedLines = lines.map(line => {
|
||||
if (line.trim().startsWith('- ') && line.trim().length > 2) {
|
||||
const trimmed = line.trim();
|
||||
const restOfLine = trimmed.substring(2);
|
||||
if (/^[a-zA-Zа-яА-Я]/.test(restOfLine)) {
|
||||
changed = true;
|
||||
return line.replace(trimmed, `— ${restOfLine}`);
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
if (changed) {
|
||||
const cursorPos = contentTextarea.selectionStart;
|
||||
contentTextarea.value = processedLines.join('\n');
|
||||
contentTextarea.setSelectionRange(cursorPos, cursorPos);
|
||||
if (!isFullscreen) autoResize();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTab(e) {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const start = contentTextarea.selectionStart;
|
||||
const end = contentTextarea.selectionEnd;
|
||||
|
||||
contentTextarea.value = contentTextarea.value.substring(0, start) + ' ' + contentTextarea.value.substring(end);
|
||||
contentTextarea.selectionStart = contentTextarea.selectionEnd = start + 4;
|
||||
if (!isFullscreen) autoResize();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreviewContent() {
|
||||
if (previewForm) {
|
||||
document.getElementById('preview-content').value = contentTextarea.value;
|
||||
}
|
||||
}
|
||||
|
||||
function adjustForMobileKeyboard() {
|
||||
if (!isMobile || !isFullscreen) return;
|
||||
|
||||
// На мобильных устройствах уменьшаем высоту textarea, чтобы клавиатура не перекрывала контент
|
||||
const viewportHeight = window.innerHeight;
|
||||
const keyboardHeight = viewportHeight * 0.4; // Предполагаемая высота клавиатуры (40% экрана)
|
||||
const availableHeight = viewportHeight - keyboardHeight - 80; // 80px для кнопок и отступов
|
||||
|
||||
contentTextarea.style.height = availableHeight + 'px';
|
||||
contentTextarea.style.paddingBottom = '20px';
|
||||
|
||||
// Прокручиваем к курсору
|
||||
setTimeout(() => {
|
||||
const cursorPos = contentTextarea.selectionStart;
|
||||
if (cursorPos > 0) {
|
||||
scrollToCursor();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function scrollToCursor() {
|
||||
const textarea = contentTextarea;
|
||||
const cursorPos = textarea.selectionStart;
|
||||
|
||||
// Создаем временный элемент для измерения позиции курсора
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.cssText = `
|
||||
position: absolute;
|
||||
top: -1000px;
|
||||
left: -1000px;
|
||||
width: ${textarea.clientWidth}px;
|
||||
padding: ${textarea.style.padding};
|
||||
font: ${getComputedStyle(textarea).font};
|
||||
line-height: ${textarea.style.lineHeight};
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
visibility: hidden;
|
||||
`;
|
||||
|
||||
const textBeforeCursor = textarea.value.substring(0, cursorPos);
|
||||
tempDiv.textContent = textBeforeCursor;
|
||||
|
||||
document.body.appendChild(tempDiv);
|
||||
const textHeight = tempDiv.offsetHeight;
|
||||
document.body.removeChild(tempDiv);
|
||||
|
||||
// Прокручиваем так, чтобы курсор был виден
|
||||
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 24;
|
||||
const visibleHeight = textarea.clientHeight;
|
||||
const cursorLine = Math.floor(textHeight / lineHeight);
|
||||
const visibleLines = Math.floor(visibleHeight / lineHeight);
|
||||
|
||||
const targetScroll = Math.max(0, (cursorLine - Math.floor(visibleLines / 3)) * lineHeight);
|
||||
|
||||
textarea.scrollTop = targetScroll;
|
||||
}
|
||||
|
||||
function addControlButtons() {
|
||||
const container = contentTextarea.parentElement;
|
||||
|
||||
const controlsContainer = document.createElement('div');
|
||||
controlsContainer.className = 'editor-controls';
|
||||
|
||||
const fullscreenBtn = createButton('🔲', 'Полноэкранный режим', toggleFullscreen);
|
||||
const helpBtn = createButton('❓', 'Справка по Markdown', showHelp);
|
||||
|
||||
controlsContainer.appendChild(fullscreenBtn);
|
||||
controlsContainer.appendChild(helpBtn);
|
||||
|
||||
container.insertBefore(controlsContainer, contentTextarea);
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!isFullscreen) {
|
||||
enterFullscreen();
|
||||
} else {
|
||||
exitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
function enterFullscreen() {
|
||||
originalStyles = {
|
||||
position: contentTextarea.style.position,
|
||||
top: contentTextarea.style.top,
|
||||
left: contentTextarea.style.left,
|
||||
width: contentTextarea.style.width,
|
||||
height: contentTextarea.style.height,
|
||||
zIndex: contentTextarea.style.zIndex,
|
||||
backgroundColor: contentTextarea.style.backgroundColor,
|
||||
border: contentTextarea.style.border,
|
||||
borderRadius: contentTextarea.style.borderRadius,
|
||||
fontSize: contentTextarea.style.fontSize,
|
||||
padding: contentTextarea.style.padding,
|
||||
margin: contentTextarea.style.margin
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
// Для мобильных - адаптивный режим с учетом клавиатуры
|
||||
const viewportHeight = window.innerHeight;
|
||||
const availableHeight = viewportHeight - 100; // Оставляем место для кнопок
|
||||
|
||||
Object.assign(contentTextarea.style, {
|
||||
position: 'fixed',
|
||||
top: '50px',
|
||||
left: '0',
|
||||
width: '100vw',
|
||||
height: availableHeight + 'px',
|
||||
zIndex: '9998',
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #007bff',
|
||||
borderRadius: '0',
|
||||
fontSize: '18px',
|
||||
padding: '15px',
|
||||
margin: '0',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'none'
|
||||
});
|
||||
|
||||
// На мобильных устройствах фокусируем textarea сразу
|
||||
setTimeout(() => {
|
||||
contentTextarea.focus();
|
||||
}, 300);
|
||||
} else {
|
||||
// Для ПК - классический полноэкранный режим
|
||||
Object.assign(contentTextarea.style, {
|
||||
position: 'fixed',
|
||||
top: '5vh',
|
||||
left: '5vw',
|
||||
width: '90vw',
|
||||
height: '90vh',
|
||||
zIndex: '9998',
|
||||
backgroundColor: 'white',
|
||||
border: '2px solid #007bff',
|
||||
borderRadius: '8px',
|
||||
fontSize: '16px',
|
||||
padding: '20px',
|
||||
margin: '0',
|
||||
boxSizing: 'border-box',
|
||||
resize: 'none'
|
||||
});
|
||||
}
|
||||
|
||||
controlsContainer.style.display = 'none';
|
||||
createFullscreenControls();
|
||||
|
||||
isFullscreen = true;
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
function exitFullscreen() {
|
||||
Object.assign(contentTextarea.style, originalStyles);
|
||||
|
||||
controlsContainer.style.display = 'flex';
|
||||
removeFullscreenControls();
|
||||
|
||||
isFullscreen = false;
|
||||
document.body.style.overflow = '';
|
||||
|
||||
autoResize();
|
||||
}
|
||||
|
||||
function createFullscreenControls() {
|
||||
const fullscreenControls = document.createElement('div');
|
||||
fullscreenControls.id = 'fullscreen-controls';
|
||||
|
||||
const exitBtn = createButton('❌', 'Выйти из полноэкранного режима', exitFullscreen);
|
||||
const helpBtnFullscreen = createButton('❓', 'Справка по Markdown', showHelp);
|
||||
|
||||
// Для мобильных увеличиваем кнопки и добавляем отступы
|
||||
const buttonSize = isMobile ? '60px' : '50px';
|
||||
const fontSize = isMobile ? '24px' : '20px';
|
||||
const topPosition = isMobile ? '10px' : '15px';
|
||||
|
||||
[exitBtn, helpBtnFullscreen].forEach(btn => {
|
||||
btn.style.cssText = `
|
||||
width: ${buttonSize};
|
||||
height: ${buttonSize};
|
||||
border-radius: 50%;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: ${fontSize};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
||||
transition: all 0.3s ease;
|
||||
color: #333333;
|
||||
touch-action: manipulation;
|
||||
`;
|
||||
});
|
||||
|
||||
fullscreenControls.appendChild(helpBtnFullscreen);
|
||||
fullscreenControls.appendChild(exitBtn);
|
||||
|
||||
fullscreenControls.style.cssText = `
|
||||
position: fixed;
|
||||
top: ${topPosition};
|
||||
right: 10px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
`;
|
||||
|
||||
document.body.appendChild(fullscreenControls);
|
||||
|
||||
// Предотвращаем всплытие событий от кнопок к textarea
|
||||
fullscreenControls.addEventListener('touchstart', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
fullscreenControls.addEventListener('touchend', function(e) {
|
||||
e.stopPropagation();
|
||||
});
|
||||
}
|
||||
|
||||
function removeFullscreenControls() {
|
||||
const fullscreenControls = document.getElementById('fullscreen-controls');
|
||||
if (fullscreenControls) {
|
||||
fullscreenControls.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Выход по ESC
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
exitFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
// На мобильных устройствах добавляем обработчик для выхода по тапу вне textarea
|
||||
if (isMobile) {
|
||||
document.addEventListener('touchstart', function(e) {
|
||||
if (isFullscreen && !contentTextarea.contains(e.target) &&
|
||||
!document.getElementById('fullscreen-controls')?.contains(e.target)) {
|
||||
exitFullscreen();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Обработчик фокуса для мобильных устройств
|
||||
if (isMobile) {
|
||||
contentTextarea.addEventListener('focus', function() {
|
||||
if (isFullscreen) {
|
||||
setTimeout(adjustForMobileKeyboard, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createButton(icon, title, onClick) {
|
||||
const button = document.createElement('button');
|
||||
button.innerHTML = icon;
|
||||
button.title = title;
|
||||
button.type = 'button';
|
||||
|
||||
const buttonSize = isMobile ? '50px' : '40px';
|
||||
const fontSize = isMobile ? '20px' : '16px';
|
||||
|
||||
button.style.cssText = `
|
||||
width: ${buttonSize};
|
||||
height: ${buttonSize};
|
||||
border-radius: 50%;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
font-size: ${fontSize};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
transition: all 0.3s ease;
|
||||
color: #333333;
|
||||
touch-action: manipulation;
|
||||
`;
|
||||
|
||||
button.addEventListener('mouseenter', function() {
|
||||
this.style.transform = 'scale(1.1)';
|
||||
this.style.backgroundColor = '#f8f9fa';
|
||||
this.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
|
||||
});
|
||||
|
||||
button.addEventListener('mouseleave', function() {
|
||||
this.style.transform = 'scale(1)';
|
||||
this.style.backgroundColor = 'white';
|
||||
this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
|
||||
});
|
||||
|
||||
button.addEventListener('click', onClick);
|
||||
|
||||
// Для мобильных устройств
|
||||
button.addEventListener('touchstart', function(e) {
|
||||
e.stopPropagation();
|
||||
this.style.transform = 'scale(1.1)';
|
||||
this.style.backgroundColor = '#f8f9fa';
|
||||
this.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
|
||||
});
|
||||
|
||||
button.addEventListener('touchend', function(e) {
|
||||
e.stopPropagation();
|
||||
this.style.transform = 'scale(1)';
|
||||
this.style.backgroundColor = 'white';
|
||||
this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
|
||||
onClick();
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function showHelp() {
|
||||
const helpContent = `
|
||||
<div style="font-family: system-ui, sans-serif; line-height: 1.6; color: #333;">
|
||||
<h1 style="color: #007bff; margin-top: 0; border-bottom: 2px solid #007bff; padding-bottom: 10px;">Справка по Markdown</h1>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="color: #555;">Основное форматирование</h2>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #007bff;">
|
||||
<p><strong>Жирный текст:</strong> **текст** или __текст__</p>
|
||||
<p><em>Наклонный текст:</em> *текст* или _текст_</p>
|
||||
<p><u>Подчеркнутый текст:</u> <u>текст</u></p>
|
||||
<p><del>Зачеркнутый текст:</del> ~~текст~~</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="color: #555;">Заголовки</h2>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #28a745;">
|
||||
<h1 style="margin: 10px 0; font-size: 1.5em;">Заголовок 1 (# Заголовок)</h1>
|
||||
<h2 style="margin: 10px 0; font-size: 1.3em;">Заголовок 2 (## Заголовок)</h2>
|
||||
<h3 style="margin: 10px 0; font-size: 1.1em;">Заголовок 3 (### Заголовок)</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="color: #555;">Цитаты</h2>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #ffc107;">
|
||||
<blockquote style="margin: 0; padding-left: 15px; border-left: 3px solid #ddd; color: #666;">
|
||||
> Это цитата
|
||||
</blockquote>
|
||||
<blockquote style="margin: 10px 0 0 20px; padding-left: 15px; border-left: 3px solid #ddd; color: #666;">
|
||||
> > Вложенная цитата
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="color: #555;">Диалоги</h2>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #dc3545;">
|
||||
<p><strong>Автоматическое преобразование:</strong></p>
|
||||
<p><code>- Привет!</code> → <em>— Привет!</em></p>
|
||||
<p style="font-size: 0.9em; color: #666; margin-top: 5px;">
|
||||
Дефис в начале строки автоматически заменяется на тире с пробелом
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="color: #555;">Списки</h2>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #6f42c1;">
|
||||
<p><strong>Маркированный список:</strong></p>
|
||||
<ul style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>- Элемент списка</li>
|
||||
<li>- Другой элемент</li>
|
||||
</ul>
|
||||
<p><strong>Нумерованный список:</strong></p>
|
||||
<ol style="margin: 10px 0; padding-left: 20px;">
|
||||
<li>1. Первый элемент</li>
|
||||
<li>2. Второй элемент</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 20px;">
|
||||
<h2 style="color: #555;">Код</h2>
|
||||
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #fd7e14;">
|
||||
<p><strong>Код в строке:</strong></p>
|
||||
<p><code>\`код в строке\`</code></p>
|
||||
<p><strong>Блок кода:</strong></p>
|
||||
<pre style="background: #e9ecef; padding: 10px; border-radius: 3px; overflow-x: auto; margin: 10px 0;">
|
||||
\`\`\`
|
||||
блок кода
|
||||
многострочный
|
||||
\`\`\`</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: #e7f3ff; padding: 15px; border-radius: 5px; border-left: 4px solid #007bff; margin-top: 20px;">
|
||||
<p style="margin: 0; font-size: 0.9em;"><strong>💡 Подсказка:</strong> Используйте кнопку "👁️ Предпросмотр" чтобы увидеть как будет выглядеть готовый текст!</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = `
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
z-index: 10000;
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
`;
|
||||
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.innerHTML = '✕';
|
||||
closeBtn.title = 'Закрыть справку';
|
||||
closeBtn.style.cssText = `
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
background: #ff4444;
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.3s ease;
|
||||
`;
|
||||
|
||||
closeBtn.addEventListener('mouseenter', function() {
|
||||
this.style.background = '#cc0000';
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('mouseleave', function() {
|
||||
this.style.background = '#ff4444';
|
||||
});
|
||||
|
||||
closeBtn.addEventListener('click', function() {
|
||||
modal.remove();
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
modal.innerHTML = helpContent;
|
||||
modal.appendChild(closeBtn);
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
overlay.addEventListener('click', function() {
|
||||
modal.remove();
|
||||
overlay.remove();
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const closeHandler = function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
modal.remove();
|
||||
overlay.remove();
|
||||
document.removeEventListener('keydown', closeHandler);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', closeHandler);
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
<?php
|
||||
require_once 'controllers/BaseController.php';
|
||||
require_once 'models/User.php';
|
||||
|
||||
class AdminController extends BaseController {
|
||||
public function __construct() {
|
||||
parent::__construct();
|
||||
$this->requireAdmin();
|
||||
}
|
||||
|
||||
|
||||
public function users() {
|
||||
$userModel = new User($this->pdo);
|
||||
$users = $userModel->findAll();
|
||||
|
||||
$this->render('admin/users', [
|
||||
'users' => $users,
|
||||
'page_title' => 'Управление пользователями'
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggleUserStatus($user_id) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = "Неверный метод запроса или токен безопасности";
|
||||
$this->redirect('/admin/users');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user_id == $_SESSION['user_id']) {
|
||||
$_SESSION['error'] = "Нельзя изменить статус собственного аккаунта";
|
||||
$this->redirect('/admin/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$userModel = new User($this->pdo);
|
||||
$user = $userModel->findById($user_id);
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = "Пользователь не найден";
|
||||
$this->redirect('/admin/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$newStatus = $user['is_active'] ? 0 : 1;
|
||||
if ($userModel->updateStatus($user_id, $newStatus)) {
|
||||
$_SESSION['success'] = "Статус пользователя обновлен";
|
||||
} else {
|
||||
$_SESSION['error'] = "Ошибка при обновлении статуса";
|
||||
}
|
||||
|
||||
$this->redirect('/admin/users');
|
||||
}
|
||||
|
||||
public function deleteUser($user_id) {
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = "Неверный метод запроса или токен безопасности";
|
||||
$this->redirect('/admin/users');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user_id == $_SESSION['user_id']) {
|
||||
$_SESSION['error'] = "Нельзя удалить собственный аккаунт";
|
||||
$this->redirect('/admin/users');
|
||||
return;
|
||||
}
|
||||
|
||||
$userModel = new User($this->pdo);
|
||||
$user = $userModel->findById($user_id);
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = "Пользователь не найден";
|
||||
$this->redirect('/admin/users');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($userModel->delete($user_id)) {
|
||||
$_SESSION['success'] = "Пользователь успешно удален";
|
||||
} else {
|
||||
$_SESSION['error'] = "Ошибка при удалении пользователя";
|
||||
}
|
||||
|
||||
$this->redirect('/admin/users');
|
||||
}
|
||||
|
||||
public function addUser() {
|
||||
$error = '';
|
||||
$success = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$error = "Ошибка безопасности";
|
||||
} else {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$password_confirm = $_POST['password_confirm'] ?? '';
|
||||
$email = trim($_POST['email'] ?? '');
|
||||
$display_name = trim($_POST['display_name'] ?? '');
|
||||
$is_active = isset($_POST['is_active']) ? 1 : 0;
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
$error = 'Имя пользователя и пароль обязательны';
|
||||
} elseif ($password !== $password_confirm) {
|
||||
$error = 'Пароли не совпадают';
|
||||
} elseif (strlen($password) < 6) {
|
||||
$error = 'Пароль должен быть не менее 6 символов';
|
||||
} else {
|
||||
$userModel = new User($this->pdo);
|
||||
if ($userModel->findByUsername($username)) {
|
||||
$error = 'Имя пользователя уже занято';
|
||||
} elseif (!empty($email) && $userModel->findByEmail($email)) {
|
||||
$error = 'Email уже используется';
|
||||
} else {
|
||||
$data = [
|
||||
'username' => $username,
|
||||
'password' => $password,
|
||||
'email' => $email ?: null,
|
||||
'display_name' => $display_name ?: $username,
|
||||
'is_active' => $is_active
|
||||
];
|
||||
|
||||
if ($userModel->create($data)) {
|
||||
$success = 'Пользователь успешно создан';
|
||||
// Очищаем поля формы
|
||||
$_POST = [];
|
||||
} else {
|
||||
$error = 'Ошибка при создании пользователя';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->render('admin/add_user', [
|
||||
'error' => $error,
|
||||
'success' => $success,
|
||||
'page_title' => 'Добавление пользователя'
|
||||
]);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -24,6 +24,23 @@ class BaseController {
|
|||
}
|
||||
}
|
||||
|
||||
protected function requireAdmin() {
|
||||
if (!is_logged_in()) {
|
||||
$this->redirect('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
global $pdo;
|
||||
$userModel = new User($pdo);
|
||||
$user = $userModel->findById($_SESSION['user_id']);
|
||||
|
||||
if (!$user || $user['id'] != 1) { // Предполагаем, что администратор имеет ID = 1
|
||||
$_SESSION['error'] = "У вас нет прав администратора";
|
||||
$this->redirect('/dashboard');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
protected function jsonResponse($data) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode($data);
|
||||
|
|
|
|||
|
|
@ -21,12 +21,8 @@ class BookController extends BaseController {
|
|||
$this->requireLogin();
|
||||
$seriesModel = new Series($this->pdo);
|
||||
$series = $seriesModel->findByUser($_SESSION['user_id']);
|
||||
|
||||
// Возвращаем типы редакторов для выбора
|
||||
$editor_types = [
|
||||
'markdown' => 'Markdown редактор',
|
||||
'html' => 'HTML редактор (TinyMCE)'
|
||||
];
|
||||
$error = '';
|
||||
$cover_error = '';
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
|
|
@ -46,25 +42,38 @@ class BookController extends BaseController {
|
|||
'description' => trim($_POST['description'] ?? ''),
|
||||
'genre' => trim($_POST['genre'] ?? ''),
|
||||
'user_id' => $_SESSION['user_id'],
|
||||
'editor_type' => $_POST['editor_type'] ?? 'markdown',
|
||||
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
|
||||
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
|
||||
'published' => isset($_POST['published']) ? 1 : 0
|
||||
];
|
||||
|
||||
if ($bookModel->create($data)) {
|
||||
$_SESSION['success'] = "Книга успешно создана";
|
||||
$new_book_id = $this->pdo->lastInsertId();
|
||||
$this->redirect("/books/{$new_book_id}/edit");
|
||||
|
||||
// Обработка загрузки обложки
|
||||
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
|
||||
$cover_result = handleCoverUpload($_FILES['cover_image'], $new_book_id);
|
||||
if ($cover_result['success']) {
|
||||
$bookModel->updateCover($new_book_id, $cover_result['filename']);
|
||||
} else {
|
||||
$cover_error = $cover_result['error'];
|
||||
// Сохраняем ошибку в сессии, чтобы показать после редиректа
|
||||
$_SESSION['cover_error'] = $cover_error;
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : "");
|
||||
$this->redirect("/books/{$new_book_id}/edit");
|
||||
} else {
|
||||
$_SESSION['error'] = "Ошибка при создании книги";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$this->render('books/create', [
|
||||
'series' => $series,
|
||||
'editor_types' => $editor_types,
|
||||
'selected_editor' => 'markdown', // по умолчанию
|
||||
'error' => $error,
|
||||
'cover_error' => $cover_error,
|
||||
'page_title' => 'Создание новой книги'
|
||||
]);
|
||||
}
|
||||
|
|
@ -82,11 +91,6 @@ class BookController extends BaseController {
|
|||
$seriesModel = new Series($this->pdo);
|
||||
$series = $seriesModel->findByUser($_SESSION['user_id']);
|
||||
|
||||
// Типы редакторов для выбора
|
||||
$editor_types = [
|
||||
'markdown' => 'Markdown редактор',
|
||||
'html' => 'HTML редактор (TinyMCE)'
|
||||
];
|
||||
|
||||
$error = '';
|
||||
$cover_error = '';
|
||||
|
|
@ -99,29 +103,16 @@ class BookController extends BaseController {
|
|||
if (empty($title)) {
|
||||
$error = "Название книги обязательно";
|
||||
} else {
|
||||
$old_editor_type = $book['editor_type'];
|
||||
$new_editor_type = $_POST['editor_type'] ?? 'markdown';
|
||||
$editor_changed = ($old_editor_type !== $new_editor_type);
|
||||
|
||||
$data = [
|
||||
'title' => $title,
|
||||
'description' => trim($_POST['description'] ?? ''),
|
||||
'genre' => trim($_POST['genre'] ?? ''),
|
||||
'user_id' => $_SESSION['user_id'],
|
||||
'editor_type' => $new_editor_type,
|
||||
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
|
||||
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
|
||||
'published' => isset($_POST['published']) ? 1 : 0
|
||||
];
|
||||
|
||||
// Обработка смены редактора (прежде чем обновлять книгу)
|
||||
if ($editor_changed) {
|
||||
$conversion_success = $bookModel->convertChaptersContent($id, $old_editor_type, $new_editor_type);
|
||||
if (!$conversion_success) {
|
||||
$_SESSION['warning'] = "Внимание: не удалось автоматически сконвертировать содержание всех глав.";
|
||||
}
|
||||
}
|
||||
|
||||
// Обработка обложки
|
||||
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
|
||||
$cover_result = handleCoverUpload($_FILES['cover_image'], $id);
|
||||
|
|
@ -142,9 +133,6 @@ class BookController extends BaseController {
|
|||
|
||||
if ($success) {
|
||||
$success_message = "Книга успешно обновлена";
|
||||
if ($editor_changed) {
|
||||
$success_message .= ". Содержание глав сконвертировано в новый формат.";
|
||||
}
|
||||
$_SESSION['success'] = $success_message;
|
||||
$this->redirect("/books/{$id}/edit");
|
||||
} else {
|
||||
|
|
@ -162,7 +150,6 @@ class BookController extends BaseController {
|
|||
'book' => $book,
|
||||
'series' => $series,
|
||||
'chapters' => $chapters,
|
||||
'editor_types' => $editor_types,
|
||||
'error' => $error,
|
||||
'cover_error' => $cover_error,
|
||||
'page_title' => 'Редактирование книги'
|
||||
|
|
@ -193,6 +180,69 @@ class BookController extends BaseController {
|
|||
$this->redirect('/books');
|
||||
}
|
||||
|
||||
|
||||
public function deleteAll() {
|
||||
$this->requireLogin();
|
||||
$user_id = $_SESSION['user_id'];
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = "Ошибка безопасности";
|
||||
$this->redirect('/books');
|
||||
}
|
||||
|
||||
$bookModel = new Book($this->pdo);
|
||||
|
||||
// Получаем все книги пользователя
|
||||
$books = $bookModel->findByUser($user_id);
|
||||
if (empty($books)) {
|
||||
$_SESSION['info'] = "У вас нет книг для удаления";
|
||||
$this->redirect('/books');
|
||||
}
|
||||
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
$deleted_count = 0;
|
||||
$deleted_covers = 0;
|
||||
|
||||
foreach ($books as $book) {
|
||||
// Удаляем обложку если она есть
|
||||
if (!empty($book['cover_image'])) {
|
||||
$cover_path = COVERS_PATH . $book['cover_image'];
|
||||
if (file_exists($cover_path) && unlink($cover_path)) {
|
||||
$deleted_covers++;
|
||||
}
|
||||
}
|
||||
|
||||
// Удаляем главы книги
|
||||
$stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id = ?");
|
||||
$stmt->execute([$book['id']]);
|
||||
|
||||
// Удаляем саму книгу
|
||||
$stmt = $this->pdo->prepare("DELETE FROM books WHERE id = ? AND user_id = ?");
|
||||
$stmt->execute([$book['id'], $user_id]);
|
||||
|
||||
$deleted_count++;
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
|
||||
$message = "Все книги успешно удалены ($deleted_count книг";
|
||||
if ($deleted_covers > 0) {
|
||||
$message .= ", удалено $deleted_covers обложек";
|
||||
}
|
||||
$message .= ")";
|
||||
|
||||
$_SESSION['success'] = $message;
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
error_log("Ошибка при массовом удалении: " . $e->getMessage());
|
||||
$_SESSION['error'] = "Произошла ошибка при удалении книг: " . $e->getMessage();
|
||||
}
|
||||
|
||||
$this->redirect('/books');
|
||||
}
|
||||
|
||||
public function viewPublic($share_token) {
|
||||
$bookModel = new Book($this->pdo);
|
||||
$book = $bookModel->findByShareToken($share_token);
|
||||
|
|
@ -216,29 +266,6 @@ class BookController extends BaseController {
|
|||
]);
|
||||
}
|
||||
|
||||
public function normalizeContent($id) {
|
||||
$this->requireLogin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$_SESSION['error'] = "Неверный метод запроса";
|
||||
$this->redirect("/books/{$id}/edit");
|
||||
}
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = "Ошибка безопасности";
|
||||
$this->redirect("/books/{$id}/edit");
|
||||
}
|
||||
$user_id = $_SESSION['user_id'];
|
||||
$bookModel = new Book($this->pdo);
|
||||
if (!$bookModel->userOwnsBook($id, $user_id)) {
|
||||
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
||||
$this->redirect('/books');
|
||||
}
|
||||
if ($bookModel->normalizeBookContent($id)) {
|
||||
$_SESSION['success'] = "Контент глав успешно нормализован";
|
||||
} else {
|
||||
$_SESSION['error'] = "Ошибка при нормализации контента";
|
||||
}
|
||||
$this->redirect("/books/{$id}/edit");
|
||||
}
|
||||
|
||||
public function regenerateToken($id) {
|
||||
$this->requireLogin();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
require_once 'controllers/BaseController.php';
|
||||
require_once 'models/Chapter.php';
|
||||
require_once 'models/Book.php';
|
||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
||||
|
||||
class ChapterController extends BaseController {
|
||||
|
||||
|
|
@ -94,11 +93,29 @@ class ChapterController extends BaseController {
|
|||
|
||||
// Проверяем права доступа к главе
|
||||
if (!$chapterModel->userOwnsChapter($id, $user_id)) {
|
||||
if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
||||
// Для AJAX запросов возвращаем JSON
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Доступ запрещен']);
|
||||
exit;
|
||||
}
|
||||
$_SESSION['error'] = "У вас нет доступа к этой главе";
|
||||
$this->redirect('/books');
|
||||
}
|
||||
|
||||
$chapter = $chapterModel->findById($id);
|
||||
|
||||
// Дополнительная проверка - глава должна существовать
|
||||
if (!$chapter) {
|
||||
if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Глава не найдена']);
|
||||
exit;
|
||||
}
|
||||
$_SESSION['error'] = "Глава не найдена";
|
||||
$this->redirect('/books');
|
||||
}
|
||||
|
||||
$book = $bookModel->findById($chapter['book_id']);
|
||||
$error = '';
|
||||
|
||||
|
|
@ -119,6 +136,20 @@ class ChapterController extends BaseController {
|
|||
'status' => $status
|
||||
];
|
||||
|
||||
// Если это запрос автосейва, возвращаем JSON ответ
|
||||
if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
||||
if ($chapterModel->update($id, $data)) {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => true]);
|
||||
exit;
|
||||
} else {
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Ошибка при сохранении']);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
// Обычный POST запрос (сохранение формы)
|
||||
if ($chapterModel->update($id, $data)) {
|
||||
$_SESSION['success'] = "Глава успешно обновлена";
|
||||
$this->redirect("/books/{$chapter['book_id']}/chapters");
|
||||
|
|
@ -174,23 +205,12 @@ class ChapterController extends BaseController {
|
|||
|
||||
public function preview() {
|
||||
$this->requireLogin();
|
||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
||||
$Parsedown = new ParsedownExtra();
|
||||
|
||||
$content = $_POST['content'] ?? '';
|
||||
$title = $_POST['title'] ?? 'Предпросмотр';
|
||||
$editor_type = $_POST['editor_type'] ?? 'markdown';
|
||||
|
||||
// Обрабатываем контент в зависимости от типа редактора
|
||||
if ($editor_type == 'markdown') {
|
||||
// Нормализуем Markdown перед преобразованием
|
||||
$normalized_content = $this->normalizeMarkdownContent($content);
|
||||
$html_content = $Parsedown->text($normalized_content);
|
||||
} else {
|
||||
// Для HTML редактора нормализуем контент
|
||||
$normalized_content = $this->normalizeHtmlContent($content);
|
||||
$html_content = $normalized_content;
|
||||
}
|
||||
// Просто используем HTML как есть
|
||||
$html_content = $content;
|
||||
|
||||
$this->render('chapters/preview', [
|
||||
'content' => $html_content,
|
||||
|
|
@ -199,114 +219,5 @@ class ChapterController extends BaseController {
|
|||
]);
|
||||
}
|
||||
|
||||
private function normalizeMarkdownContent($markdown) {
|
||||
// Нормализация Markdown - убеждаемся, что есть пустые строки между абзацами
|
||||
$lines = explode("\n", $markdown);
|
||||
$normalized = [];
|
||||
$inParagraph = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if (empty($trimmed)) {
|
||||
// Пустая строка - конец абзаца
|
||||
if ($inParagraph) {
|
||||
$normalized[] = '';
|
||||
$inParagraph = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем, не является ли строка началом списка
|
||||
if (preg_match('/^[\*\-\+] /', $line) || preg_match('/^\d+\./', $line)) {
|
||||
if ($inParagraph) {
|
||||
$normalized[] = ''; // Завершаем предыдущий абзац
|
||||
$inParagraph = false;
|
||||
}
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем, не является ли строка началом цитаты
|
||||
if (preg_match('/^> /', $line) || preg_match('/^— /', $line)) {
|
||||
if ($inParagraph) {
|
||||
$normalized[] = ''; // Завершаем предыдущий абзац
|
||||
$inParagraph = false;
|
||||
}
|
||||
$normalized[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем, не является ли строка заголовком
|
||||
if (preg_match('/^#+ /', $line)) {
|
||||
if ($inParagraph) {
|
||||
$normalized[] = ''; // Завершаем предыдущий абзац
|
||||
$inParagraph = false;
|
||||
}
|
||||
$normalized[] = $line;
|
||||
$normalized[] = ''; // Пустая строка после заголовка
|
||||
continue;
|
||||
}
|
||||
|
||||
// Непустая строка - часть абзаца
|
||||
if (!$inParagraph && !empty($normalized) && end($normalized) !== '') {
|
||||
// Добавляем пустую строку перед новым абзацем
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
$normalized[] = $line;
|
||||
$inParagraph = true;
|
||||
}
|
||||
|
||||
return implode("\n", $normalized);
|
||||
}
|
||||
|
||||
// И метод для нормализации HTML контента
|
||||
private function normalizeHtmlContent($html) {
|
||||
// Оборачиваем текст без тегов в <p>
|
||||
if (!preg_match('/<[^>]+>/', $html) && trim($html) !== '') {
|
||||
$lines = explode("\n", trim($html));
|
||||
$wrapped = [];
|
||||
$inParagraph = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if (empty($trimmed)) {
|
||||
if ($inParagraph) {
|
||||
$wrapped[] = '</p>';
|
||||
$inParagraph = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем на начало списка
|
||||
if (preg_match('/^[\*\-\+] /', $trimmed) || preg_match('/^\d+\./', $trimmed)) {
|
||||
if ($inParagraph) {
|
||||
$wrapped[] = '</p>';
|
||||
$inParagraph = false;
|
||||
}
|
||||
// Обрабатываем списки отдельно
|
||||
$wrapped[] = '<ul><li>' . htmlspecialchars($trimmed) . '</li></ul>';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$inParagraph) {
|
||||
$wrapped[] = '<p>' . htmlspecialchars($trimmed);
|
||||
$inParagraph = true;
|
||||
} else {
|
||||
$wrapped[] = htmlspecialchars($trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if ($inParagraph) {
|
||||
$wrapped[] = '</p>';
|
||||
}
|
||||
|
||||
return implode("\n", $wrapped);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -4,7 +4,6 @@ require_once 'controllers/BaseController.php';
|
|||
require_once 'models/Book.php';
|
||||
require_once 'models/Chapter.php';
|
||||
require_once 'vendor/autoload.php';
|
||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
||||
|
||||
use PhpOffice\PhpWord\PhpWord;
|
||||
use PhpOffice\PhpWord\IOFactory;
|
||||
|
|
@ -69,20 +68,20 @@ class ExportController extends BaseController {
|
|||
}
|
||||
|
||||
private function handleExport($book, $chapters, $is_public, $author_name, $format) {
|
||||
$Parsedown = new ParsedownExtra();
|
||||
|
||||
|
||||
switch ($format) {
|
||||
case 'pdf':
|
||||
$this->exportPDF($book, $chapters, $is_public, $author_name, $Parsedown);
|
||||
$this->exportPDF($book, $chapters, $is_public, $author_name);
|
||||
break;
|
||||
case 'docx':
|
||||
$this->exportDOCX($book, $chapters, $is_public, $author_name, $Parsedown);
|
||||
$this->exportDOCX($book, $chapters, $is_public, $author_name);
|
||||
break;
|
||||
case 'html':
|
||||
$this->exportHTML($book, $chapters, $is_public, $author_name, $Parsedown);
|
||||
$this->exportHTML($book, $chapters, $is_public, $author_name);
|
||||
break;
|
||||
case 'txt':
|
||||
$this->exportTXT($book, $chapters, $is_public, $author_name, $Parsedown);
|
||||
$this->exportTXT($book, $chapters, $is_public, $author_name);
|
||||
break;
|
||||
default:
|
||||
$_SESSION['error'] = "Неверный формат экспорта";
|
||||
|
|
@ -94,7 +93,7 @@ class ExportController extends BaseController {
|
|||
}
|
||||
|
||||
function exportPDF($book, $chapters, $is_public, $author_name) {
|
||||
global $Parsedown;
|
||||
|
||||
|
||||
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
|
||||
|
||||
|
|
@ -200,11 +199,9 @@ class ExportController extends BaseController {
|
|||
|
||||
// Контент главы
|
||||
$pdf->SetFont('dejavusans', '', 11);
|
||||
if ($book['editor_type'] == 'markdown') {
|
||||
$htmlContent = $Parsedown->text($chapter['content']);
|
||||
} else {
|
||||
$htmlContent = $chapter['content'];
|
||||
}
|
||||
|
||||
$htmlContent = $chapter['content'];
|
||||
|
||||
$pdf->writeHTML($htmlContent, true, false, true, false, '');
|
||||
|
||||
$pdf->Ln(8);
|
||||
|
|
@ -223,7 +220,6 @@ class ExportController extends BaseController {
|
|||
}
|
||||
|
||||
function exportDOCX($book, $chapters, $is_public, $author_name) {
|
||||
global $Parsedown;
|
||||
|
||||
$phpWord = new PhpWord();
|
||||
|
||||
|
|
@ -263,11 +259,8 @@ class ExportController extends BaseController {
|
|||
|
||||
// Описание
|
||||
if (!empty($book['description'])) {
|
||||
if ($book['editor_type'] == 'markdown') {
|
||||
$descriptionParagraphs = $this->markdownToParagraphs($book['description']);
|
||||
} else {
|
||||
$descriptionParagraphs = $this->htmlToParagraphs($book['description']);
|
||||
}
|
||||
|
||||
$descriptionParagraphs = $this->htmlToParagraphs($book['description']);
|
||||
|
||||
foreach ($descriptionParagraphs as $paragraph) {
|
||||
if (!empty(trim($paragraph))) {
|
||||
|
|
@ -303,14 +296,11 @@ class ExportController extends BaseController {
|
|||
$section->addText($chapter['title'], ['bold' => true, 'size' => 14]);
|
||||
$section->addTextBreak(1);
|
||||
|
||||
// Получаем очищенный текст и разбиваем на абзацы в зависимости от типа редактора
|
||||
if ($book['editor_type'] == 'markdown') {
|
||||
$cleanContent = $this->cleanMarkdown($chapter['content']);
|
||||
$paragraphs = $this->markdownToParagraphs($cleanContent);
|
||||
} else {
|
||||
$cleanContent = strip_tags($chapter['content']);
|
||||
$paragraphs = $this->htmlToParagraphs($chapter['content']);
|
||||
}
|
||||
// Получаем очищенный текст и разбиваем на абзацы
|
||||
|
||||
$cleanContent = strip_tags($chapter['content']);
|
||||
$paragraphs = $this->htmlToParagraphs($chapter['content']);
|
||||
|
||||
|
||||
// Добавляем каждый абзац
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
|
|
@ -342,7 +332,6 @@ class ExportController extends BaseController {
|
|||
}
|
||||
|
||||
function exportHTML($book, $chapters, $is_public, $author_name) {
|
||||
global $Parsedown;
|
||||
|
||||
$html = '<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
|
@ -520,11 +509,7 @@ class ExportController extends BaseController {
|
|||
|
||||
if (!empty($book['description'])) {
|
||||
$html .= '<div class="book-description">';
|
||||
if ($book['editor_type'] == 'markdown') {
|
||||
$html .= nl2br(htmlspecialchars($book['description']));
|
||||
} else {
|
||||
$html .= $book['description'];
|
||||
}
|
||||
$html .= $book['description'];
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
|
|
@ -546,15 +531,7 @@ class ExportController extends BaseController {
|
|||
foreach ($chapters as $index => $chapter) {
|
||||
$html .= '<div class="chapter">';
|
||||
$html .= '<div class="chapter-title" id="chapter-' . $chapter['id'] . '" name="chapter-' . $chapter['id'] . '">' . htmlspecialchars($chapter['title']) . '</div>';
|
||||
|
||||
// Обрабатываем контент в зависимости от типа редактора
|
||||
if ($book['editor_type'] == 'markdown') {
|
||||
$htmlContent = $Parsedown->text($chapter['content']);
|
||||
} else {
|
||||
$htmlContent = $chapter['content'];
|
||||
}
|
||||
|
||||
$html .= '<div class="chapter-content">' . $htmlContent . '</div>';
|
||||
$html .= '<div class="chapter-content">' . $chapter['content']. '</div>';
|
||||
$html .= '</div>';
|
||||
|
||||
if ($index < count($chapters) - 1) {
|
||||
|
|
@ -589,13 +566,8 @@ class ExportController extends BaseController {
|
|||
if (!empty($book['description'])) {
|
||||
$content .= "ОПИСАНИЕ:\n";
|
||||
|
||||
// Обрабатываем описание в зависимости от типа редактора
|
||||
if ($book['editor_type'] == 'markdown') {
|
||||
$descriptionText = $this->cleanMarkdown($book['description']);
|
||||
} else {
|
||||
$descriptionText = strip_tags($book['description']);
|
||||
}
|
||||
|
||||
// Обрабатываем описание
|
||||
$descriptionText = strip_tags($book['description']);
|
||||
$content .= wordwrap($descriptionText, 144) . "\n\n";
|
||||
}
|
||||
|
||||
|
|
@ -616,14 +588,9 @@ class ExportController extends BaseController {
|
|||
$content .= $chapter['title'] . "\n";
|
||||
$content .= str_repeat("-", 60) . "\n\n";
|
||||
|
||||
// Получаем очищенный текст в зависимости от типа редактора
|
||||
if ($book['editor_type'] == 'markdown') {
|
||||
$cleanContent = $this->cleanMarkdown($chapter['content']);
|
||||
$paragraphs = $this->markdownToParagraphs($cleanContent);
|
||||
} else {
|
||||
$cleanContent = strip_tags($chapter['content']);
|
||||
$paragraphs = $this->htmlToPlainTextParagraphs($cleanContent);
|
||||
}
|
||||
// Получаем очищенный текст
|
||||
$cleanContent = strip_tags($chapter['content']);
|
||||
$paragraphs = $this->htmlToPlainTextParagraphs($cleanContent);
|
||||
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
if (!empty(trim($paragraph))) {
|
||||
|
|
@ -648,180 +615,7 @@ class ExportController extends BaseController {
|
|||
exit;
|
||||
}
|
||||
|
||||
|
||||
// Функция для преобразования Markdown в чистый текст с форматированием абзацев
|
||||
function markdownToPlainText($markdown) {
|
||||
// Обрабатываем диалоги (заменяем - на —)
|
||||
$markdown = preg_replace('/^- (.+)$/m', "— $1", $markdown);
|
||||
|
||||
// Убираем Markdown разметку, но сохраняем переносы строк
|
||||
$text = $markdown;
|
||||
|
||||
// Убираем заголовки
|
||||
$text = preg_replace('/^#+\s+/m', '', $text);
|
||||
|
||||
// Убираем жирный и курсив
|
||||
$text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
|
||||
$text = preg_replace('/\*(.*?)\*/', '$1', $text);
|
||||
$text = preg_replace('/__(.*?)__/', '$1', $text);
|
||||
$text = preg_replace('/_(.*?)_/', '$1', $text);
|
||||
|
||||
// Убираем зачеркивание
|
||||
$text = preg_replace('/~~(.*?)~~/', '$1', $text);
|
||||
|
||||
// Убираем код (встроенный)
|
||||
$text = preg_replace('/`(.*?)`/', '$1', $text);
|
||||
|
||||
// Убираем блоки кода (сохраняем содержимое)
|
||||
$text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
|
||||
|
||||
// Убираем ссылки
|
||||
$text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
|
||||
|
||||
// Обрабатываем списки - заменяем маркеры на *
|
||||
$text = preg_replace('/^[\*\-+]\s+/m', '* ', $text);
|
||||
$text = preg_replace('/^\d+\.\s+/m', '* ', $text);
|
||||
|
||||
// Обрабатываем цитаты
|
||||
$text = preg_replace('/^>\s+/m', '', $text);
|
||||
|
||||
return $text;
|
||||
}
|
||||
// Функция для разбивки Markdown на абзацы с сохранением структуры
|
||||
function markdownToParagraphs($markdown) {
|
||||
// Нормализуем переносы строк
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $markdown);
|
||||
|
||||
// Обрабатываем диалоги (заменяем - на —)
|
||||
$text = preg_replace('/^- (.+)$/m', "— $1", $text);
|
||||
|
||||
// Разбиваем на строки
|
||||
$lines = explode("\n", $text);
|
||||
$paragraphs = [];
|
||||
$currentParagraph = '';
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmedLine = trim($line);
|
||||
|
||||
// Пустая строка - конец абзаца
|
||||
if (empty($trimmedLine)) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$paragraphs[] = $currentParagraph;
|
||||
$currentParagraph = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Диалог (начинается с —) всегда начинает новый абзац
|
||||
if (str_starts_with($trimmedLine, '—')) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$paragraphs[] = $currentParagraph;
|
||||
}
|
||||
$currentParagraph = $trimmedLine;
|
||||
$paragraphs[] = $currentParagraph;
|
||||
$currentParagraph = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Заголовки (начинаются с #) всегда начинают новый абзац
|
||||
if (str_starts_with($trimmedLine, '#')) {
|
||||
if (!empty($currentParagraph)) {
|
||||
$paragraphs[] = $currentParagraph;
|
||||
}
|
||||
$currentParagraph = preg_replace('/^#+\s+/', '', $trimmedLine);
|
||||
$paragraphs[] = $currentParagraph;
|
||||
$currentParagraph = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Обычный текст - добавляем к текущему абзацу
|
||||
if (!empty($currentParagraph)) {
|
||||
$currentParagraph .= ' ' . $trimmedLine;
|
||||
} else {
|
||||
$currentParagraph = $trimmedLine;
|
||||
}
|
||||
}
|
||||
|
||||
// Добавляем последний абзац
|
||||
if (!empty($currentParagraph)) {
|
||||
$paragraphs[] = $currentParagraph;
|
||||
}
|
||||
|
||||
return $paragraphs;
|
||||
}
|
||||
|
||||
// Функция для очистки Markdown разметки
|
||||
function cleanMarkdown($markdown) {
|
||||
$text = $markdown;
|
||||
|
||||
// Убираем заголовки
|
||||
$text = preg_replace('/^#+\s+/m', '', $text);
|
||||
|
||||
// Убираем жирный и курсив
|
||||
$text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
|
||||
$text = preg_replace('/\*(.*?)\*/', '$1', $text);
|
||||
$text = preg_replace('/__(.*?)__/', '$1', $text);
|
||||
$text = preg_replace('/_(.*?)_/', '$1', $text);
|
||||
|
||||
// Убираем зачеркивание
|
||||
$text = preg_replace('/~~(.*?)~~/', '$1', $text);
|
||||
|
||||
// Убираем код
|
||||
$text = preg_replace('/`(.*?)`/', '$1', $text);
|
||||
$text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
|
||||
|
||||
// Убираем ссылки
|
||||
$text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
|
||||
|
||||
// Обрабатываем списки - убираем маркеры
|
||||
$text = preg_replace('/^[\*\-+]\s+/m', '', $text);
|
||||
$text = preg_replace('/^\d+\.\s+/m', '', $text);
|
||||
|
||||
// Обрабатываем цитаты
|
||||
$text = preg_replace('/^>\s+/m', '', $text);
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
// Функция для форматирования текста с сохранением абзацев и диалогов
|
||||
function formatPlainText($text) {
|
||||
$lines = explode("\n", $text);
|
||||
$formatted = [];
|
||||
$in_paragraph = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
|
||||
if (empty($line)) {
|
||||
if ($in_paragraph) {
|
||||
$formatted[] = ''; // Пустая строка для разделения абзацев
|
||||
$in_paragraph = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Диалоги начинаются с —
|
||||
if (str_starts_with($line, '—')) {
|
||||
if ($in_paragraph) {
|
||||
$formatted[] = ''; // Разделяем абзацы перед диалогом
|
||||
}
|
||||
$formatted[] = $line;
|
||||
$formatted[] = ''; // Пустая строка после диалога
|
||||
$in_paragraph = false;
|
||||
} else {
|
||||
// Обычный текст
|
||||
$formatted[] = $line;
|
||||
$in_paragraph = true;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", array_filter($formatted, function($line) {
|
||||
return $line !== '' || !empty($line);
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
// // Новая функция для разбивки HTML на абзацы
|
||||
// Функция для разбивки HTML на абзацы
|
||||
function htmlToParagraphs($html) {
|
||||
// Убираем HTML теги и нормализуем пробелы
|
||||
$text = strip_tags($html);
|
||||
|
|
@ -837,6 +631,7 @@ class ExportController extends BaseController {
|
|||
|
||||
return $paragraphs;
|
||||
}
|
||||
|
||||
function htmlToPlainTextParagraphs($html) {
|
||||
// Убираем HTML теги
|
||||
$text = strip_tags($html);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
require_once 'controllers/BaseController.php';
|
||||
require_once 'models/Series.php';
|
||||
require_once 'models/Book.php';
|
||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
||||
|
||||
class SeriesController extends BaseController {
|
||||
|
||||
|
|
@ -178,17 +177,134 @@ class SeriesController extends BaseController {
|
|||
$total_chapters += $book_stats['chapter_count'] ?? 0;
|
||||
}
|
||||
|
||||
$Parsedown = new ParsedownExtra();
|
||||
|
||||
$this->render('series/view_public', [
|
||||
'series' => $series,
|
||||
'books' => $books,
|
||||
'author' => $author,
|
||||
'total_words' => $total_words,
|
||||
'total_chapters' => $total_chapters,
|
||||
'Parsedown' => $Parsedown,
|
||||
'page_title' => $series['title'] . ' — серия книг'
|
||||
]);
|
||||
}
|
||||
|
||||
public function addBook($series_id) {
|
||||
$this->requireLogin();
|
||||
|
||||
$user_id = $_SESSION['user_id'];
|
||||
$seriesModel = new Series($this->pdo);
|
||||
$bookModel = new Book($this->pdo);
|
||||
|
||||
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
|
||||
$_SESSION['error'] = "У вас нет доступа к этой серии";
|
||||
$this->redirect('/series');
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = "Ошибка безопасности";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
$book_id = (int)($_POST['book_id'] ?? 0);
|
||||
$sort_order = (int)($_POST['sort_order'] ?? 0);
|
||||
|
||||
if (!$book_id) {
|
||||
$_SESSION['error'] = "Выберите книгу";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
// Проверяем, что книга принадлежит пользователю
|
||||
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
||||
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
// Добавляем книгу в серию
|
||||
if ($bookModel->updateSeriesInfo($book_id, $series_id, $sort_order)) {
|
||||
$_SESSION['success'] = "Книга добавлена в серию";
|
||||
} else {
|
||||
$_SESSION['error'] = "Ошибка при добавлении книги в серию";
|
||||
}
|
||||
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
}
|
||||
|
||||
public function removeBook($series_id, $book_id) {
|
||||
$this->requireLogin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$_SESSION['error'] = "Неверный метод запроса";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = "Ошибка безопасности";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
$user_id = $_SESSION['user_id'];
|
||||
$seriesModel = new Series($this->pdo);
|
||||
$bookModel = new Book($this->pdo);
|
||||
|
||||
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
|
||||
$_SESSION['error'] = "У вас нет доступа к этой серии";
|
||||
$this->redirect('/series');
|
||||
}
|
||||
|
||||
// Проверяем, что книга принадлежит пользователю
|
||||
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
||||
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
// Удаляем книгу из серии
|
||||
if ($bookModel->removeFromSeries($book_id)) {
|
||||
$_SESSION['success'] = "Книга удалена из серии";
|
||||
} else {
|
||||
$_SESSION['error'] = "Ошибка при удалении книги из серии";
|
||||
}
|
||||
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
public function updateBookOrder($series_id) {
|
||||
$this->requireLogin();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
$_SESSION['error'] = "Неверный метод запроса";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||
$_SESSION['error'] = "Ошибка безопасности";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
$user_id = $_SESSION['user_id'];
|
||||
$seriesModel = new Series($this->pdo);
|
||||
$bookModel = new Book($this->pdo);
|
||||
|
||||
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
|
||||
$_SESSION['error'] = "У вас нет доступа к этой серии";
|
||||
$this->redirect('/series');
|
||||
}
|
||||
|
||||
$order_data = $_POST['order'] ?? [];
|
||||
|
||||
if (empty($order_data)) {
|
||||
$_SESSION['error'] = "Нет данных для обновления";
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
|
||||
// Обновляем порядок книг
|
||||
if ($bookModel->reorderSeriesBooks($series_id, $order_data)) {
|
||||
$_SESSION['success'] = "Порядок книг обновлен";
|
||||
} else {
|
||||
$_SESSION['error'] = "Ошибка при обновлении порядка книг";
|
||||
}
|
||||
|
||||
$this->redirect("/series/{$series_id}/edit");
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
require_once 'controllers/BaseController.php';
|
||||
require_once 'models/User.php';
|
||||
require_once 'models/Book.php';
|
||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
||||
|
||||
class UserController extends BaseController {
|
||||
|
||||
|
|
@ -101,7 +100,7 @@ class UserController extends BaseController {
|
|||
$total_chapters += $book_stats['chapter_count'] ?? 0;
|
||||
}
|
||||
|
||||
$Parsedown = new ParsedownExtra();
|
||||
|
||||
|
||||
$this->render('user/view_public', [
|
||||
'user' => $user,
|
||||
|
|
@ -109,7 +108,6 @@ class UserController extends BaseController {
|
|||
'total_books' => $total_books,
|
||||
'total_words' => $total_words,
|
||||
'total_chapters' => $total_chapters,
|
||||
'Parsedown' => $Parsedown,
|
||||
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,18 +0,0 @@
|
|||
<?php
|
||||
require_once 'Parsedown.php';
|
||||
|
||||
class ParsedownExtra extends Parsedown {
|
||||
protected function blockQuote($Line) {
|
||||
if (preg_match('/^<5E>\s+/', $Line['text'])) {
|
||||
return array(
|
||||
'element' => array(
|
||||
'name' => 'div',
|
||||
'attributes' => array('class' => 'dialogue'),
|
||||
'text' => ltrim($Line['text'], '<27> ')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return parent::blockQuote($Line);
|
||||
}
|
||||
}
|
||||
13
index.php
13
index.php
|
|
@ -122,6 +122,7 @@ $router->add('/books', 'BookController@index');
|
|||
$router->add('/books/create', 'BookController@create');
|
||||
$router->add('/books/{id}/edit', 'BookController@edit');
|
||||
$router->add('/books/{id}/delete', 'BookController@delete');
|
||||
$router->add('/books/delete-all', 'BookController@deleteAll');
|
||||
$router->add('/books/{id}/normalize', 'BookController@normalizeContent');
|
||||
$router->add('/books/{id}/regenerate-token', 'BookController@regenerateToken');
|
||||
|
||||
|
|
@ -137,7 +138,9 @@ $router->add('/series', 'SeriesController@index');
|
|||
$router->add('/series/create', 'SeriesController@create');
|
||||
$router->add('/series/{id}/edit', 'SeriesController@edit');
|
||||
$router->add('/series/{id}/delete', 'SeriesController@delete');
|
||||
|
||||
$router->add('/series/{id}/add-book', 'SeriesController@addBook');
|
||||
$router->add('/series/{id}/remove-book/{book_id}', 'SeriesController@removeBook');
|
||||
$router->add('/series/{id}/update-order', 'SeriesController@updateBookOrder');
|
||||
|
||||
// Профиль
|
||||
$router->add('/profile', 'UserController@profile');
|
||||
|
|
@ -154,6 +157,14 @@ $router->add('/book/{share_token}', 'BookController@viewPublic');
|
|||
$router->add('/author/{id}', 'UserController@viewPublic');
|
||||
$router->add('/series/{id}/view', 'SeriesController@viewPublic');
|
||||
|
||||
|
||||
// Администрирование
|
||||
$router->add('/admin/users', 'AdminController@users');
|
||||
$router->add('/admin/add-user', 'AdminController@addUser');
|
||||
$router->add('/admin/user/{user_id}/toggle-status', 'AdminController@toggleUserStatus');
|
||||
$router->add('/admin/user/{user_id}/delete', 'AdminController@deleteUser');
|
||||
|
||||
|
||||
// Обработка запроса
|
||||
$requestUri = $_SERVER['REQUEST_URI'];
|
||||
$router->handle($requestUri);
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ CREATE TABLE `books` (
|
|||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||
`share_token` varchar(32) DEFAULT NULL,
|
||||
`published` tinyint(1) NOT NULL DEFAULT 0,
|
||||
`editor_type` ENUM('markdown', 'html') DEFAULT 'markdown',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `share_token` (`share_token`),
|
||||
KEY `user_id` (`user_id`),
|
||||
|
|
@ -219,6 +218,11 @@ define('SITE_URL', '{$site_url}');
|
|||
|
||||
// Настройки приложения
|
||||
define('APP_NAME', 'Web Writer');
|
||||
|
||||
define('CONTROLLERS_PATH', __DIR__ . '/../controllers/');
|
||||
define('VIEWS_PATH', __DIR__ . '/../views/');
|
||||
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
|
||||
|
||||
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
|
||||
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
|
||||
define('COVERS_URL', SITE_URL . '/uploads/covers/');
|
||||
|
|
@ -242,6 +246,8 @@ try {
|
|||
die("Ошибка подключения к базе данных");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Автозагрузка моделей
|
||||
spl_autoload_register(function (\$class_name) {
|
||||
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
|
||||
|
|
|
|||
437
models/Book.php
437
models/Book.php
|
|
@ -1,6 +1,5 @@
|
|||
<?php
|
||||
// models/Book.php
|
||||
require_once __DIR__ . '/../includes/parsedown/ParsedownExtra.php';
|
||||
class Book {
|
||||
private $pdo;
|
||||
|
||||
|
|
@ -41,11 +40,10 @@ class Book {
|
|||
public function create($data) {
|
||||
$share_token = bin2hex(random_bytes(16));
|
||||
$published = isset($data['published']) ? (int)$data['published'] : 0;
|
||||
$editor_type = $data['editor_type'] ?? 'markdown';
|
||||
|
||||
$stmt = $this->pdo->prepare("
|
||||
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, editor_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
");
|
||||
return $stmt->execute([
|
||||
$data['title'],
|
||||
|
|
@ -55,14 +53,12 @@ class Book {
|
|||
$data['series_id'] ?? null,
|
||||
$data['sort_order_in_series'] ?? null,
|
||||
$share_token,
|
||||
$published,
|
||||
$editor_type
|
||||
$published
|
||||
]);
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
$published = isset($data['published']) ? (int)$data['published'] : 0;
|
||||
$editor_type = $data['editor_type'] ?? 'markdown';
|
||||
|
||||
// Преобразуем пустые строки в NULL для integer полей
|
||||
$series_id = !empty($data['series_id']) ? (int)$data['series_id'] : null;
|
||||
|
|
@ -70,7 +66,7 @@ class Book {
|
|||
|
||||
$stmt = $this->pdo->prepare("
|
||||
UPDATE books
|
||||
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?, editor_type = ?
|
||||
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
return $stmt->execute([
|
||||
|
|
@ -80,7 +76,6 @@ class Book {
|
|||
$series_id, // Теперь это либо integer, либо NULL
|
||||
$sort_order_in_series, // Теперь это либо integer, либо NULL
|
||||
$published,
|
||||
$editor_type,
|
||||
$id,
|
||||
$data['user_id']
|
||||
]);
|
||||
|
|
@ -107,6 +102,39 @@ class Book {
|
|||
}
|
||||
}
|
||||
|
||||
public function deleteAllByUser($user_id) {
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
// Получаем ID всех книг пользователя
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE user_id = ?");
|
||||
$stmt->execute([$user_id]);
|
||||
$book_ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
|
||||
if (empty($book_ids)) {
|
||||
$this->pdo->commit();
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Удаляем главы всех книг пользователя (одним запросом)
|
||||
$placeholders = implode(',', array_fill(0, count($book_ids), '?'));
|
||||
$stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id IN ($placeholders)");
|
||||
$stmt->execute($book_ids);
|
||||
|
||||
// Удаляем все книги пользователя (одним запросом)
|
||||
$stmt = $this->pdo->prepare("DELETE FROM books WHERE user_id = ?");
|
||||
$stmt->execute([$user_id]);
|
||||
|
||||
$deleted_count = $stmt->rowCount();
|
||||
$this->pdo->commit();
|
||||
|
||||
return $deleted_count;
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function userOwnsBook($book_id, $user_id) {
|
||||
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE id = ? AND user_id = ?");
|
||||
$stmt->execute([$book_id, $user_id]);
|
||||
|
|
@ -172,24 +200,6 @@ class Book {
|
|||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function reorderSeriesBooks($series_id, $new_order) {
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
foreach ($new_order as $order => $book_id) {
|
||||
$stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
|
||||
$stmt->execute([$order + 1, $book_id, $series_id]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public function getBookStats($book_id, $only_published_chapters = false) {
|
||||
$sql = "
|
||||
SELECT
|
||||
|
|
@ -209,73 +219,48 @@ class Book {
|
|||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function convertChaptersContent($book_id, $from_editor, $to_editor) {
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
// Получаем все главы книги
|
||||
$chapters = $this->getAllChapters($book_id);
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$converted_content = $this->convertContent(
|
||||
$chapter['content'],
|
||||
$from_editor,
|
||||
$to_editor
|
||||
);
|
||||
private function getAllChapters($book_id) {
|
||||
$stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
|
||||
$stmt->execute([$book_id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// Обновляем контент главы
|
||||
$this->updateChapterContent($chapter['id'], $converted_content);
|
||||
private function updateChapterContent($chapter_id, $content) {
|
||||
$word_count = $this->countWords($content);
|
||||
$stmt = $this->pdo->prepare("
|
||||
UPDATE chapters
|
||||
SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
");
|
||||
return $stmt->execute([$content, $word_count, $chapter_id]);
|
||||
}
|
||||
|
||||
public function getBooksNotInSeries($user_id, $series_id = null) {
|
||||
$sql = "SELECT * FROM books WHERE user_id = ? AND (series_id IS NULL OR series_id = ?)";
|
||||
$stmt = $this->pdo->prepare($sql);
|
||||
$stmt->execute([$user_id, $series_id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
public function reorderSeriesBooks($series_id, $new_order) {
|
||||
try {
|
||||
$this->pdo->beginTransaction();
|
||||
|
||||
foreach ($new_order as $order => $book_id) {
|
||||
$stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
|
||||
$stmt->execute([$order + 1, $book_id, $series_id]);
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
error_log("Ошибка при обновлении порядка книг: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->pdo->commit();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
$this->pdo->rollBack();
|
||||
error_log("Error converting chapters: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getAllChapters($book_id) {
|
||||
$stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
|
||||
$stmt->execute([$book_id]);
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
private function updateChapterContent($chapter_id, $content) {
|
||||
$word_count = $this->countWords($content);
|
||||
$stmt = $this->pdo->prepare("
|
||||
UPDATE chapters
|
||||
SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
");
|
||||
return $stmt->execute([$content, $word_count, $chapter_id]);
|
||||
}
|
||||
|
||||
private function convertContent($content, $from_editor, $to_editor) {
|
||||
if ($from_editor === $to_editor) {
|
||||
return $content;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../includes/parsedown/ParsedownExtra.php';
|
||||
|
||||
try {
|
||||
if ($from_editor === 'markdown' && $to_editor === 'html') {
|
||||
// Markdown to HTML
|
||||
$parsedown = new ParsedownExtra();
|
||||
return $parsedown->text($content);
|
||||
} elseif ($from_editor === 'html' && $to_editor === 'markdown') {
|
||||
// HTML to Markdown (упрощенная версия)
|
||||
return $this->htmlToMarkdown($content);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
error_log("Error converting content from {$from_editor} to {$to_editor}: " . $e->getMessage());
|
||||
return $content;
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
private function countWords($text) {
|
||||
$text = strip_tags($text);
|
||||
|
|
@ -285,279 +270,5 @@ private function convertContent($content, $from_editor, $to_editor) {
|
|||
return count($words);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private function markdownToHtmlWithParagraphs($markdown) {
|
||||
$parsedown = new ParsedownExtra();
|
||||
|
||||
// Включаем разметку строк для лучшей обработки абзацев
|
||||
$parsedown->setBreaksEnabled(true);
|
||||
|
||||
// Обрабатываем Markdown
|
||||
$html = $parsedown->text($markdown);
|
||||
|
||||
// Дополнительная обработка для обеспечения правильной структуры абзацев
|
||||
$html = $this->ensureParagraphStructure($html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function ensureParagraphStructure($html) {
|
||||
// Если HTML не содержит тегов абзацев или div'ов, оборачиваем в <p>
|
||||
if (!preg_match('/<(p|div|h[1-6]|blockquote|pre|ul|ol|li)/i', $html)) {
|
||||
// Разбиваем на строки и оборачиваем каждую непустую строку в <p>
|
||||
$lines = explode("\n", trim($html));
|
||||
$wrappedLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$line = trim($line);
|
||||
if (!empty($line)) {
|
||||
// Пропускаем уже обернутые строки
|
||||
if (!preg_match('/^<[^>]+>/', $line) || preg_match('/^<(p|div|h[1-6])/i', $line)) {
|
||||
$wrappedLines[] = $line;
|
||||
} else {
|
||||
$wrappedLines[] = "<p>{$line}</p>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$html = implode("\n", $wrappedLines);
|
||||
}
|
||||
|
||||
// Убеждаемся, что теги правильно закрыты
|
||||
$html = $this->balanceTags($html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function balanceTags($html) {
|
||||
// Простая балансировка тегов - в реальном проекте лучше использовать DOMDocument
|
||||
$tags = [
|
||||
'p' => 0,
|
||||
'div' => 0,
|
||||
'span' => 0,
|
||||
'strong' => 0,
|
||||
'em' => 0,
|
||||
];
|
||||
|
||||
// Счетчик открывающих и закрывающих тегов
|
||||
foreach ($tags as $tag => &$count) {
|
||||
$open = substr_count($html, "<{$tag}>") + substr_count($html, "<{$tag} ");
|
||||
$close = substr_count($html, "</{$tag}>");
|
||||
$count = $open - $close;
|
||||
}
|
||||
|
||||
// Добавляем недостающие закрывающие теги
|
||||
foreach ($tags as $tag => $count) {
|
||||
if ($count > 0) {
|
||||
$html .= str_repeat("</{$tag}>", $count);
|
||||
}
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
private function htmlToMarkdown($html) {
|
||||
// Сначала нормализуем HTML структуру
|
||||
$html = $this->normalizeHtml($html);
|
||||
|
||||
// Базовая конвертация HTML в Markdown
|
||||
$markdown = $html;
|
||||
|
||||
// 1. Сначала обрабатываем абзацы - заменяем на двойные переносы строк
|
||||
$markdown = preg_replace_callback('/<p[^>]*>(.*?)<\/p>/is', function($matches) {
|
||||
$content = trim($matches[1]);
|
||||
if (!empty($content)) {
|
||||
return $content . "\n\n";
|
||||
}
|
||||
return '';
|
||||
}, $markdown);
|
||||
|
||||
// 2. Обрабатываем разрывы строк
|
||||
$markdown = preg_replace('/<br[^>]*>\s*<\/br[^>]*>/i', "\n", $markdown);
|
||||
$markdown = preg_replace('/<br[^>]*>/i', " \n", $markdown); // Два пробела для Markdown разрыва
|
||||
|
||||
// 3. Заголовки
|
||||
$markdown = preg_replace('/<h1[^>]*>(.*?)<\/h1>/is', "# $1\n\n", $markdown);
|
||||
$markdown = preg_replace('/<h2[^>]*>(.*?)<\/h2>/is', "## $1\n\n", $markdown);
|
||||
$markdown = preg_replace('/<h3[^>]*>(.*?)<\/h3>/is', "### $1\n\n", $markdown);
|
||||
$markdown = preg_replace('/<h4[^>]*>(.*?)<\/h4>/is', "#### $1\n\n", $markdown);
|
||||
$markdown = preg_replace('/<h5[^>]*>(.*?)<\/h5>/is', "##### $1\n\n", $markdown);
|
||||
$markdown = preg_replace('/<h6[^>]*>(.*?)<\/h6>/is', "###### $1\n\n", $markdown);
|
||||
|
||||
// 4. Жирный текст
|
||||
$markdown = preg_replace('/<strong[^>]*>(.*?)<\/strong>/is', '**$1**', $markdown);
|
||||
$markdown = preg_replace('/<b[^>]*>(.*?)<\/b>/is', '**$1**', $markdown);
|
||||
|
||||
// 5. Курсив
|
||||
$markdown = preg_replace('/<em[^>]*>(.*?)<\/em>/is', '*$1*', $markdown);
|
||||
$markdown = preg_replace('/<i[^>]*>(.*?)<\/i>/is', '*$1*', $markdown);
|
||||
|
||||
// 6. Зачеркивание
|
||||
$markdown = preg_replace('/<s[^>]*>(.*?)<\/s>/is', '~~$1~~', $markdown);
|
||||
$markdown = preg_replace('/<strike[^>]*>(.*?)<\/strike>/is', '~~$1~~', $markdown);
|
||||
$markdown = preg_replace('/<del[^>]*>(.*?)<\/del>/is', '~~$1~~', $markdown);
|
||||
|
||||
// 7. Списки
|
||||
$markdown = preg_replace('/<li[^>]*>(.*?)<\/li>/is', "- $1\n", $markdown);
|
||||
|
||||
// Обработка вложенных списков
|
||||
$markdown = preg_replace('/<ul[^>]*>(.*?)<\/ul>/is', "\n$1\n", $markdown);
|
||||
$markdown = preg_replace('/<ol[^>]*>(.*?)<\/ol>/is', "\n$1\n", $markdown);
|
||||
|
||||
// 8. Блочные цитаты
|
||||
$markdown = preg_replace('/<blockquote[^>]*>(.*?)<\/blockquote>/is', "> $1\n\n", $markdown);
|
||||
|
||||
// 9. Код
|
||||
$markdown = preg_replace('/<code[^>]*>(.*?)<\/code>/is', '`$1`', $markdown);
|
||||
$markdown = preg_replace('/<pre[^>]*><code[^>]*>(.*?)<\/code><\/pre>/is', "```\n$1\n```", $markdown);
|
||||
$markdown = preg_replace('/<pre[^>]*>(.*?)<\/pre>/is', "```\n$1\n```", $markdown);
|
||||
|
||||
// 10. Ссылки
|
||||
$markdown = preg_replace('/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/is', '[$2]($1)', $markdown);
|
||||
|
||||
// 11. Изображения
|
||||
$markdown = preg_replace('/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/is', '', $markdown);
|
||||
|
||||
// 12. Таблицы
|
||||
$markdown = preg_replace_callback('/<table[^>]*>(.*?)<\/table>/is', function($matches) {
|
||||
$tableContent = $matches[1];
|
||||
// Простое преобразование таблицы в Markdown
|
||||
$tableContent = preg_replace('/<th[^>]*>(.*?)<\/th>/i', "| **$1** ", $tableContent);
|
||||
$tableContent = preg_replace('/<td[^>]*>(.*?)<\/td>/i', "| $1 ", $tableContent);
|
||||
$tableContent = preg_replace('/<tr[^>]*>(.*?)<\/tr>/i', "$1|\n", $tableContent);
|
||||
$tableContent = preg_replace('/<thead[^>]*>(.*?)<\/thead>/i', "$1", $tableContent);
|
||||
$tableContent = preg_replace('/<tbody[^>]*>(.*?)<\/tbody>/i', "$1", $tableContent);
|
||||
|
||||
// Добавляем разделитель для заголовков таблицы
|
||||
$tableContent = preg_replace('/\| \*\*[^\|]+\*\* [^\n]*?\|\n/', "$0| --- |\n", $tableContent, 1);
|
||||
|
||||
return "\n" . $tableContent . "\n";
|
||||
}, $markdown);
|
||||
|
||||
// 13. Удаляем все остальные HTML-теги
|
||||
$markdown = strip_tags($markdown);
|
||||
|
||||
// 14. Чистим лишние пробелы и переносы
|
||||
$markdown = preg_replace('/\n{3,}/', "\n\n", $markdown); // Более двух переносов заменяем на два
|
||||
$markdown = preg_replace('/^\s+|\s+$/m', '', $markdown); // Trim каждой строки
|
||||
$markdown = preg_replace('/\n\s*\n/', "\n\n", $markdown); // Чистим пустые строки
|
||||
$markdown = preg_replace('/^ +/m', '', $markdown); // Убираем отступы в начале строк
|
||||
|
||||
$markdown = trim($markdown);
|
||||
|
||||
// 15. Дополнительная нормализация - убеждаемся, что есть пустые строки между абзацами
|
||||
$lines = explode("\n", $markdown);
|
||||
$normalized = [];
|
||||
$inParagraph = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if (empty($trimmed)) {
|
||||
// Пустая строка - конец абзаца
|
||||
if ($inParagraph) {
|
||||
$normalized[] = '';
|
||||
$inParagraph = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Непустая строка
|
||||
if (!$inParagraph && !empty($normalized) && end($normalized) !== '') {
|
||||
// Добавляем пустую строку перед новым абзацем
|
||||
$normalized[] = '';
|
||||
}
|
||||
|
||||
$normalized[] = $trimmed;
|
||||
$inParagraph = true;
|
||||
}
|
||||
|
||||
return implode("\n", $normalized);
|
||||
}
|
||||
|
||||
private function normalizeHtml($html) {
|
||||
// Нормализуем HTML структуру перед конвертацией
|
||||
$html = preg_replace('/<div[^>]*>(.*?)<\/div>/is', "<p>$1</p>", $html);
|
||||
|
||||
// Убираем лишние пробелы
|
||||
$html = preg_replace('/\s+/', ' ', $html);
|
||||
|
||||
// Восстанавливаем структуру абзацев
|
||||
$html = preg_replace('/([^>])\s*<\/(p|div)>\s*([^<])/', "$1</$2>\n\n$3", $html);
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
public function normalizeBookContent($book_id) {
|
||||
try {
|
||||
$chapters = $this->getAllChapters($book_id);
|
||||
$book = $this->findById($book_id);
|
||||
|
||||
foreach ($chapters as $chapter) {
|
||||
$normalized_content = '';
|
||||
|
||||
if ($book['editor_type'] == 'html') {
|
||||
// Нормализуем HTML контент
|
||||
$normalized_content = $this->normalizeHtmlContent($chapter['content']);
|
||||
} else {
|
||||
// Нормализуем Markdown контент
|
||||
$normalized_content = $this->normalizeMarkdownContent($chapter['content']);
|
||||
}
|
||||
|
||||
if ($normalized_content !== $chapter['content']) {
|
||||
$this->updateChapterContent($chapter['id'], $normalized_content);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
error_log("Error normalizing book content: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeHtmlContent($html) {
|
||||
// Простая нормализация HTML - оборачиваем текст без тегов в <p>
|
||||
if (!preg_match('/<[^>]+>/', $html) && trim($html) !== '') {
|
||||
// Если нет HTML тегов, оборачиваем в <p>
|
||||
$lines = explode("\n", trim($html));
|
||||
$wrapped = array_map(function($line) {
|
||||
$line = trim($line);
|
||||
return $line ? "<p>{$line}</p>" : '';
|
||||
}, $lines);
|
||||
return implode("\n", array_filter($wrapped));
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function normalizeMarkdownContent($markdown) {
|
||||
// Нормализация Markdown - убеждаемся, что есть пустые строки между абзацами
|
||||
$lines = explode("\n", $markdown);
|
||||
$normalized = [];
|
||||
$inParagraph = false;
|
||||
|
||||
foreach ($lines as $line) {
|
||||
$trimmed = trim($line);
|
||||
|
||||
if (empty($trimmed)) {
|
||||
// Пустая строка - конец абзаца
|
||||
if ($inParagraph) {
|
||||
$normalized[] = '';
|
||||
$inParagraph = false;
|
||||
}
|
||||
} else {
|
||||
// Непустая строка
|
||||
if (!$inParagraph && !empty($normalized) && end($normalized) !== '') {
|
||||
// Добавляем пустую строку перед новым абзацем
|
||||
$normalized[] = '';
|
||||
}
|
||||
$normalized[] = $line;
|
||||
$inParagraph = true;
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $normalized);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
|
@ -67,16 +67,16 @@ class User {
|
|||
return $stmt->execute($params);
|
||||
}
|
||||
|
||||
public function updateStatus($id, $is_active) {
|
||||
$stmt = $this->pdo->prepare("UPDATE users SET is_active = ? WHERE id = ?");
|
||||
return $stmt->execute([$is_active, $id]);
|
||||
}
|
||||
|
||||
public function delete($id) {
|
||||
$stmt = $this->pdo->prepare("DELETE FROM users WHERE id = ?");
|
||||
return $stmt->execute([$id]);
|
||||
}
|
||||
|
||||
public function updateStatus($id, $is_active) {
|
||||
$stmt = $this->pdo->prepare("UPDATE users SET is_active = ? WHERE id = ?");
|
||||
return $stmt->execute([$is_active, $id]);
|
||||
}
|
||||
|
||||
public function updateLastLogin($id) {
|
||||
$stmt = $this->pdo->prepare("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
return $stmt->execute([$id]);
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
|
|
@ -0,0 +1,88 @@
|
|||
<?php include 'views/layouts/header.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<h1>Добавление пользователя</h1>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($success) && $success): ?>
|
||||
<div class="alert alert-success">
|
||||
<?= e($success) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" style="max-width: 500px; margin: 0 auto;">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Имя пользователя *
|
||||
</label>
|
||||
<input type="text" id="username" name="username"
|
||||
value="<?= e($_POST['username'] ?? '') ?>"
|
||||
placeholder="Введите имя пользователя"
|
||||
style="width: 100%;"
|
||||
required
|
||||
pattern="[a-zA-Z0-9_]+"
|
||||
title="Только латинские буквы, цифры и символ подчеркивания">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="<?= e($_POST['display_name'] ?? '') ?>"
|
||||
placeholder="Введите отображаемое имя"
|
||||
style="width: 100%;">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Email
|
||||
</label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="<?= e($_POST['email'] ?? '') ?>"
|
||||
placeholder="Введите email"
|
||||
style="width: 100%;">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Пароль *
|
||||
</label>
|
||||
<input type="password" id="password" name="password"
|
||||
placeholder="Введите пароль (минимум 6 символов)"
|
||||
style="width: 100%;"
|
||||
required
|
||||
minlength="6">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="password_confirm" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Подтверждение пароля *
|
||||
</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm"
|
||||
placeholder="Повторите пароль"
|
||||
style="width: 100%;"
|
||||
required
|
||||
minlength="6">
|
||||
</div>
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="is_active">
|
||||
<input type="checkbox" id="is_active" name="is_active" value="1"
|
||||
<?= isset($_POST['is_active']) ? 'checked' : 'checked' ?>>
|
||||
Активировать пользователя сразу
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button type="submit" class="contrast" style="flex: 1;">
|
||||
👥 Добавить пользователя
|
||||
</button>
|
||||
<a href="<?= SITE_URL ?>/admin/users" class="secondary" style="display: flex; align-items: center; justify-content: center; padding: 0.75rem; text-decoration: none;">
|
||||
❌ Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
<?php include 'views/layouts/header.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<h1>Управление пользователями</h1>
|
||||
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="alert alert-success">
|
||||
<?= e($_SESSION['success']) ?>
|
||||
<?php unset($_SESSION['success']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($_SESSION['error']) ?>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Всего пользователей: <?= count($users) ?></h2>
|
||||
<a href="<?= SITE_URL ?>/admin/add-user" class="action-button primary">➕ Добавить пользователя</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($users)): ?>
|
||||
<article style="text-align: center; padding: 2rem;">
|
||||
<h3>Пользователи не найдены</h3>
|
||||
<p>Зарегистрируйте первого пользователя</p>
|
||||
<a href="<?= SITE_URL ?>/admin/add-user" role="button">📝 Добавить пользователя</a>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%;">ID</th>
|
||||
<th style="width: 15%;">Имя пользователя</th>
|
||||
<th style="width: 20%;">Отображаемое имя</th>
|
||||
<th style="width: 20%;">Email</th>
|
||||
<th style="width: 15%;">Дата регистрации</th>
|
||||
<th style="width: 10%;">Статус</th>
|
||||
<th style="width: 15%;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $user): ?>
|
||||
<tr>
|
||||
<td><?= $user['id'] ?></td>
|
||||
<td>
|
||||
<strong><a href="<?= SITE_URL ?>/author/<?= $user['id'] ?>"><?= e($user['username']) ?></a></strong>
|
||||
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
||||
<br><small style="color: #666;">(Вы)</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= e($user['display_name']) ?></td>
|
||||
<td><?= e($user['email']) ?></td>
|
||||
<td>
|
||||
<small><?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></small>
|
||||
<?php if ($user['last_login']): ?>
|
||||
<br><small style="color: #666;">Вход: <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span style="color: <?= $user['is_active'] ? 'green' : 'red' ?>">
|
||||
<?= $user['is_active'] ? '✅ Активен' : '❌ Неактивен' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
||||
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
|
||||
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/toggle-status" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button secondary" title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
|
||||
<?= $user['is_active'] ? '⏸️' : '▶️' ?>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить">
|
||||
🗑️
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<small style="color: #666;">Текущий пользователь</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
|
|
@ -3,6 +3,23 @@
|
|||
include 'views/layouts/header.php';
|
||||
?>
|
||||
<h1>Создание новой книги</h1>
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($_SESSION['error']) ?>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($cover_error) && $cover_error): ?>
|
||||
<div class="alert alert-error">
|
||||
Ошибка загрузки обложки: <?= e($cover_error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<div style="max-width: 100%; margin-bottom: 0.5rem;">
|
||||
|
|
@ -21,13 +38,7 @@ include 'views/layouts/header.php';
|
|||
value="<?= e($_POST['genre'] ?? '') ?>"
|
||||
placeholder="Например: Фантастика, Роман, Детектив..."
|
||||
style="width: 100%; margin-bottom: 1.5rem;">
|
||||
<label for="editor_type" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Режим редактора
|
||||
</label>
|
||||
<select id="editor_type" name="editor_type" style="width: 100%; margin-bottom: 1.5rem;">
|
||||
<option value="markdown" <?= ($_POST['editor_type'] ?? 'markdown') == 'markdown' ? 'selected' : '' ?>>Markdown редактор</option>
|
||||
<option value="html" <?= ($_POST['editor_type'] ?? '') == 'html' ? 'selected' : '' ?>>HTML редактор (TinyMCE)</option>
|
||||
</select>
|
||||
|
||||
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Серия
|
||||
</label>
|
||||
|
|
@ -46,6 +57,16 @@ include 'views/layouts/header.php';
|
|||
placeholder="Краткое описание сюжета или аннотация..."
|
||||
rows="6"
|
||||
style="width: 100;"><?= e($_POST['description'] ?? '') ?></textarea>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Обложка книги
|
||||
</label>
|
||||
<input type="file" id="cover_image" name="cover_image"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp">
|
||||
<small style="color: var(--muted-color);">
|
||||
Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
|
||||
</small>
|
||||
</div>
|
||||
<div style="margin-top: 1rem;">
|
||||
<label for="published">
|
||||
<input type="checkbox" id="published" name="published" value="1"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@
|
|||
// views/books/edit.php
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
<?php if (isset($_SESSION['cover_error'])): ?>
|
||||
<div class="alert alert-error">
|
||||
Ошибка загрузки обложки: <?= e($_SESSION['cover_error']) ?>
|
||||
<?php unset($_SESSION['cover_error']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<h1>Редактирование книги</h1>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
|
@ -21,19 +27,6 @@ include 'views/layouts/header.php';
|
|||
value="<?= e($book['genre'] ?? '') ?>"
|
||||
placeholder="Например: Фантастика, Роман, Детектив..."
|
||||
style="width: 100%; margin-bottom: 1.5rem;">
|
||||
<label for="editor_type" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Режим редактора
|
||||
</label>
|
||||
<select id="editor_type" name="editor_type" style="width: 100%; margin-bottom: 1.5rem;" onchange="showEditorWarning(this)">
|
||||
<?php foreach ($editor_types as $type => $label): ?>
|
||||
<option value="<?= e($type) ?>" <?= ($book['editor_type'] ?? 'markdown') == $type ? 'selected' : '' ?>>
|
||||
<?= e($label) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div id="editor_warning" style="display: none; background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 4px; margin-bottom: 1rem;">
|
||||
<strong>Внимание:</strong> При смене редактора содержимое всех глав будет автоматически сконвертировано в новый формат.
|
||||
</div>
|
||||
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Серия
|
||||
</label>
|
||||
|
|
@ -111,15 +104,6 @@ include 'views/layouts/header.php';
|
|||
</form>
|
||||
|
||||
<?php if ($book): ?>
|
||||
<div style="margin-top: 2rem;">
|
||||
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/normalize" onsubmit="return confirm('Нормализовать контент всех глав книги? Это действие нельзя отменить.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="button secondary">🔄 Нормализовать контент глав</button>
|
||||
<p style="margin-top: 0.5rem; font-size: 0.8em; color: var(--muted-color);">
|
||||
Если контент глав отображается неправильно после смены редактора, можно нормализовать его структуру.
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>Публичная ссылка для чтения</h3>
|
||||
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;">
|
||||
|
|
@ -224,22 +208,9 @@ include 'views/layouts/header.php';
|
|||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
function showEditorWarning(select) {
|
||||
const warning = document.getElementById('editor_warning');
|
||||
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
|
||||
if (select.value !== currentEditor) {
|
||||
warning.style.display = 'block';
|
||||
} else {
|
||||
warning.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
|
||||
const selectedEditor = document.getElementById('editor_type').value;
|
||||
if (currentEditor !== selectedEditor) {
|
||||
document.getElementById('editor_warning').style.display = 'block';
|
||||
}
|
||||
|
||||
// Копирование ссылки для чтения
|
||||
window.copyShareLink = function() {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@
|
|||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Мои книги</h1>
|
||||
<h1>Мои книги <small style="color: #ccc; font-size:1rem;">(Всего книг: <?= count($books) ?>)</small></h1>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<h2 style="margin: 0;">Всего книг: <?= count($books) ?></h2>
|
||||
|
||||
<div style="display: flex; justify-content: right; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<a href="<?= SITE_URL ?>/books/create" class="action-button primary">➕ Новая книга</a>
|
||||
<?php if (!empty($books)): ?>
|
||||
<a href="#" onclick="showDeleteAllConfirmation()" class="action-button delete">🗑️ Удалить все книги</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (empty($books)): ?>
|
||||
|
|
@ -17,50 +20,103 @@ include 'views/layouts/header.php';
|
|||
<a href="<?= SITE_URL ?>/books/create" role="button">📖 Создать первую книгу</a>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<div class="grid">
|
||||
<div class="books-grid">
|
||||
<?php foreach ($books as $book): ?>
|
||||
<article>
|
||||
<header>
|
||||
<h3 style="margin-bottom: 0.5rem;">
|
||||
<?= e($book['title']) ?>
|
||||
<div style="float: right; display: flex; gap: 3px;">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button secondary" title="Редактировать книгу">
|
||||
✏️
|
||||
<article class="book-card">
|
||||
<!-- Обложка книги -->
|
||||
<div class="book-cover-container">
|
||||
<?php if (!empty($book['cover_image'])): ?>
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
class="book-cover"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="cover-placeholder" style="display: none;">
|
||||
📚
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="cover-placeholder">
|
||||
📚
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Статус книги -->
|
||||
<div class="book-status <?= $book['published'] ? 'published' : 'draft' ?>">
|
||||
<?= $book['published'] ? '✅' : '📝' ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Информация о книге -->
|
||||
<div class="book-info">
|
||||
<h3 class="book-title">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit">
|
||||
<?= e($book['title']) ?>
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="compact-button secondary" title="Просмотреть книгу" target="_blank">
|
||||
👁️
|
||||
</h3>
|
||||
|
||||
<?php if (!empty($book['genre'])): ?>
|
||||
<p class="book-genre"><?= e($book['genre']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($book['description'])): ?>
|
||||
<p class="book-description">
|
||||
<?= e(mb_strimwidth($book['description'], 0, 120, '...')) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="book-stats">
|
||||
<span class="stat-item">
|
||||
<strong><?= $book['chapter_count'] ?? 0 ?></strong> глав
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<strong><?= number_format($book['total_words'] ?? 0) ?></strong> слов
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="book-actions">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button primary-btn">
|
||||
✏️ Редактировать
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="compact-button secondary-btn">
|
||||
📑 Главы
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="compact-button secondary-btn" target="_blank">
|
||||
👁️ Просмотр
|
||||
</a>
|
||||
</div>
|
||||
</h3>
|
||||
<?php if ($book['genre']): ?>
|
||||
<p style="margin: 0; color: var(--muted-color);"><em><?= e($book['genre']) ?></em></p>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<?php if ($book['description']): ?>
|
||||
<p><?= e(mb_strimwidth($book['description'], 0, 200, '...')) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<footer>
|
||||
<div>
|
||||
<small>
|
||||
Глав: <?= $book['chapter_count'] ?> |
|
||||
Слов: <?= $book['total_words'] ?> |
|
||||
Статус: <?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
|
||||
</small>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; display: flex; gap: 5px; flex-wrap: wrap;">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="adaptive-button secondary">
|
||||
📑 Главы
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>" class="adaptive-button secondary" target="_blank">
|
||||
📄 Экспорт
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Статистика внизу -->
|
||||
<div class="books-stats-footer">
|
||||
<strong>Общая статистика:</strong>
|
||||
Книг: <?= count($books) ?> |
|
||||
Глав: <?= array_sum(array_column($books, 'chapter_count')) ?> |
|
||||
Слов: <?= number_format(array_sum(array_column($books, 'total_words'))) ?> |
|
||||
Опубликовано: <?= count(array_filter($books, function($book) { return $book['published']; })) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($books)): ?>
|
||||
<script>
|
||||
function showDeleteAllConfirmation() {
|
||||
if (confirm('Вы уверены, что хотите удалить ВСЕ книги? Это действие также удалит все главы и обложки книг. Действие нельзя отменить!')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '<?= SITE_URL ?>/books/delete-all';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= generate_csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
|
|
@ -27,36 +27,9 @@ include 'views/layouts/header.php';
|
|||
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Содержание главы *
|
||||
</label>
|
||||
|
||||
<?php if (($book['editor_type'] ?? 'markdown') == 'html'): ?>
|
||||
<textarea id="content" name="content" class="html-editor"
|
||||
placeholder="Начните писать вашу главу..."
|
||||
rows="20"
|
||||
style="width: 100%;"><?= e($_POST['content'] ?? '') ?></textarea>
|
||||
<?php else: ?>
|
||||
<div style="margin-bottom: 1rem; padding: 0.5rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<div style="display: flex; gap: 3px; flex-wrap: nowrap; overflow-x: auto; padding: 5px 0;">
|
||||
<button type="button" onclick="insertMarkdown('**')" class="compact-button secondary" title="Жирный текст" style="white-space: nowrap; flex-shrink: 0;">**B**</button>
|
||||
<button type="button" onclick="insertMarkdown('*')" class="compact-button secondary" title="Курсив" style="white-space: nowrap; flex-shrink: 0;">*I*</button>
|
||||
<button type="button" onclick="insertMarkdown('~~')" class="compact-button secondary" title="Зачеркнутый" style="white-space: nowrap; flex-shrink: 0;">~~S~~</button>
|
||||
<button type="button" onclick="insertMarkdown('`')" class="compact-button secondary" title="Код" style="white-space: nowrap; flex-shrink: 0;">`code`</button>
|
||||
<button type="button" onclick="insertMarkdown('\n\n- ')" class="compact-button secondary" title="Список" style="white-space: nowrap; flex-shrink: 0;">- список</button>
|
||||
<button type="button" onclick="insertMarkdown('\n\n> ')" class="compact-button secondary" title="Цитата" style="white-space: nowrap; flex-shrink: 0;">> цитата</button>
|
||||
<button type="button" onclick="insertMarkdown('\n\n# ')" class="compact-button secondary" title="Заголовок" style="white-space: nowrap; flex-shrink: 0;"># Заголовок</button>
|
||||
<button type="button" onclick="insertMarkdown('\n— ')" class="compact-button secondary" title="Диалог" style="white-space: nowrap; flex-shrink: 0;">— диалог</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea id="content" name="content"
|
||||
placeholder="Начните писать вашу главу... Поддерживается Markdown разметка."
|
||||
rows="20"
|
||||
style="width: 100%; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px; line-height: 1.5;"><?= e($_POST['content'] ?? '') ?></textarea>
|
||||
|
||||
<div style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
|
||||
<strong>Подсказка:</strong> Используйте Markdown для форматирования.
|
||||
<a href="https://commonmark.org/help/" target="_blank">Справка по Markdown</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<textarea id="content" name="content" class="writer-editor" style="display: none;">
|
||||
<?= e($_POST['content'] ?? '') ?>
|
||||
</textarea>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
|
|
@ -86,7 +59,7 @@ include 'views/layouts/header.php';
|
|||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<link href="/assets/css/quill_reset.css" rel="stylesheet">
|
||||
<script>
|
||||
function previewChapter() {
|
||||
const form = document.getElementById('chapter-form');
|
||||
|
|
@ -128,7 +101,6 @@ function previewChapter() {
|
|||
document.body.removeChild(tempForm);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script src="/assets/js/markdown-editor.js"></script>
|
||||
<script src="/assets/js/editor.js"></script>
|
||||
<script src="/assets/js/autosave.js"></script>
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
|
|
@ -27,23 +27,10 @@ include 'views/layouts/header.php';
|
|||
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Содержание главы *
|
||||
</label>
|
||||
<textarea id="content" name="content" class="writer-editor" style="display: none;">
|
||||
<?= e($chapter['content'] ?? '') ?>
|
||||
</textarea>
|
||||
|
||||
<?php if (($book['editor_type'] ?? 'markdown') == 'html'): ?>
|
||||
<textarea id="content" name="content" class="html-editor"
|
||||
placeholder="Начните писать вашу главу..."
|
||||
rows="20"
|
||||
style="width: 100%;"><?= e($chapter['content']) ?></textarea>
|
||||
<?php else: ?>
|
||||
<textarea id="content" name="content"
|
||||
placeholder="Начните писать вашу главу... Поддерживается Markdown разметка."
|
||||
rows="20"
|
||||
style="width: 100%; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px; line-height: 1.5;"><?= e($chapter['content']) ?></textarea>
|
||||
|
||||
<div style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
|
||||
<strong>Подсказка:</strong> Используйте Markdown для форматирования.
|
||||
<a href="https://commonmark.org/help/" target="_blank">Справка по Markdown</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
|
|
@ -81,7 +68,7 @@ include 'views/layouts/header.php';
|
|||
<p><strong>Создана:</strong> <?= date('d.m.Y H:i', strtotime($chapter['created_at'])) ?></p>
|
||||
<p><strong>Обновлена:</strong> <?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></p>
|
||||
</div>
|
||||
|
||||
<link href="/assets/css/quill_reset.css" rel="stylesheet">
|
||||
<script>
|
||||
function previewChapter() {
|
||||
const form = document.getElementById('chapter-form');
|
||||
|
|
@ -118,6 +105,6 @@ function previewChapter() {
|
|||
document.body.removeChild(tempForm);
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/markdown-editor.js"></script>
|
||||
<script src="/assets/js/editor.js"></script>
|
||||
<script src="/assets/js/autosave.js"></script>
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
|
|
@ -25,24 +25,6 @@
|
|||
console.error('Ошибка копирования: ', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Инициализация TinyMCE если есть текстовые редакторы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const htmlEditors = document.querySelectorAll('.html-editor');
|
||||
htmlEditors.forEach(function(editor) {
|
||||
if (typeof tinymce !== 'undefined') {
|
||||
tinymce.init({
|
||||
selector: '#' + editor.id,
|
||||
plugins: 'advlist autolink lists link image charmap preview anchor',
|
||||
toolbar: 'undo redo | formatselect | bold italic | alignleft aligncenter alignright | bullist numlist outdent indent | link image',
|
||||
language: 'ru',
|
||||
height: 400,
|
||||
menubar: false,
|
||||
statusbar: false
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -9,7 +9,8 @@
|
|||
<title><?= e($page_title ?? 'Web Writer') ?></title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.10/css/pico.min.css">
|
||||
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.6/tinymce.min.js" referrerpolicy="origin"></script>
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="container-fluid">
|
||||
|
|
@ -28,6 +29,10 @@
|
|||
</summary>
|
||||
<ul role="listbox">
|
||||
<li><a href="<?= SITE_URL ?>/profile">⚙️ Профиль</a></li>
|
||||
<li><a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">👤 Моя публичная страница</a></li>
|
||||
<?php if ($_SESSION['user_id'] == 1): // Проверка на администратора ?>
|
||||
<li><a href="<?= SITE_URL ?>/admin/users">👥 Управление пользователями</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="<?= SITE_URL ?>/logout">🚪 Выход</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
|
|
|
|||
|
|
@ -1,126 +1,218 @@
|
|||
<?php
|
||||
// views/series/edit.php
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Редактирование серии: <?= e($series['title']) ?></h1>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="grid">
|
||||
<div>
|
||||
<article>
|
||||
<h2>Основная информация</h2>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/edit">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<label for="title">
|
||||
Название серии *
|
||||
<input type="text" id="title" name="title" value="<?= e($series['title']) ?>" required>
|
||||
</label>
|
||||
|
||||
<div style="max-width: 100%; margin-bottom: 1rem;">
|
||||
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Название серии *
|
||||
</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="<?= e($series['title']) ?>"
|
||||
placeholder="Введите название серии"
|
||||
style="width: 100%; margin-bottom: 1.5rem;"
|
||||
required>
|
||||
<label for="description">
|
||||
Описание серии
|
||||
<textarea id="description" name="description" rows="4"><?= e($series['description'] ?? '') ?></textarea>
|
||||
</label>
|
||||
|
||||
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Описание серии
|
||||
</label>
|
||||
<textarea id="description" name="description"
|
||||
placeholder="Описание сюжета серии, общая концепция..."
|
||||
rows="6"
|
||||
style="width: 100%;"><?= e($series['description']) ?></textarea>
|
||||
<button type="submit" class="primary-btn">Сохранить изменения</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Добавить книгу в серию</h2>
|
||||
<?php
|
||||
$available_books = $bookModel->getBooksNotInSeries($_SESSION['user_id'], $series['id']);
|
||||
?>
|
||||
|
||||
<?php if (!empty($available_books)): ?>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/add-book">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<label for="book_id">
|
||||
Выберите книгу
|
||||
<select id="book_id" name="book_id" required>
|
||||
<option value="">-- Выберите книгу --</option>
|
||||
<?php foreach ($available_books as $book): ?>
|
||||
<option value="<?= $book['id'] ?>"><?= e($book['title']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label for="sort_order">
|
||||
Порядковый номер в серии
|
||||
<input type="number" id="sort_order" name="sort_order" value="<?= count($books_in_series) + 1 ?>" min="1">
|
||||
</label>
|
||||
|
||||
<button type="submit" class="secondary-btn">Добавить в серию</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>Все ваши книги уже добавлены в эту серию или у вас нет доступных книг.</p>
|
||||
<a href="/books/create" class="primary-btn">Создать новую книгу</a>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button type="submit" class="contrast">
|
||||
💾 Сохранить изменения
|
||||
</button>
|
||||
<div>
|
||||
<article>
|
||||
<h2>Книги в серии (<?= count($books_in_series) ?>)</h2>
|
||||
|
||||
<a href="<?= SITE_URL ?>/series" role="button" class="secondary">
|
||||
❌ Отмена
|
||||
</a>
|
||||
<?php if (!empty($books_in_series)): ?>
|
||||
<div id="series-books-list">
|
||||
<form id="reorder-form" method="post" action="/series/<?= $series['id'] ?>/update-order">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="books-list">
|
||||
<?php foreach ($books_in_series as $index => $book): ?>
|
||||
<div class="book-item" data-book-id="<?= $book['id'] ?>">
|
||||
<div class="book-drag-handle" style="cursor: move;">☰</div>
|
||||
<div class="book-info">
|
||||
<strong><?= e($book['title']) ?></strong>
|
||||
<small>Порядок: <?= $book['sort_order_in_series'] ?></small>
|
||||
</div>
|
||||
<div class="book-actions">
|
||||
<a href="/books/<?= $book['id'] ?>/edit" class="compact-button">Редактировать</a>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/remove-book/<?= $book['id'] ?>"
|
||||
style="display: inline;" onsubmit="return confirm('Удалить книгу из серии?')">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button delete-btn">Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
<input type="hidden" name="order[]" value="<?= $book['id'] ?>">
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="secondary-btn" id="save-order-btn" style="display: none;">
|
||||
Сохранить порядок
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p>В этой серии пока нет книг. Добавьте книги с помощью формы слева.</p>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Статистика серии</h2>
|
||||
<div class="stats-list">
|
||||
<p><strong>Количество книг:</strong> <?= count($books_in_series) ?></p>
|
||||
<?php
|
||||
$total_words = 0;
|
||||
$total_chapters = 0;
|
||||
foreach ($books_in_series as $book) {
|
||||
$stats = $bookModel->getBookStats($book['id']);
|
||||
$total_words += $stats['total_words'] ?? 0;
|
||||
$total_chapters += $stats['chapter_count'] ?? 0;
|
||||
}
|
||||
?>
|
||||
<p><strong>Всего глав:</strong> <?= $total_chapters ?></p>
|
||||
<p><strong>Всего слов:</strong> <?= $total_words ?></p>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if ($series): ?>
|
||||
<div style="margin-top: 3rem;">
|
||||
<h3>Книги в этой серии</h3>
|
||||
|
||||
<?php if (empty($books_in_series)): ?>
|
||||
<div style="text-align: center; padding: 2rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<p>В этой серии пока нет книг.</p>
|
||||
<a href="<?= SITE_URL ?>/books" class="adaptive-button">📚 Добавить книги</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x: auto;">
|
||||
<table class="compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 10%;">Порядок</th>
|
||||
<th style="width: 40%;">Название книги</th>
|
||||
<th style="width: 20%;">Жанр</th>
|
||||
<th style="width: 15%;">Статус</th>
|
||||
<th style="width: 15%;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($books_in_series as $book): ?>
|
||||
<tr>
|
||||
<td><?= $book['sort_order_in_series'] ?></td>
|
||||
<td>
|
||||
<strong><?= e($book['title']) ?></strong>
|
||||
<?php if ($book['description']): ?>
|
||||
<br><small style="color: var(--muted-color);"><?= e(mb_strimwidth($book['description'], 0, 100, '...')) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= e($book['genre']) ?></td>
|
||||
<td>
|
||||
<span style="color: <?= $book['published'] ? 'green' : 'orange' ?>">
|
||||
<?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button secondary">
|
||||
Редактировать
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
// Вычисляем общую статистику
|
||||
$total_chapters = 0;
|
||||
$total_words = 0;
|
||||
foreach ($books_in_series as $book) {
|
||||
$bookModel = new Book($pdo);
|
||||
$stats = $bookModel->getBookStats($book['id']);
|
||||
$total_chapters += $stats['chapter_count'] ?? 0;
|
||||
$total_words += $stats['total_words'] ?? 0;
|
||||
}
|
||||
?>
|
||||
|
||||
<div style="margin-top: 1rem; padding: 0.5rem; background: var(--card-background-color); border-radius: 3px;">
|
||||
<strong>Статистика серии:</strong>
|
||||
Книг: <?= count($books_in_series) ?> |
|
||||
Глав: <?= $total_chapters ?> |
|
||||
Слов: <?= $total_words ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; text-align: center;">
|
||||
<form method="post" action="<?= SITE_URL ?>/series/<?= $series['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить серию «<?= e($series['title']) ?>»? Книги останутся, но будут убраны из серии.');">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="button" style="background: #ff4444; border-color: #ff4444; color: white;">
|
||||
🗑️ Удалить серию
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<style>
|
||||
.books-list {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
.book-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: white;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.book-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.book-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.book-item.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.book-item.sortable-chosen {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.book-drag-handle {
|
||||
padding: 0 10px;
|
||||
color: #666;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.book-info strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.book-info small {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.book-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const booksList = document.querySelector('.books-list');
|
||||
const saveOrderBtn = document.getElementById('save-order-btn');
|
||||
|
||||
if (booksList) {
|
||||
const sortable = new Sortable(booksList, {
|
||||
handle: '.book-drag-handle',
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
animation: 150,
|
||||
onUpdate: function() {
|
||||
saveOrderBtn.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Автосохранение порядка через 2 секунды после изменения
|
||||
let saveTimeout;
|
||||
saveOrderBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
clearTimeout(saveTimeout);
|
||||
document.getElementById('reorder-form').submit();
|
||||
});
|
||||
|
||||
// Автоматическое сохранение при изменении порядка
|
||||
booksList.addEventListener('sortupdate', function() {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(() => {
|
||||
document.getElementById('reorder-form').submit();
|
||||
}, 2000);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php
|
||||
include 'views/layouts/footer.php';
|
||||
?>
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
<?php
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div style="display: flex; justify-content: between; align-items: center; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<h1 style="margin: 0;">Мои серии книг</h1>
|
||||
<a href="/series/create" class="action-button primary">➕ Создать серию</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($series)): ?>
|
||||
<article class="series-empty-state">
|
||||
<div class="series-empty-icon">📚</div>
|
||||
<h2>Пока нет серий</h2>
|
||||
<p style="color: #666; margin-bottom: 2rem;">
|
||||
Создайте свою первую серию, чтобы организовать книги в циклы и сериалы.
|
||||
</p>
|
||||
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="/series/create" class="action-button primary">Создать серию</a>
|
||||
<a href="/books" class="action-button secondary">Перейти к книгам</a>
|
||||
</div>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<div class="series-grid">
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<article class="series-card">
|
||||
<div class="series-header">
|
||||
<h3 class="series-title">
|
||||
<a href="/series/<?= $ser['id'] ?>/edit"><?= e($ser['title']) ?></a>
|
||||
</h3>
|
||||
<div class="series-meta">
|
||||
Создана <?= date('d.m.Y', strtotime($ser['created_at'])) ?>
|
||||
<?php if ($ser['updated_at'] != $ser['created_at']): ?>
|
||||
• Обновлена <?= date('d.m.Y', strtotime($ser['updated_at'])) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($ser['description'])): ?>
|
||||
<div class="series-description">
|
||||
<?= e($ser['description']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="series-stats-grid">
|
||||
<div class="series-stat">
|
||||
<span class="series-stat-number"><?= $ser['book_count'] ?? 0 ?></span>
|
||||
<span class="series-stat-label">книг</span>
|
||||
</div>
|
||||
<div class="series-stat">
|
||||
<span class="series-stat-number"><?= number_format($ser['total_words'] ?? 0) ?></span>
|
||||
<span class="series-stat-label">слов</span>
|
||||
</div>
|
||||
<div class="series-stat">
|
||||
<span class="series-stat-number">
|
||||
<?php
|
||||
$avg_words = $ser['book_count'] > 0 ? round($ser['total_words'] / $ser['book_count']) : 0;
|
||||
echo number_format($avg_words);
|
||||
?>
|
||||
</span>
|
||||
<span class="series-stat-label">слов/книга</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="series-actions">
|
||||
<a href="/series/<?= $ser['id'] ?>/edit" class="compact-button primary-btn">
|
||||
✏️ Управление
|
||||
</a>
|
||||
<a href="/series/<?= $ser['id'] ?>/view" class="compact-button secondary-btn" target="_blank">
|
||||
👁️ Публично
|
||||
</a>
|
||||
<form method="post" action="/series/<?= $ser['id'] ?>/delete"
|
||||
style="display: inline;"
|
||||
onsubmit="return confirm('Удалить серию? Книги останутся, но будут удалены из серии.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button delete-btn">🗑️</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
include 'views/layouts/footer.php';
|
||||
?>
|
||||
|
|
@ -14,7 +14,7 @@ include 'views/layouts/header.php';
|
|||
|
||||
<?php if ($series['description']): ?>
|
||||
<div style="background: var(--card-background-color); padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;">
|
||||
<?= $Parsedown->text($series['description']) ?>
|
||||
<?= e($series['description']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ include 'views/layouts/header.php';
|
|||
<!-- Биография автора -->
|
||||
<?php if (!empty($user['bio'])): ?>
|
||||
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
|
||||
<?= $Parsedown->text($user['bio']) ?>
|
||||
<?= e($user['bio']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue