Compare commits
6 Commits
d7fe90a615
...
d850f54666
| Author | SHA1 | Date |
|---|---|---|
|
|
d850f54666 | |
|
|
07b69b7616 | |
|
|
14434bc2ac | |
|
|
833d125f64 | |
|
|
f093791c14 | |
|
|
24b3788493 |
|
|
@ -1,2 +0,0 @@
|
||||||
<?php
|
|
||||||
die();
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||||
|
}
|
||||||
1236
assets/css/style.css
1236
assets/css/style.css
File diff suppressed because it is too large
Load Diff
|
|
@ -1,98 +1,73 @@
|
||||||
// assets/js/autosave.js
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
const quill = window.quillEditorInstance;
|
||||||
const contentTextarea = document.getElementById('content');
|
const textarea = window.quillTextarea;
|
||||||
const titleInput = document.getElementById('title');
|
if (!quill || !textarea) return;
|
||||||
const statusSelect = document.getElementById('status');
|
|
||||||
|
let lastSavedContent = textarea.value;
|
||||||
// Проверяем, что это редактирование существующей главы
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const isEditMode = urlParams.has('id');
|
|
||||||
|
|
||||||
if (!contentTextarea || !isEditMode) {
|
|
||||||
console.log('Автосохранение отключено: создание новой главы');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let saveTimeout;
|
let saveTimeout;
|
||||||
let isSaving = false;
|
|
||||||
let lastSavedContent = contentTextarea.value;
|
function showMessage(message, isError = false) {
|
||||||
|
let msgEl = document.getElementById('autosave-message');
|
||||||
function showSaveMessage(message) {
|
if (!msgEl) {
|
||||||
let messageEl = document.getElementById('autosave-message');
|
msgEl = document.createElement('div');
|
||||||
if (!messageEl) {
|
msgEl.id = 'autosave-message';
|
||||||
messageEl = document.createElement('div');
|
msgEl.style.cssText = `
|
||||||
messageEl.id = 'autosave-message';
|
position: fixed;
|
||||||
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;';
|
top: 70px;
|
||||||
document.body.appendChild(messageEl);
|
right: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: ${isError ? '#dc3545' : '#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(msgEl);
|
||||||
}
|
}
|
||||||
|
msgEl.textContent = message;
|
||||||
messageEl.textContent = message;
|
msgEl.style.background = isError ? '#dc3545' : '#28a745';
|
||||||
messageEl.style.display = 'block';
|
msgEl.style.display = 'block';
|
||||||
|
setTimeout(() => msgEl.style.display = 'none', 2000);
|
||||||
setTimeout(() => {
|
|
||||||
messageEl.style.display = 'none';
|
|
||||||
}, 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function autoSave() {
|
const autoSave = () => {
|
||||||
if (isSaving) return;
|
const currentContent = textarea.value;
|
||||||
|
|
||||||
const currentContent = contentTextarea.value;
|
|
||||||
const currentTitle = titleInput ? titleInput.value : '';
|
|
||||||
const currentStatus = statusSelect ? statusSelect.value : 'draft';
|
|
||||||
|
|
||||||
if (currentContent === lastSavedContent) return;
|
if (currentContent === lastSavedContent) return;
|
||||||
|
|
||||||
isSaving = true;
|
const form = document.getElementById('chapter-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('content', currentContent);
|
|
||||||
formData.append('title', currentTitle);
|
|
||||||
formData.append('status', currentStatus);
|
|
||||||
formData.append('autosave', 'true');
|
formData.append('autosave', 'true');
|
||||||
formData.append('csrf_token', document.querySelector('input[name="csrf_token"]').value);
|
|
||||||
|
showMessage('Сохранение...');
|
||||||
|
|
||||||
fetch(window.location.href, {
|
fetch(window.location.href, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
.then(response => response.json())
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
lastSavedContent = currentContent;
|
lastSavedContent = currentContent;
|
||||||
showSaveMessage('Сохранено: ' + new Date().toLocaleTimeString());
|
showMessage('Автосохранено: ' + new Date().toLocaleTimeString());
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Ошибка сервера');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(err => {
|
||||||
console.error('Ошибка автосохранения:', error);
|
console.error(err);
|
||||||
})
|
showMessage('Ошибка автосохранения: ' + err.message, true);
|
||||||
.finally(() => {
|
|
||||||
isSaving = false;
|
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
contentTextarea.addEventListener('input', function() {
|
quill.on('text-change', () => {
|
||||||
clearTimeout(saveTimeout);
|
clearTimeout(saveTimeout);
|
||||||
saveTimeout = setTimeout(autoSave, 2000);
|
saveTimeout = setTimeout(autoSave, 2000);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (titleInput) {
|
// Периодическая автосохранение
|
||||||
titleInput.addEventListener('input', function() {
|
setInterval(autoSave, 30000);
|
||||||
clearTimeout(saveTimeout);
|
});
|
||||||
saveTimeout = setTimeout(autoSave, 2000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (statusSelect) {
|
|
||||||
statusSelect.addEventListener('change', autoSave);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', function(e) {
|
|
||||||
if (contentTextarea.value !== lastSavedContent) {
|
|
||||||
e.preventDefault();
|
|
||||||
e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
//console.log('Автосохранение включено для редактирования главы');
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
class WriterEditor {
|
||||||
|
constructor(formSelector = '#chapter-form', editorContainerId = 'quill-editor', textareaId = 'content') {
|
||||||
|
this.form = document.querySelector(formSelector);
|
||||||
|
this.editorContainer = document.getElementById(editorContainerId);
|
||||||
|
this.textarea = document.getElementById(textareaId);
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (!this.editorContainer || !this.textarea || !this.form) return;
|
||||||
|
|
||||||
|
this.quill = new Quill(this.editorContainer, {
|
||||||
|
theme: 'snow',
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||||
|
['bold','italic','underline','strike'],
|
||||||
|
['blockquote','code-block'],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||||
|
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||||
|
[{ 'size': ['small', false, 'large', 'huge'] }],
|
||||||
|
[{ 'font': [] }],
|
||||||
|
['link','image','video'],
|
||||||
|
['clean']
|
||||||
|
],
|
||||||
|
history: { delay: 1000, maxStack: 100, userOnly: true }
|
||||||
|
},
|
||||||
|
placeholder: 'Введите текст главы...'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загружаем текст
|
||||||
|
const rawContent = this.editorContainer.dataset.content || '';
|
||||||
|
if (rawContent.trim()) this.quill.root.innerHTML = rawContent.trim();
|
||||||
|
|
||||||
|
// Синхронизация с textarea
|
||||||
|
const sync = () => {
|
||||||
|
let html = this.quill.root.innerHTML;
|
||||||
|
html = html.replace(/^(<p><br><\/p>)+/, '').replace(/(<p><br><\/p>)+$/, '');
|
||||||
|
this.textarea.value = html;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.quill.on('text-change', sync);
|
||||||
|
this.form.addEventListener('submit', sync);
|
||||||
|
|
||||||
|
// Делаем глобально доступным для автосейва
|
||||||
|
window.quillEditorInstance = this.quill;
|
||||||
|
window.quillTextarea = this.textarea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
window.writerEditor = new WriterEditor();
|
||||||
|
});
|
||||||
|
|
@ -1,563 +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);
|
|
||||||
|
|
||||||
initEditor();
|
|
||||||
|
|
||||||
function initEditor() {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,47 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
$_SESSION['error'] = "Неверный метод запроса";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$book_id = $_POST['book_id'] ?? null;
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
|
|
||||||
if (!$book_id) {
|
|
||||||
$_SESSION['error'] = "Не указана книга для удаления";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
|
|
||||||
// Проверяем права доступа
|
|
||||||
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
|
||||||
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем информацию о книге перед удалением
|
|
||||||
$book = $bookModel->findById($book_id);
|
|
||||||
if (!empty($book['cover_image'])) {
|
|
||||||
$cover_path = COVERS_PATH . $book['cover_image'];
|
|
||||||
if (file_exists($cover_path)) {
|
|
||||||
unlink($cover_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Удаляем книгу
|
|
||||||
if ($bookModel->delete($book_id, $user_id)) {
|
|
||||||
$_SESSION['success'] = "Книга «" . e($book['title']) . "» успешно удалена";
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = "Ошибка при удалении книги";
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect('books.php');
|
|
||||||
?>
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
$_SESSION['error'] = "Неверный метод запроса";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
|
|
||||||
// Получаем все книги пользователя
|
|
||||||
$books = $bookModel->findByUser($user_id);
|
|
||||||
|
|
||||||
if (empty($books)) {
|
|
||||||
$_SESSION['error'] = "У вас нет книг для удаления";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$deleted_count = 0;
|
|
||||||
$error_count = 0;
|
|
||||||
|
|
||||||
// Удаляем каждую книгу
|
|
||||||
foreach ($books as $book) {
|
|
||||||
if ($bookModel->delete($book['id'], $user_id)) {
|
|
||||||
$deleted_count++;
|
|
||||||
} else {
|
|
||||||
$error_count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($error_count === 0) {
|
|
||||||
$_SESSION['success'] = "Все книги успешно удалены ($deleted_count книг)";
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = "Удалено $deleted_count книг, не удалось удалить $error_count книг";
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect('books.php');
|
|
||||||
?>
|
|
||||||
413
book_edit.php
413
book_edit.php
|
|
@ -1,413 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
|
|
||||||
// Проверяем, редактируем ли существующую книгу
|
|
||||||
$book_id = $_GET['id'] ?? null;
|
|
||||||
$book = null;
|
|
||||||
$is_edit = false;
|
|
||||||
|
|
||||||
if ($book_id) {
|
|
||||||
$book = $bookModel->findById($book_id);
|
|
||||||
if (!$book || $book['user_id'] != $user_id) {
|
|
||||||
$_SESSION['error'] = "Книга не найдена или у вас нет доступа";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
$is_edit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка формы
|
|
||||||
$cover_error = '';
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect($is_edit ? "book_edit.php?id=$book_id" : 'book_edit.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = trim($_POST['title'] ?? '');
|
|
||||||
$description = trim($_POST['description'] ?? '');
|
|
||||||
$genre = trim($_POST['genre'] ?? '');
|
|
||||||
$editor_type = $_POST['editor_type'] ?? 'markdown';
|
|
||||||
|
|
||||||
if (empty($title)) {
|
|
||||||
$_SESSION['error'] = "Название книги обязательно";
|
|
||||||
} else {
|
|
||||||
$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;
|
|
||||||
|
|
||||||
if ($series_id && !$sort_order_in_series) {
|
|
||||||
$seriesModel = new Series($pdo);
|
|
||||||
$sort_order_in_series = $seriesModel->getNextSortOrder($series_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'title' => $title,
|
|
||||||
'description' => $description,
|
|
||||||
'genre' => $genre,
|
|
||||||
'user_id' => $user_id,
|
|
||||||
'series_id' => $series_id,
|
|
||||||
'sort_order_in_series' => $sort_order_in_series,
|
|
||||||
'editor_type' => $editor_type
|
|
||||||
];
|
|
||||||
$data['published'] = isset($_POST['published']) ? 1 : 0;
|
|
||||||
|
|
||||||
// Проверяем, изменился ли тип редактора
|
|
||||||
$editor_changed = false;
|
|
||||||
$old_editor_type = null;
|
|
||||||
|
|
||||||
if ($is_edit && $book['editor_type'] !== $editor_type) {
|
|
||||||
$editor_changed = true;
|
|
||||||
$old_editor_type = $book['editor_type'];
|
|
||||||
}
|
|
||||||
// Обработка загрузки обложки
|
|
||||||
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
|
|
||||||
$cover_result = handleCoverUpload($_FILES['cover_image'], $book_id);
|
|
||||||
if ($cover_result['success']) {
|
|
||||||
$bookModel->updateCover($book_id, $cover_result['filename']);
|
|
||||||
// Обновляем данные книги
|
|
||||||
$book = $bookModel->findById($book_id);
|
|
||||||
} else {
|
|
||||||
$cover_error = $cover_result['error'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка удаления обложки
|
|
||||||
if (isset($_POST['delete_cover']) && $_POST['delete_cover'] == '1') {
|
|
||||||
$bookModel->deleteCover($book_id);
|
|
||||||
$book = $bookModel->findById($book_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($is_edit) {
|
|
||||||
$success = $bookModel->update($book_id, $data);
|
|
||||||
|
|
||||||
// Конвертируем контент глав, если изменился редактор
|
|
||||||
if ($success && $editor_changed) {
|
|
||||||
$conversion_success = $bookModel->convertChaptersContent($book_id, $old_editor_type, $editor_type);
|
|
||||||
if (!$conversion_success) {
|
|
||||||
$_SESSION['warning'] = "Книга обновлена, но возникли ошибки при конвертации содержания глав";
|
|
||||||
} else {
|
|
||||||
$_SESSION['info'] = "Книга обновлена. Содержание глав сконвертировано в новый формат редактора.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$message = $success ? "Книга успешно обновлена" : "Ошибка при обновлении книги";
|
|
||||||
} else {
|
|
||||||
$success = $bookModel->create($data);
|
|
||||||
$message = $success ? "Книга успешно создана" : "Ошибка при создании книги";
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
$new_book_id = $pdo->lastInsertId();
|
|
||||||
redirect("book_edit.php?id=$new_book_id");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
$_SESSION['success'] = $message;
|
|
||||||
redirect('books.php');
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = $message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$page_title = $is_edit ? "Редактирование книги" : "Создание новой книги";
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<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;">
|
|
||||||
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Название книги *
|
|
||||||
</label>
|
|
||||||
<input type="text" id="title" name="title"
|
|
||||||
value="<?= e($book['title'] ?? $_POST['title'] ?? '') ?>"
|
|
||||||
placeholder="Введите название книги"
|
|
||||||
style="width: 100%; margin-bottom: 1.5rem;"
|
|
||||||
required>
|
|
||||||
|
|
||||||
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Жанр
|
|
||||||
</label>
|
|
||||||
<input type="text" id="genre" name="genre"
|
|
||||||
value="<?= e($book['genre'] ?? $_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;" onchange="showEditorWarning(this)">
|
|
||||||
<option value="markdown" <?= ($book['editor_type'] ?? 'markdown') == 'markdown' ? 'selected' : '' ?>>Markdown редактор</option>
|
|
||||||
<option value="html" <?= ($book['editor_type'] ?? '') == 'html' ? 'selected' : '' ?>>HTML редактор (TinyMCE)</option>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
<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';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Серия
|
|
||||||
</label>
|
|
||||||
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
|
|
||||||
<option value="">-- Без серии --</option>
|
|
||||||
<?php
|
|
||||||
$seriesModel = new Series($pdo);
|
|
||||||
$user_series = $seriesModel->findByUser($user_id, false);
|
|
||||||
|
|
||||||
foreach ($user_series as $ser):
|
|
||||||
$selected = ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '';
|
|
||||||
?>
|
|
||||||
<option value="<?= $ser['id'] ?>" <?= $selected ?>>
|
|
||||||
<?= e($ser['title']) ?>
|
|
||||||
</option>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="sort_order_in_series" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Порядок в серии
|
|
||||||
</label>
|
|
||||||
<input type="number" id="sort_order_in_series" name="sort_order_in_series"
|
|
||||||
value="<?= e($book['sort_order_in_series'] ?? '') ?>"
|
|
||||||
placeholder="Номер по порядку в серии"
|
|
||||||
min="1"
|
|
||||||
style="width: 100%; margin-bottom: 1.5rem;">
|
|
||||||
<!-- Обложка -->
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
|
||||||
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Обложка книги
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<?php if (!empty($book['cover_image'])): ?>
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<p><strong>Текущая обложка:</strong></p>
|
|
||||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
|
||||||
alt="Обложка"
|
|
||||||
style="max-width: 200px; height: auto; border-radius: 4px; border: 1px solid #ddd;">
|
|
||||||
<div style="margin-top: 0.5rem;">
|
|
||||||
<label style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<input type="checkbox" name="delete_cover" value="1">
|
|
||||||
Удалить обложку
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<input type="file" id="cover_image" name="cover_image"
|
|
||||||
accept="image/jpeg, image/png, image/gif, image/webp"
|
|
||||||
style="height: 2.6rem;">
|
|
||||||
<small style="color: #666;">
|
|
||||||
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
|
|
||||||
Рекомендуемый размер: 300×450 пикселей.
|
|
||||||
</small>
|
|
||||||
|
|
||||||
<?php if (!empty($cover_error)): ?>
|
|
||||||
<div style="color: #d32f2f; margin-top: 0.5rem;">
|
|
||||||
❌ <?= e($cover_error) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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($book['description'] ?? $_POST['description'] ?? '') ?></textarea>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label for="published">
|
|
||||||
<input type="checkbox" id="published" name="published" value="1"
|
|
||||||
<?= !empty($book['published']) || (!empty($_POST['published']) && $_POST['published']) ? 'checked' : '' ?>>
|
|
||||||
Опубликовать книгу (показывать на публичной странице автора)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
|
|
||||||
<button type="submit" class="contrast button">
|
|
||||||
<?= $is_edit ? '💾 Сохранить изменения' : '📖 Создать книгу' ?>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<form method="post" action="book_normalize_content.php" onsubmit="return confirm('Нормализовать контент всех глав книги? Это действие нельзя отменить.')">
|
|
||||||
<input type="hidden" name="book_id" value="<?= $book_id ?>">
|
|
||||||
<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: #666;">
|
|
||||||
Если контент глав отображается неправильно после смены редактора, можно нормализовать его структуру.
|
|
||||||
</p>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<form method="post" action="book_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
|
|
||||||
<input type="hidden" name="book_id" value="<?= $book['id'] ?>">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
<button type="submit" class="compact secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить книгу">
|
|
||||||
🗑️ Удалить главу
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<?php endif ?>
|
|
||||||
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<div style="margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
|
|
||||||
<h3>Публичная ссылка для чтения</h3>
|
|
||||||
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;">
|
|
||||||
<input type="text"
|
|
||||||
id="share-link"
|
|
||||||
value="<?= e(SITE_URL . '/view_book.php?share_token=' . $book['share_token']) ?>"
|
|
||||||
readonly
|
|
||||||
style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; background: white; width:80%;">
|
|
||||||
<br>
|
|
||||||
<button type="button" onclick="copyShareLink()" class="compact-button secondary" style="width: 15%;">
|
|
||||||
📋 Копировать
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<form method="post" action="book_regenerate_token.php" style="display: inline; margin-top: 1.5em;">
|
|
||||||
<input type="hidden" name="book_id" value="<?= $book_id ?>">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
<button type="submit" class="compact-button secondary" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')" >
|
|
||||||
🔄 Обновить
|
|
||||||
</button>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
<p style="margin-top: -1rem; font-size: 0.8em; color: #666; width: 100%;">
|
|
||||||
<strong>Примечание:</strong> В публичном просмотре отображаются только главы со статусом "Опубликована"
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function copyShareLink() {
|
|
||||||
const shareLink = document.getElementById('share-link');
|
|
||||||
shareLink.select();
|
|
||||||
shareLink.setSelectionRange(0, 99999);
|
|
||||||
document.execCommand('copy');
|
|
||||||
|
|
||||||
// Показать уведомление
|
|
||||||
const button = event.target;
|
|
||||||
const originalText = button.innerHTML;
|
|
||||||
button.innerHTML = '✅ Скопировано';
|
|
||||||
setTimeout(() => {
|
|
||||||
button.innerHTML = originalText;
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<div style="margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
|
|
||||||
<h3>Экспорт книги</h3>
|
|
||||||
<p style="margin-bottom: 0.5rem;">Экспортируйте книгу в различные форматы:</p>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
|
|
||||||
<a href="export_book.php?book_id=<?= $book_id ?>&format=pdf" class="adaptive-button secondary" target="_blank">
|
|
||||||
📄 PDF
|
|
||||||
</a>
|
|
||||||
<a href="export_book.php?book_id=<?= $book_id ?>&format=docx" class="adaptive-button secondary" target="_blank">
|
|
||||||
📝 DOCX
|
|
||||||
</a>
|
|
||||||
<a href="export_book.php?book_id=<?= $book_id ?>&format=html" class="adaptive-button secondary" target="_blank">
|
|
||||||
🌐 HTML
|
|
||||||
</a>
|
|
||||||
<a href="export_book.php?book_id=<?= $book_id ?>&format=txt" class="adaptive-button secondary" target="_blank">
|
|
||||||
📄 TXT
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="margin-top: 0.5rem; font-size: 0.9em; color: #666;">
|
|
||||||
<strong>Примечание:</strong> Экспортируются все главы книги (включая черновики)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<div style="margin-top: 3rem;">
|
|
||||||
<h2>Главы этой книги</h2>
|
|
||||||
<a href="chapters.php?book_id=<?= $book_id ?>" class="compact-button secondary">
|
|
||||||
📑 Все главы
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="chapter_edit.php?book_id=<?= $book_id ?>" role="button" class="compact-button secondary">
|
|
||||||
✏️ Добавить главу
|
|
||||||
</a>
|
|
||||||
<?php
|
|
||||||
// Получаем главы книги
|
|
||||||
$stmt = $pdo->prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY sort_order, created_at");
|
|
||||||
$stmt->execute([$book_id]);
|
|
||||||
$chapters = $stmt->fetchAll();
|
|
||||||
|
|
||||||
if ($chapters): ?>
|
|
||||||
<div style="overflow-x: auto;">
|
|
||||||
<table style="width: 100%;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th style="text-align: left; padding: 12px 8px;">Название</th>
|
|
||||||
<th style="text-align: left; padding: 12px 8px;">Статус</th>
|
|
||||||
<th style="text-align: left; padding: 12px 8px;">Слов</th>
|
|
||||||
<th style="text-align: left; padding: 12px 8px;">Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<?php foreach ($chapters as $chapter): ?>
|
|
||||||
<tr style="border-bottom: 1px solid #eee;">
|
|
||||||
<td style="padding: 12px 8px;"><?= e($chapter['title']) ?></td>
|
|
||||||
<td style="padding: 12px 8px;">
|
|
||||||
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>">
|
|
||||||
<?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style="padding: 12px 8px;"><?= $chapter['word_count'] ?></td>
|
|
||||||
<td style="padding: 12px 8px;">
|
|
||||||
<a href="chapter_edit.php?id=<?= $chapter['id'] ?>" role="button" class="compact-button secondary" style="text-decoration: none;">
|
|
||||||
Редактировать
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px;">
|
|
||||||
<p style="margin-bottom: 1rem;">В этой книге пока нет глав.</p>
|
|
||||||
<a href="chapter_edit.php?book_id=<?= $book_id ?>" role="button" class="compact-button secondary" >
|
|
||||||
✏️ Добавить первую главу
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
$_SESSION['error'] = "Неверный метод запроса";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$book_id = $_POST['book_id'] ?? null;
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
|
|
||||||
if (!$book_id) {
|
|
||||||
$_SESSION['error'] = "Не указана книга";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
|
|
||||||
// Проверяем права доступа
|
|
||||||
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
|
||||||
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Генерируем новый токен
|
|
||||||
$new_token = $bookModel->generateNewShareToken($book_id);
|
|
||||||
|
|
||||||
if ($new_token) {
|
|
||||||
$_SESSION['success'] = "Публичная ссылка обновлена";
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = "Ошибка при обновлении ссылки";
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect("book_edit.php?id=$book_id");
|
|
||||||
?>
|
|
||||||
156
books.php
156
books.php
|
|
@ -1,156 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
$books = $bookModel->findByUser($user_id);
|
|
||||||
|
|
||||||
$page_title = "Мои книги";
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<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($books) ?></h2>
|
|
||||||
<div style="display: flex; gap: 10px; align-items: center;">
|
|
||||||
<a href="book_edit.php" class="action-button primary">➕ Новая книга</a>
|
|
||||||
<?php if (!empty($books)): ?>
|
|
||||||
<button type="button" onclick="showDeleteConfirmation()" class="action-button delete">
|
|
||||||
🗑️ Удалить все книги
|
|
||||||
</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (empty($books)): ?>
|
|
||||||
<article style="text-align: center; padding: 2rem;">
|
|
||||||
<h3>У вас пока нет книг</h3>
|
|
||||||
<p>Создайте свою первую книгу и начните писать!</p>
|
|
||||||
<a href="book_edit.php" role="button">📖 Создать первую книгу</a>
|
|
||||||
</article>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="flex">
|
|
||||||
<?php foreach ($books as $book): ?>
|
|
||||||
<article>
|
|
||||||
<?php if ($book['cover_image']): ?>
|
|
||||||
<div style="text-align: center; margin-bottom: 1rem; float: left; margin-right: 2em;">
|
|
||||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
|
||||||
alt="<?= e($book['title']) ?>"
|
|
||||||
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;">
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<header>
|
|
||||||
<h3><?= e($book['title']) ?>
|
|
||||||
<?php if ($book['series_id']): ?>
|
|
||||||
<?php
|
|
||||||
$series_stmt = $pdo->prepare("SELECT title FROM series WHERE id = ?");
|
|
||||||
$series_stmt->execute([$book['series_id']]);
|
|
||||||
$series_title = $series_stmt->fetch()['title'] ?? '';
|
|
||||||
?>
|
|
||||||
<?php if ($series_title): ?>
|
|
||||||
<div style="margin: 0.3rem 0;">
|
|
||||||
<small style="color: #007bff;">
|
|
||||||
📚 Серия: <?= e($series_title) ?>
|
|
||||||
<?php if ($book['sort_order_in_series']): ?>
|
|
||||||
(Книга <?= $book['sort_order_in_series'] ?>)
|
|
||||||
<?php endif; ?>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
<div style="display: flex; gap: 3px; float:right;">
|
|
||||||
<a href="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary" title="Редактировать книгу">
|
|
||||||
✏️
|
|
||||||
</a>
|
|
||||||
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" class="compact-button secondary" title="Просмотреть книгу" target="_blank">
|
|
||||||
👁️
|
|
||||||
</a>
|
|
||||||
<a href="chapters.php?book_id=<?= $book['id'] ?>" class="compact-button secondary" title="Просмотр глав">
|
|
||||||
📑
|
|
||||||
</a>
|
|
||||||
<a href="export_book.php?book_id=<?= $book['id'] ?>&format=pdf" class="compact-button secondary" title="Экспорт в PDF" target="_blank">
|
|
||||||
📄
|
|
||||||
</a>
|
|
||||||
<form method="post" action="book_delete.php" style="display: inline; margin-top: -0.1em;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
|
|
||||||
<input type="hidden" name="book_id" value="<?= $book['id'] ?>">
|
|
||||||
<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>
|
|
||||||
</h3>
|
|
||||||
<?php if ($book['genre']): ?>
|
|
||||||
<small style="color: #666; margin-top: 0.1em;"><?= e($book['genre']) ?></small>
|
|
||||||
<?php endif; ?>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if ($book['description']): ?>
|
|
||||||
<p><?= e(mb_strimwidth($book['description'], 0, 150, '...')) ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<footer style="margin-top:1em; padding-top:2em;">
|
|
||||||
<div>
|
|
||||||
<small>
|
|
||||||
Глав: <?= $book['chapter_count'] ?> |
|
|
||||||
Слов: <?= $book['total_words'] ?>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<!-- Подтверждение удаления всех книг -->
|
|
||||||
<dialog id="deleteAllDialog" style="border-radius: 8px; padding: 20px; max-width: 500px; background-color: #fff;">
|
|
||||||
<h3 style="margin-top: 0;">Удалить все книги?</h3>
|
|
||||||
<p>Это действие удалит все ваши книги и все связанные с ними главы. Это действие нельзя отменить.</p>
|
|
||||||
|
|
||||||
<form method="post" action="book_delete_all.php" style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
<button type="button" onclick="closeDeleteDialog()" class="secondary" style="flex: 1;">
|
|
||||||
❌ Отмена
|
|
||||||
</button>
|
|
||||||
<button type="submit" class="contrast" style="flex: 1; background: #ff4444; border-color: #ff4444; color: white;">
|
|
||||||
🗑️ Удалить все
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function showDeleteConfirmation() {
|
|
||||||
const dialog = document.getElementById('deleteAllDialog');
|
|
||||||
dialog.showModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDeleteDialog() {
|
|
||||||
const dialog = document.getElementById('deleteAllDialog');
|
|
||||||
dialog.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Закрытие диалога по клику вне его области
|
|
||||||
document.getElementById('deleteAllDialog').addEventListener('click', function(event) {
|
|
||||||
if (event.target === this) {
|
|
||||||
closeDeleteDialog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
$_SESSION['error'] = "Неверный метод запроса";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapter_id = $_POST['chapter_id'] ?? null;
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
|
|
||||||
if (!$chapter_id) {
|
|
||||||
$_SESSION['error'] = "Не указана глава для удаления";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$chapterModel = new Chapter($pdo);
|
|
||||||
|
|
||||||
// Проверяем права доступа
|
|
||||||
if (!$chapterModel->userOwnsChapter($chapter_id, $user_id)) {
|
|
||||||
$_SESSION['error'] = "У вас нет доступа к этой главе";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
$chapter = $chapterModel->findById($chapter_id);
|
|
||||||
$book_id = $chapter['book_id'];
|
|
||||||
|
|
||||||
// Удаляем главу
|
|
||||||
if ($chapterModel->delete($chapter_id)) {
|
|
||||||
$_SESSION['success'] = "Глава успешно удалена";
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = "Ошибка при удалении главы";
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect("chapters.php?book_id=$book_id");
|
|
||||||
?>
|
|
||||||
386
chapter_edit.php
386
chapter_edit.php
|
|
@ -1,386 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$chapterModel = new Chapter($pdo);
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
|
|
||||||
// Получаем book_id из GET или из существующей главы
|
|
||||||
$chapter_id = $_GET['id'] ?? null;
|
|
||||||
$book_id = $_GET['book_id'] ?? null;
|
|
||||||
$chapter = null;
|
|
||||||
$is_edit = false;
|
|
||||||
|
|
||||||
// Если редактируем существующую главу
|
|
||||||
if ($chapter_id) {
|
|
||||||
$chapter = $chapterModel->findById($chapter_id);
|
|
||||||
if (!$chapter || $chapter['user_id'] != $user_id) {
|
|
||||||
$_SESSION['error'] = "Глава не найдена или у вас нет доступа";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
$book_id = $chapter['book_id'];
|
|
||||||
$is_edit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$book_id) {
|
|
||||||
$_SESSION['error'] = "Не указана книга";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
|
||||||
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем информацию о книге
|
|
||||||
$book = $bookModel->findById($book_id);
|
|
||||||
|
|
||||||
// Обработка формы
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect($is_edit ? "chapter_edit.php?id=$chapter_id" : "chapter_edit.php?book_id=$book_id");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка автосохранения
|
|
||||||
if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
|
||||||
// Автосохранение работает только для существующих глав
|
|
||||||
// Если это не редактирование, игнорируем автосохранение
|
|
||||||
if (!$is_edit) {
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['success' => false, 'message' => 'Автосохранение недоступно для новых глав']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = trim($_POST['title'] ?? '');
|
|
||||||
$content = trim($_POST['content'] ?? '');
|
|
||||||
$status = $_POST['status'] ?? 'draft';
|
|
||||||
|
|
||||||
if (empty($title)) {
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['success' => false, 'message' => 'Название главы обязательно']);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = [
|
|
||||||
'title' => $title,
|
|
||||||
'content' => $content,
|
|
||||||
'status' => $status,
|
|
||||||
'book_id' => $book_id
|
|
||||||
];
|
|
||||||
|
|
||||||
$success = $chapterModel->update($chapter_id, $data);
|
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
|
||||||
echo json_encode(['success' => $success]);
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обычная обработка формы (не автосохранение)
|
|
||||||
$title = trim($_POST['title'] ?? '');
|
|
||||||
$content = trim($_POST['content'] ?? '');
|
|
||||||
$status = $_POST['status'] ?? 'draft';
|
|
||||||
|
|
||||||
if (empty($title)) {
|
|
||||||
$_SESSION['error'] = "Название главы обязательно";
|
|
||||||
} else {
|
|
||||||
$data = [
|
|
||||||
'title' => $title,
|
|
||||||
'content' => $content,
|
|
||||||
'status' => $status,
|
|
||||||
'book_id' => $book_id
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($is_edit) {
|
|
||||||
$success = $chapterModel->update($chapter_id, $data);
|
|
||||||
$message = $success ? "Глава успешно обновлена" : "Ошибка при обновлении главы";
|
|
||||||
} else {
|
|
||||||
$success = $chapterModel->create($data);
|
|
||||||
$message = $success ? "Глава успешно создана" : "Ошибка при создании главы";
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
$new_chapter_id = $pdo->lastInsertId();
|
|
||||||
redirect("chapter_edit.php?id=$new_chapter_id");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
$_SESSION['success'] = $message;
|
|
||||||
redirect("book_edit.php?id=$book_id");
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = $message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$page_title = $is_edit ? "Редактирование главы" : "Создание новой главы";
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<div style="margin-top: 1rem;">
|
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
||||||
<?php
|
|
||||||
// Получаем все главы книги для навигации
|
|
||||||
$chapters = $chapterModel->findByBook($book_id);
|
|
||||||
$current_index = null;
|
|
||||||
|
|
||||||
// Находим индекс текущей главы
|
|
||||||
foreach ($chapters as $index => $chap) {
|
|
||||||
if ($chap['id'] == $chapter_id) {
|
|
||||||
$current_index = $index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($current_index !== null && $current_index > 0):
|
|
||||||
$prev_chapter = $chapters[$current_index - 1];
|
|
||||||
?>
|
|
||||||
<a href="chapter_edit.php?id=<?= $prev_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
|
|
||||||
⬅️ Предыдущая: <?= e(mb_strimwidth($prev_chapter['title'], 0, 30, '...')) ?>
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($current_index !== null && $current_index < count($chapters) - 1):
|
|
||||||
$next_chapter = $chapters[$current_index + 1];
|
|
||||||
?>
|
|
||||||
<a href="chapter_edit.php?id=<?= $next_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
|
|
||||||
Следующая: <?= e(mb_strimwidth($next_chapter['title'], 0, 30, '...')) ?> ➡️
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<h1><?= $is_edit ? "Редактирование главы" : "Создание новой главы" ?></h1>
|
|
||||||
<p><strong>Книга:</strong> <?= e($book['title']) ?></p>
|
|
||||||
|
|
||||||
<?php if (isset($_SESSION['error'])): ?>
|
|
||||||
<div class="alert alert-error">
|
|
||||||
<?= e($_SESSION['error']) ?>
|
|
||||||
<?php unset($_SESSION['error']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<form method="post" id="main-form">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
|
|
||||||
<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($chapter['title'] ?? $_POST['title'] ?? '') ?>"
|
|
||||||
placeholder="Введите название главы"
|
|
||||||
style="width: 100%; margin-bottom: 1.5rem;"
|
|
||||||
required>
|
|
||||||
|
|
||||||
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Статус
|
|
||||||
</label>
|
|
||||||
<select id="status" name="status" style="width: 100%; margin-bottom: 1.5rem;">
|
|
||||||
<option value="draft" <?= ($chapter['status'] ?? 'draft') == 'draft' ? 'selected' : '' ?>>Черновик</option>
|
|
||||||
<option value="published" <?= ($chapter['status'] ?? '') == 'published' ? 'selected' : '' ?>>Опубликована</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<label for="content" style="display: block; margin-bottom: 0; font-weight: bold;">
|
|
||||||
Содержание главы
|
|
||||||
<?php if (isset($book['editor_type'])): ?>
|
|
||||||
<small style="color: #666; font-weight: normal;">
|
|
||||||
(Режим: <?= $book['editor_type'] == 'markdown' ? 'Markdown' : 'HTML' ?>)
|
|
||||||
</small>
|
|
||||||
<?php endif; ?>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<?php if (($book['editor_type'] ?? 'markdown') === 'html'): ?>
|
|
||||||
<!-- HTML редактор (TinyMCE) -->
|
|
||||||
<textarea name="content" id="content" style="width: 100%; min-height: 500px;">
|
|
||||||
<?= e($chapter['content'] ?? $_POST['content'] ?? '') ?>
|
|
||||||
</textarea>
|
|
||||||
|
|
||||||
<!-- Подключаем TinyMCE -->
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.6/tinymce.min.js" referrerpolicy="origin"></script>
|
|
||||||
<script>
|
|
||||||
tinymce.init({
|
|
||||||
selector: '#content',
|
|
||||||
plugins: 'advlist autolink lists link image charmap preview anchor pagebreak searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media table emoticons',
|
|
||||||
toolbar: 'undo redo | blocks | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media | code preview fullscreen',
|
|
||||||
menubar: 'edit view insert format tools table',
|
|
||||||
height: 500,
|
|
||||||
language: 'ru',
|
|
||||||
branding: false,
|
|
||||||
promotion: false,
|
|
||||||
image_advtab: true,
|
|
||||||
|
|
||||||
// Важные настройки для сохранения структуры
|
|
||||||
forced_root_block: 'p', // Используем <p> вместо <div>
|
|
||||||
force_br_newlines: false, // Не использовать <br> вместо абзацев
|
|
||||||
force_p_newlines: true, // Всегда создавать новые абзацы при Enter
|
|
||||||
convert_newlines_to_brs: false, // Не конвертировать переносы в <br>
|
|
||||||
remove_trailing_brs: true, // Убирать лишние <br> в конце
|
|
||||||
|
|
||||||
// Настройки форматирования
|
|
||||||
formats: {
|
|
||||||
// Сохраняем семантическое форматирование
|
|
||||||
bold: { inline: 'strong' },
|
|
||||||
italic: { inline: 'em' },
|
|
||||||
underline: { inline: 'u', exact: true },
|
|
||||||
strikethrough: { inline: 'del' }
|
|
||||||
},
|
|
||||||
|
|
||||||
// Настройки контента
|
|
||||||
content_style: `
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 0;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
margin: 0 0 1em 0;
|
|
||||||
}
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
|
||||||
margin: 1em 0 0.5em 0;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
|
|
||||||
// Настройки для чистого HTML
|
|
||||||
valid_elements: '*[*]', // Разрешаем все элементы (можно ограничить при необходимости)
|
|
||||||
valid_children: '+body[p,div,h1,h2,h3,h4,h5,h6,blockquote,pre,ul,ol,li,table]',
|
|
||||||
|
|
||||||
// Автосохранение
|
|
||||||
setup: function (editor) {
|
|
||||||
editor.on('init', function () {
|
|
||||||
// Нормализуем контент при инициализации
|
|
||||||
var content = editor.getContent();
|
|
||||||
if (content && !content.match(/<p[^>]*>/) && content.trim().length > 0) {
|
|
||||||
// Если нет тегов абзацев, оборачиваем в <p>
|
|
||||||
editor.setContent('<p>' + content.replace(/\n/g, '</p><p>') + '</p>');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.on('keydown', function (e) {
|
|
||||||
clearTimeout(window.tinymceSaveTimeout);
|
|
||||||
window.tinymceSaveTimeout = setTimeout(function() {
|
|
||||||
if (typeof autoSave === 'function') {
|
|
||||||
autoSave();
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Обработка вставки текста
|
|
||||||
editor.on('paste', function (e) {
|
|
||||||
// Нормализуем вставленный текст
|
|
||||||
setTimeout(function() {
|
|
||||||
var content = editor.getContent();
|
|
||||||
// Убеждаемся, что контент имеет правильную структуру абзацев
|
|
||||||
editor.setContent(content);
|
|
||||||
}, 100);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<?php else: ?>
|
|
||||||
<!-- Markdown редактор (существующий) -->
|
|
||||||
<textarea name="content" id="content"
|
|
||||||
placeholder="Начните писать вашу главу здесь..."
|
|
||||||
rows="15"
|
|
||||||
style="width: 100%; font-family: monospace;"><?= e($chapter['content'] ?? $_POST['content'] ?? '') ?></textarea>
|
|
||||||
|
|
||||||
<script src="/assets/js/markdown-editor.js"></script>
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<script src="/assets/js/autosave.js"></script>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="button-group">
|
|
||||||
<button type="submit" form="main-form" class="contrast">
|
|
||||||
<?= $is_edit ? '💾 Сохранить изменения' : '📝 Создать главу' ?>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a href="book_edit.php?id=<?= $book_id ?>" role="button" class="secondary">
|
|
||||||
❌ Отмена
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<button type="button" class="green-btn" id="preview-button">
|
|
||||||
👁️ Предпросмотр
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Форма для предпросмотра -->
|
|
||||||
<form method="post" action="preview.php" target="_blank" id="preview-form" style="display: none;">
|
|
||||||
<input type="hidden" name="content" id="preview-content">
|
|
||||||
<input type="hidden" name="title" id="preview-title" value="<?= e($chapter['title'] ?? 'Новая глава') ?>">
|
|
||||||
<input type="hidden" name="editor_type" id="preview-editor-type" value="<?= e($book['editor_type'] ?? 'markdown') ?>">
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<div class="button-group">
|
|
||||||
<a href="chapter_edit.php?book_id=<?= $book_id ?>" role="button">
|
|
||||||
➕ Новая глава
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<form method="post" action="chapter_delete.php" style="flex: 1;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');">
|
|
||||||
<input type="hidden" name="chapter_id" value="<?= $chapter_id ?>">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
<button type="submit" class="secondary delete-btn">
|
|
||||||
🗑️ Удалить
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<div style="margin-top: 3rem;">
|
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
||||||
<?php
|
|
||||||
// Получаем все главы книги для навигации
|
|
||||||
$chapters = $chapterModel->findByBook($book_id);
|
|
||||||
$current_index = null;
|
|
||||||
|
|
||||||
// Находим индекс текущей главы
|
|
||||||
foreach ($chapters as $index => $chap) {
|
|
||||||
if ($chap['id'] == $chapter_id) {
|
|
||||||
$current_index = $index;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($current_index !== null && $current_index > 0):
|
|
||||||
$prev_chapter = $chapters[$current_index - 1];
|
|
||||||
?>
|
|
||||||
<a href="chapter_edit.php?id=<?= $prev_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
|
|
||||||
⬅️ Предыдущая: <?= e(mb_strimwidth($prev_chapter['title'], 0, 30, '...')) ?>
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($current_index !== null && $current_index < count($chapters) - 1):
|
|
||||||
$next_chapter = $chapters[$current_index + 1];
|
|
||||||
?>
|
|
||||||
<a href="chapter_edit.php?id=<?= $next_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
|
|
||||||
Следующая: <?= e(mb_strimwidth($next_chapter['title'], 0, 30, '...')) ?> ➡️
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Обработчик для кнопки предпросмотра
|
|
||||||
document.getElementById('preview-button').addEventListener('click', function() {
|
|
||||||
// Обновляем содержимое для предпросмотра
|
|
||||||
document.getElementById('preview-content').value = document.getElementById('content').value;
|
|
||||||
document.getElementById('preview-title').value = document.getElementById('title').value || 'Новая глава';
|
|
||||||
|
|
||||||
// Отправляем форму предпросмотра
|
|
||||||
document.getElementById('preview-form').submit();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -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' => 'Добавление пользователя'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
<?php
|
||||||
|
// controllers/AuthController.php
|
||||||
|
require_once 'controllers/BaseController.php';
|
||||||
|
require_once 'models/User.php';
|
||||||
|
|
||||||
|
class AuthController extends BaseController {
|
||||||
|
|
||||||
|
public function login() {
|
||||||
|
// Если пользователь уже авторизован, перенаправляем на dashboard
|
||||||
|
if (is_logged_in()) {
|
||||||
|
$this->redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = "Ошибка безопасности";
|
||||||
|
} else {
|
||||||
|
$username = trim($_POST['username'] ?? '');
|
||||||
|
$password = $_POST['password'] ?? '';
|
||||||
|
|
||||||
|
if (empty($username) || empty($password)) {
|
||||||
|
$error = 'Пожалуйста, введите имя пользователя и пароль';
|
||||||
|
} else {
|
||||||
|
$userModel = new User($this->pdo);
|
||||||
|
$user = $userModel->findByUsername($username);
|
||||||
|
|
||||||
|
if ($user && $userModel->verifyPassword($password, $user['password_hash'])) {
|
||||||
|
if (!$user['is_active']) {
|
||||||
|
$error = 'Ваш аккаунт деактивирован или ожидает активации администратором.';
|
||||||
|
} else {
|
||||||
|
// Успешный вход
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['user_id'] = $user['id'];
|
||||||
|
$_SESSION['username'] = $user['username'];
|
||||||
|
$_SESSION['display_name'] = $user['display_name'] ?: $user['username'];
|
||||||
|
$_SESSION['avatar'] = $user['avatar'] ?? null;
|
||||||
|
|
||||||
|
// Обновляем время последнего входа
|
||||||
|
$userModel->updateLastLogin($user['id']);
|
||||||
|
|
||||||
|
$_SESSION['success'] = 'Добро пожаловать, ' . e($user['display_name'] ?: $user['username']) . '!';
|
||||||
|
$this->redirect('/dashboard');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$error = 'Неверное имя пользователя или пароль';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('auth/login', [
|
||||||
|
'error' => $error,
|
||||||
|
'page_title' => 'Вход в систему'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout() {
|
||||||
|
// Очищаем все данные сессии
|
||||||
|
$_SESSION = [];
|
||||||
|
|
||||||
|
if (ini_get("session.use_cookies")) {
|
||||||
|
$params = session_get_cookie_params();
|
||||||
|
setcookie(session_name(), '', time() - 42000,
|
||||||
|
$params["path"], $params["domain"],
|
||||||
|
$params["secure"], $params["httponly"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
session_destroy();
|
||||||
|
$this->redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register() {
|
||||||
|
// Если пользователь уже авторизован, перенаправляем на dashboard
|
||||||
|
if (is_logged_in()) {
|
||||||
|
$this->redirect('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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'] ?? '');
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (empty($username) || empty($password)) {
|
||||||
|
$error = 'Имя пользователя и пароль обязательны';
|
||||||
|
} elseif ($password !== $password_confirm) {
|
||||||
|
$error = 'Пароли не совпадают';
|
||||||
|
} elseif (strlen($password) < 6) {
|
||||||
|
$error = 'Пароль должен быть не менее 6 символов';
|
||||||
|
} else {
|
||||||
|
$userModel = new User($this->pdo);
|
||||||
|
|
||||||
|
// Проверяем, не занят ли username
|
||||||
|
if ($userModel->findByUsername($username)) {
|
||||||
|
$error = 'Имя пользователя уже занято';
|
||||||
|
} elseif ($email && $userModel->findByEmail($email)) {
|
||||||
|
$error = 'Email уже используется';
|
||||||
|
} else {
|
||||||
|
$data = [
|
||||||
|
'username' => $username,
|
||||||
|
'password' => $password,
|
||||||
|
'email' => $email ?: null,
|
||||||
|
'display_name' => $display_name ?: $username,
|
||||||
|
'is_active' => 1 // Авто-активация для простоты
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($userModel->create($data)) {
|
||||||
|
$success = 'Регистрация успешна! Теперь вы можете войти в систему.';
|
||||||
|
// Можно автоматически войти после регистрации
|
||||||
|
// $this->redirect('/login');
|
||||||
|
} else {
|
||||||
|
$error = 'Ошибка при создании аккаунта';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('auth/register', [
|
||||||
|
'error' => $error,
|
||||||
|
'success' => $success,
|
||||||
|
'page_title' => 'Регистрация'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?php
|
||||||
|
// controllers/BaseController.php
|
||||||
|
class BaseController {
|
||||||
|
protected $pdo;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
global $pdo;
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render($view, $data = []) {
|
||||||
|
extract($data);
|
||||||
|
include "views/$view.php";
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function redirect($url) {
|
||||||
|
header("Location: " . SITE_URL . $url);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function requireLogin() {
|
||||||
|
if (!is_logged_in()) {
|
||||||
|
$this->redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,319 @@
|
||||||
|
<?php
|
||||||
|
// controllers/BookController.php
|
||||||
|
require_once 'controllers/BaseController.php';
|
||||||
|
require_once 'models/Book.php';
|
||||||
|
require_once 'models/Chapter.php';
|
||||||
|
require_once 'models/Series.php';
|
||||||
|
|
||||||
|
class BookController extends BaseController {
|
||||||
|
public function index() {
|
||||||
|
$this->requireLogin();
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$books = $bookModel->findByUser($user_id);
|
||||||
|
$this->render('books/index', [
|
||||||
|
'books' => $books,
|
||||||
|
'page_title' => 'Мои книги'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create() {
|
||||||
|
$this->requireLogin();
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
$series = $seriesModel->findByUser($_SESSION['user_id']);
|
||||||
|
$error = '';
|
||||||
|
$cover_error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$_SESSION['error'] = "Ошибка безопасности";
|
||||||
|
$this->redirect('/books/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
if (empty($title)) {
|
||||||
|
$_SESSION['error'] = "Название книги обязательно";
|
||||||
|
$this->redirect('/books/create');
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$data = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => trim($_POST['description'] ?? ''),
|
||||||
|
'genre' => trim($_POST['genre'] ?? ''),
|
||||||
|
'user_id' => $_SESSION['user_id'],
|
||||||
|
'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)) {
|
||||||
|
$new_book_id = $this->pdo->lastInsertId();
|
||||||
|
|
||||||
|
// Обработка загрузки обложки
|
||||||
|
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,
|
||||||
|
'error' => $error,
|
||||||
|
'cover_error' => $cover_error,
|
||||||
|
'page_title' => 'Создание новой книги'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$book = $bookModel->findById($id);
|
||||||
|
|
||||||
|
if (!$book || $book['user_id'] != $_SESSION['user_id']) {
|
||||||
|
$_SESSION['error'] = "Книга не найдена или у вас нет доступа";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
$series = $seriesModel->findByUser($_SESSION['user_id']);
|
||||||
|
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
$cover_error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = "Ошибка безопасности";
|
||||||
|
} else {
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
if (empty($title)) {
|
||||||
|
$error = "Название книги обязательно";
|
||||||
|
} else {
|
||||||
|
$data = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => trim($_POST['description'] ?? ''),
|
||||||
|
'genre' => trim($_POST['genre'] ?? ''),
|
||||||
|
'user_id' => $_SESSION['user_id'],
|
||||||
|
'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 (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
|
||||||
|
$cover_result = handleCoverUpload($_FILES['cover_image'], $id);
|
||||||
|
if ($cover_result['success']) {
|
||||||
|
$bookModel->updateCover($id, $cover_result['filename']);
|
||||||
|
} else {
|
||||||
|
$cover_error = $cover_result['error'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление обложки
|
||||||
|
if (isset($_POST['delete_cover']) && $_POST['delete_cover'] == '1') {
|
||||||
|
$bookModel->deleteCover($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление книги
|
||||||
|
$success = $bookModel->update($id, $data);
|
||||||
|
|
||||||
|
if ($success) {
|
||||||
|
$success_message = "Книга успешно обновлена";
|
||||||
|
$_SESSION['success'] = $success_message;
|
||||||
|
$this->redirect("/books/{$id}/edit");
|
||||||
|
} else {
|
||||||
|
$error = "Ошибка при обновлении книги";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем статистику по главам для отображения в шаблоне
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
$chapters = $chapterModel->findByBook($id);
|
||||||
|
|
||||||
|
$this->render('books/edit', [
|
||||||
|
'book' => $book,
|
||||||
|
'series' => $series,
|
||||||
|
'chapters' => $chapters,
|
||||||
|
'error' => $error,
|
||||||
|
'cover_error' => $cover_error,
|
||||||
|
'page_title' => 'Редактирование книги'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$_SESSION['error'] = "Неверный метод запроса";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$_SESSION['error'] = "Ошибка безопасности";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
if (!$bookModel->userOwnsBook($id, $user_id)) {
|
||||||
|
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
if ($bookModel->delete($id, $user_id)) {
|
||||||
|
$_SESSION['success'] = "Книга успешно удалена";
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = "Ошибка при удалении книги";
|
||||||
|
}
|
||||||
|
$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);
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
$book = $bookModel->findByShareToken($share_token);
|
||||||
|
if (!$book) {
|
||||||
|
http_response_code(404);
|
||||||
|
$this->render('errors/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
||||||
|
|
||||||
|
// Получаем информацию об авторе
|
||||||
|
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$book['user_id']]);
|
||||||
|
$author = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$this->render('books/view_public', [
|
||||||
|
'book' => $book,
|
||||||
|
'chapters' => $chapters,
|
||||||
|
'author' => $author,
|
||||||
|
'page_title' => $book['title']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewAll($id) {
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
$book = $bookModel->findById($id);
|
||||||
|
if (!$book) {
|
||||||
|
http_response_code(404);
|
||||||
|
$this->render('errors/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$chapters = $chapterModel->findByBook($book['id']);
|
||||||
|
|
||||||
|
// Получаем информацию об авторе
|
||||||
|
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$book['user_id']]);
|
||||||
|
$author = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$this->render('books/view_public', [
|
||||||
|
'book' => $book,
|
||||||
|
'chapters' => $chapters,
|
||||||
|
'author' => $author,
|
||||||
|
'page_title' => $book['title']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function regenerateToken($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');
|
||||||
|
}
|
||||||
|
$new_token = $bookModel->generateNewShareToken($id);
|
||||||
|
if ($new_token) {
|
||||||
|
$_SESSION['success'] = "Ссылка успешно обновлена";
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = "Ошибка при обновлении ссылки";
|
||||||
|
}
|
||||||
|
$this->redirect("/books/{$id}/edit");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,246 @@
|
||||||
|
<?php
|
||||||
|
// controllers/ChapterController.php
|
||||||
|
require_once 'controllers/BaseController.php';
|
||||||
|
require_once 'models/Chapter.php';
|
||||||
|
require_once 'models/Book.php';
|
||||||
|
|
||||||
|
class ChapterController extends BaseController {
|
||||||
|
|
||||||
|
public function index($book_id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
|
||||||
|
// Проверяем права доступа к книге
|
||||||
|
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
||||||
|
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию о книге и главах
|
||||||
|
$book = $bookModel->findById($book_id);
|
||||||
|
$chapters = $chapterModel->findByBook($book_id);
|
||||||
|
|
||||||
|
$this->render('chapters/index', [
|
||||||
|
'book' => $book,
|
||||||
|
'chapters' => $chapters,
|
||||||
|
'page_title' => "Главы книги: " . e($book['title'])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create($book_id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
|
||||||
|
// Проверяем права доступа к книге
|
||||||
|
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
||||||
|
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
$book = $bookModel->findById($book_id);
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = "Ошибка безопасности";
|
||||||
|
} else {
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
$content = trim($_POST['content']) ?? '';
|
||||||
|
$content = $this->cleanChapterContent($content);
|
||||||
|
$status = $_POST['status'] ?? 'draft';
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$error = "Название главы обязательно";
|
||||||
|
} else {
|
||||||
|
$data = [
|
||||||
|
'book_id' => $book_id,
|
||||||
|
'title' => $title,
|
||||||
|
'content' => $content,
|
||||||
|
'status' => $status
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($chapterModel->create($data)) {
|
||||||
|
$_SESSION['success'] = "Глава успешно создана";
|
||||||
|
$this->redirect("/books/{$book_id}/chapters");
|
||||||
|
} else {
|
||||||
|
$error = "Ошибка при создании главы";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('chapters/create', [
|
||||||
|
'book' => $book,
|
||||||
|
'error' => $error,
|
||||||
|
'page_title' => "Новая глава для: " . e($book['title'])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
|
||||||
|
// Получаем главу и книгу
|
||||||
|
$chapter = $chapterModel->findById($id);
|
||||||
|
if (!$chapter) {
|
||||||
|
if (!empty($_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']);
|
||||||
|
|
||||||
|
// Проверяем права доступа
|
||||||
|
if (!$chapterModel->userOwnsChapter($id, $user_id)) {
|
||||||
|
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Доступ запрещен']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$_SESSION['error'] = "У вас нет доступа к этой главе";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка POST запроса
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
$content = $this->cleanChapterContent($_POST['content'] ?? '');
|
||||||
|
$status = $_POST['status'] ?? 'draft';
|
||||||
|
|
||||||
|
// Проверяем CSRF
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ошибка безопасности']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$error = "Ошибка безопасности";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$error = "Название главы обязательно";
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = ['title' => $title, 'content' => $content, 'status' => $status];
|
||||||
|
|
||||||
|
// Если это автосейв — возвращаем JSON сразу
|
||||||
|
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
||||||
|
if (empty($error)) {
|
||||||
|
$success = $chapterModel->update($id, $data);
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => $success, 'error' => $success ? null : 'Ошибка при сохранении']);
|
||||||
|
} else {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
echo json_encode(['success' => false, 'error' => $error]);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обычное сохранение формы
|
||||||
|
if (empty($error)) {
|
||||||
|
if ($chapterModel->update($id, $data)) {
|
||||||
|
$_SESSION['success'] = "Глава успешно обновлена";
|
||||||
|
$this->redirect("/books/{$chapter['book_id']}/chapters");
|
||||||
|
} else {
|
||||||
|
$error = "Ошибка при обновлении главы";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рендер страницы
|
||||||
|
$this->render('chapters/edit', [
|
||||||
|
'chapter' => $chapter,
|
||||||
|
'book' => $book,
|
||||||
|
'error' => $error ?? '',
|
||||||
|
'page_title' => "Редактирование главы: " . e($chapter['title'])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$_SESSION['error'] = "Неверный метод запроса";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$_SESSION['error'] = "Ошибка безопасности";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
|
||||||
|
// Проверяем права доступа
|
||||||
|
if (!$chapterModel->userOwnsChapter($id, $user_id)) {
|
||||||
|
$_SESSION['error'] = "У вас нет доступа к этой главе";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
$chapter = $chapterModel->findById($id);
|
||||||
|
$book_id = $chapter['book_id'];
|
||||||
|
|
||||||
|
// Удаляем главу
|
||||||
|
if ($chapterModel->delete($id)) {
|
||||||
|
$_SESSION['success'] = "Глава успешно удалена";
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = "Ошибка при удалении главы";
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect("/books/{$book_id}/chapters");
|
||||||
|
}
|
||||||
|
public function preview() {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$content = $_POST['content'] ?? '';
|
||||||
|
$content = $this->cleanChapterContent($content);
|
||||||
|
$title = $_POST['title'] ?? 'Предпросмотр';
|
||||||
|
|
||||||
|
$this->render('chapters/preview', [
|
||||||
|
'content' => $content,
|
||||||
|
'title' => $title,
|
||||||
|
'page_title' => "Предпросмотр: " . e($title)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавьте эту функцию в начало файла
|
||||||
|
function cleanChapterContent($content) {
|
||||||
|
// Удаляем лишние пробелы в начале и конце
|
||||||
|
$content = trim($content);
|
||||||
|
|
||||||
|
// Удаляем пустые абзацы и параграфы, содержащие только пробелы
|
||||||
|
$content = preg_replace('/<p[^>]*>\s*(?:<br\s*\/?>| )?\s*<\/p>/i', '', $content);
|
||||||
|
$content = preg_replace('/<p[^>]*>\s*<\/p>/i', '', $content);
|
||||||
|
|
||||||
|
// Удаляем последовательные пустые абзацы
|
||||||
|
$content = preg_replace('/(<\/p>\s*<p[^>]*>)+/', '</p><p>', $content);
|
||||||
|
|
||||||
|
// Удаляем лишние пробелы в начале и конце каждого параграфа
|
||||||
|
$content = preg_replace('/(<p[^>]*>)\s+/', '$1', $content);
|
||||||
|
$content = preg_replace('/\s+<\/p>/', '</p>', $content);
|
||||||
|
|
||||||
|
// Удаляем лишние переносы строк
|
||||||
|
$content = preg_replace('/\n{3,}/', "\n\n", $content);
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
// controllers/DashboardController.php
|
||||||
|
require_once 'controllers/BaseController.php';
|
||||||
|
require_once 'models/Book.php';
|
||||||
|
require_once 'models/Chapter.php';
|
||||||
|
require_once 'models/Series.php';
|
||||||
|
|
||||||
|
class DashboardController extends BaseController {
|
||||||
|
|
||||||
|
public function index() {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
|
||||||
|
// Получаем статистику
|
||||||
|
$books = $bookModel->findByUser($user_id);
|
||||||
|
$published_books = $bookModel->findByUser($user_id, true);
|
||||||
|
|
||||||
|
$total_books = count($books);
|
||||||
|
$published_books_count = count($published_books);
|
||||||
|
|
||||||
|
// Общее количество слов и глав
|
||||||
|
$total_words = 0;
|
||||||
|
$total_chapters = 0;
|
||||||
|
foreach ($books as $book) {
|
||||||
|
$stats = $bookModel->getBookStats($book['id']);
|
||||||
|
$total_words += $stats['total_words'] ?? 0;
|
||||||
|
$total_chapters += $stats['chapter_count'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Последние книги
|
||||||
|
$recent_books = array_slice($books, 0, 5);
|
||||||
|
|
||||||
|
// Серии
|
||||||
|
$series = $seriesModel->findByUser($user_id);
|
||||||
|
|
||||||
|
$this->render('dashboard/index', [
|
||||||
|
'total_books' => $total_books,
|
||||||
|
'published_books_count' => $published_books_count,
|
||||||
|
'total_words' => $total_words,
|
||||||
|
'total_chapters' => $total_chapters,
|
||||||
|
'recent_books' => $recent_books,
|
||||||
|
'series' => $series,
|
||||||
|
'page_title' => 'Панель управления'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,679 @@
|
||||||
|
<?php
|
||||||
|
// controllers/ExportController.php
|
||||||
|
require_once 'controllers/BaseController.php';
|
||||||
|
require_once 'models/Book.php';
|
||||||
|
require_once 'models/Chapter.php';
|
||||||
|
require_once 'vendor/autoload.php';
|
||||||
|
|
||||||
|
use PhpOffice\PhpWord\PhpWord;
|
||||||
|
use PhpOffice\PhpWord\IOFactory;
|
||||||
|
use TCPDF;
|
||||||
|
|
||||||
|
class ExportController extends BaseController {
|
||||||
|
|
||||||
|
public function export($book_id, $format = 'pdf') {
|
||||||
|
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
|
||||||
|
$book = $bookModel->findById($book_id);
|
||||||
|
if (!$book || $book['user_id'] != $user_id) {
|
||||||
|
$_SESSION['error'] = "Доступ запрещен";
|
||||||
|
$this->redirect('/books');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для автора - все главы
|
||||||
|
$chapters = $chapterModel->findByBook($book_id);
|
||||||
|
|
||||||
|
// Получаем информацию об авторе
|
||||||
|
$author_name = $this->getAuthorName($book['user_id']);
|
||||||
|
|
||||||
|
$this->handleExport($book, $chapters, false, $author_name, $format);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exportShared($share_token, $format = 'pdf') {
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$chapterModel = new Chapter($this->pdo);
|
||||||
|
|
||||||
|
$book = $bookModel->findByShareToken($share_token);
|
||||||
|
if (!$book) {
|
||||||
|
$_SESSION['error'] = "Книга не найдена";
|
||||||
|
$this->redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Для публичного доступа - только опубликованные главы
|
||||||
|
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
||||||
|
|
||||||
|
// Получаем информацию об авторе
|
||||||
|
$author_name = $this->getAuthorName($book['user_id']);
|
||||||
|
|
||||||
|
$this->handleExport($book, $chapters, true, $author_name, $format);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getAuthorName($user_id) {
|
||||||
|
$stmt = $this->pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$user_id]);
|
||||||
|
$author_info = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if ($author_info && $author_info['display_name'] != "") {
|
||||||
|
return $author_info['display_name'];
|
||||||
|
} elseif ($author_info) {
|
||||||
|
return $author_info['username'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Неизвестный автор";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handleExport($book, $chapters, $is_public, $author_name, $format) {
|
||||||
|
|
||||||
|
|
||||||
|
switch ($format) {
|
||||||
|
case 'pdf':
|
||||||
|
$this->exportPDF($book, $chapters, $is_public, $author_name);
|
||||||
|
break;
|
||||||
|
case 'docx':
|
||||||
|
$this->exportDOCX($book, $chapters, $is_public, $author_name);
|
||||||
|
break;
|
||||||
|
case 'html':
|
||||||
|
$this->exportHTML($book, $chapters, $is_public, $author_name);
|
||||||
|
break;
|
||||||
|
case 'txt':
|
||||||
|
$this->exportTXT($book, $chapters, $is_public, $author_name);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$_SESSION['error'] = "Неверный формат экспорта";
|
||||||
|
$redirect_url = $is_public ?
|
||||||
|
"/book/{$book['share_token']}" :
|
||||||
|
"/books/{$book['id']}/edit";
|
||||||
|
$this->redirect($redirect_url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportPDF($book, $chapters, $is_public, $author_name) {
|
||||||
|
|
||||||
|
|
||||||
|
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
|
||||||
|
|
||||||
|
// Устанавливаем метаданные документа
|
||||||
|
$pdf->SetCreator(APP_NAME);
|
||||||
|
$pdf->SetAuthor($author_name);
|
||||||
|
$pdf->SetTitle($book['title']);
|
||||||
|
$pdf->SetSubject($book['genre'] ?? '');
|
||||||
|
|
||||||
|
// Устанавливаем margins
|
||||||
|
$pdf->SetMargins(15, 25, 15);
|
||||||
|
$pdf->SetHeaderMargin(10);
|
||||||
|
$pdf->SetFooterMargin(10);
|
||||||
|
|
||||||
|
// Устанавливаем авто разрыв страниц
|
||||||
|
$pdf->SetAutoPageBreak(TRUE, 15);
|
||||||
|
|
||||||
|
// Добавляем страницу
|
||||||
|
$pdf->AddPage();
|
||||||
|
|
||||||
|
// Устанавливаем шрифт с поддержкой кириллицы
|
||||||
|
$pdf->SetFont('dejavusans', '', 12);
|
||||||
|
|
||||||
|
// Заголовок книги
|
||||||
|
$pdf->SetFont('dejavusans', 'B', 18);
|
||||||
|
$pdf->Cell(0, 10, $book['title'], 0, 1, 'C');
|
||||||
|
$pdf->Ln(2);
|
||||||
|
|
||||||
|
// Автор
|
||||||
|
$pdf->SetFont('dejavusans', 'I', 14);
|
||||||
|
$pdf->Cell(0, 10, $author_name, 0, 1, 'C');
|
||||||
|
$pdf->Ln(5);
|
||||||
|
|
||||||
|
// Обложка книги
|
||||||
|
if (!empty($book['cover_image'])) {
|
||||||
|
$cover_path = COVERS_PATH . $book['cover_image'];
|
||||||
|
if (file_exists($cover_path)) {
|
||||||
|
list($width, $height) = getimagesize($cover_path);
|
||||||
|
$max_width = 80;
|
||||||
|
$ratio = $width / $height;
|
||||||
|
$new_height = $max_width / $ratio;
|
||||||
|
|
||||||
|
$x = (210 - $max_width) / 2;
|
||||||
|
$pdf->Image($cover_path, $x, $pdf->GetY(), $max_width, $new_height, '', '', 'N', false, 300, '', false, false, 0, false, false, false);
|
||||||
|
$pdf->Ln($new_height + 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Жанр
|
||||||
|
if (!empty($book['genre'])) {
|
||||||
|
$pdf->SetFont('dejavusans', 'I', 12);
|
||||||
|
$pdf->Cell(0, 10, 'Жанр: ' . $book['genre'], 0, 1, 'C');
|
||||||
|
$pdf->Ln(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Описание
|
||||||
|
if (!empty($book['description'])) {
|
||||||
|
$pdf->SetFont('dejavusans', '', 11);
|
||||||
|
$pdf->MultiCell(0, 6, $book['description'], 0, 'J');
|
||||||
|
$pdf->Ln(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерактивное оглавление
|
||||||
|
$chapterLinks = [];
|
||||||
|
if (!empty($chapters)) {
|
||||||
|
$pdf->SetFont('dejavusans', 'B', 14);
|
||||||
|
$pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C');
|
||||||
|
$pdf->Ln(5);
|
||||||
|
|
||||||
|
$toc_page = $pdf->getPage();
|
||||||
|
|
||||||
|
$pdf->SetFont('dejavusans', '', 11);
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
|
$chapter_number = $index + 1;
|
||||||
|
$link = $pdf->AddLink();
|
||||||
|
$chapterLinks[$chapter['id']] = $link; // Сохраняем ссылку для этой главы
|
||||||
|
$pdf->Cell(0, 6, "{$chapter_number}. {$chapter['title']}", 0, 1, 'L', false, $link);
|
||||||
|
}
|
||||||
|
$pdf->Ln(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
$pdf->Line(15, $pdf->GetY(), 195, $pdf->GetY());
|
||||||
|
$pdf->Ln(10);
|
||||||
|
|
||||||
|
// Главы с закладками и правильными ссылками
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
|
// Добавляем новую страницу для каждой главы
|
||||||
|
$pdf->AddPage();
|
||||||
|
|
||||||
|
// УСТАНАВЛИВАЕМ ЯКОРЬ ДЛЯ ССЫЛКИ
|
||||||
|
if (isset($chapterLinks[$chapter['id']])) {
|
||||||
|
$pdf->SetLink($chapterLinks[$chapter['id']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Устанавливаем закладку для этой главы
|
||||||
|
$pdf->Bookmark($chapter['title'], 0, 0, '', 'B', array(0, 0, 0));
|
||||||
|
|
||||||
|
// Название главы
|
||||||
|
$pdf->SetFont('dejavusans', 'B', 14);
|
||||||
|
$pdf->Cell(0, 8, $chapter['title'], 0, 1);
|
||||||
|
$pdf->Ln(2);
|
||||||
|
|
||||||
|
// Контент главы
|
||||||
|
$pdf->SetFont('dejavusans', '', 11);
|
||||||
|
|
||||||
|
$htmlContent = $chapter['content'];
|
||||||
|
|
||||||
|
$pdf->writeHTML($htmlContent, true, false, true, false, '');
|
||||||
|
|
||||||
|
$pdf->Ln(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Футер с информацией
|
||||||
|
$pdf->SetY(-25);
|
||||||
|
$pdf->SetFont('dejavusans', 'I', 8);
|
||||||
|
$pdf->Cell(0, 6, 'Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), 0, 1, 'C');
|
||||||
|
$pdf->Cell(0, 6, 'Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), 0, 1, 'C');
|
||||||
|
|
||||||
|
// Отправляем файл
|
||||||
|
$filename = cleanFilename($book['title']) . '.pdf';
|
||||||
|
$pdf->Output($filename, 'D');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportDOCX($book, $chapters, $is_public, $author_name) {
|
||||||
|
|
||||||
|
$phpWord = new PhpWord();
|
||||||
|
|
||||||
|
// Стили документа
|
||||||
|
$phpWord->setDefaultFontName('Times New Roman');
|
||||||
|
$phpWord->setDefaultFontSize(12);
|
||||||
|
|
||||||
|
// Секция документа
|
||||||
|
$section = $phpWord->addSection();
|
||||||
|
|
||||||
|
// Заголовок книги
|
||||||
|
$section->addText($book['title'], ['bold' => true, 'size' => 16], ['alignment' => 'center']);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
|
||||||
|
// Автор
|
||||||
|
$section->addText($author_name, ['italic' => true, 'size' => 14], ['alignment' => 'center']);
|
||||||
|
$section->addTextBreak(2);
|
||||||
|
|
||||||
|
// Обложка книги
|
||||||
|
if (!empty($book['cover_image'])) {
|
||||||
|
$cover_path = COVERS_PATH . $book['cover_image'];
|
||||||
|
if (file_exists($cover_path)) {
|
||||||
|
$section->addImage($cover_path, [
|
||||||
|
'width' => 150,
|
||||||
|
'height' => 225,
|
||||||
|
'alignment' => 'center'
|
||||||
|
]);
|
||||||
|
$section->addTextBreak(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Жанр
|
||||||
|
if (!empty($book['genre'])) {
|
||||||
|
$section->addText('Жанр: ' . $book['genre'], ['italic' => true], ['alignment' => 'center']);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Описание
|
||||||
|
if (!empty($book['description'])) {
|
||||||
|
|
||||||
|
$descriptionParagraphs = $this->htmlToParagraphs($book['description']);
|
||||||
|
|
||||||
|
foreach ($descriptionParagraphs as $paragraph) {
|
||||||
|
if (!empty(trim($paragraph))) {
|
||||||
|
$section->addText($paragraph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$section->addTextBreak(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерактивное оглавление
|
||||||
|
if (!empty($chapters)) {
|
||||||
|
$section->addText('Оглавление', ['bold' => true, 'size' => 14], ['alignment' => 'center']);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
|
$chapter_number = $index + 1;
|
||||||
|
// Создаем гиперссылку на заголовок главы
|
||||||
|
$section->addLink("chapter_{$chapter['id']}", "{$chapter_number}. {$chapter['title']}", null, null, true);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
}
|
||||||
|
$section->addTextBreak(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Разделитель
|
||||||
|
$section->addPageBreak();
|
||||||
|
|
||||||
|
// Главы с закладками
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
|
// Добавляем закладку для главы
|
||||||
|
$section->addBookmark("chapter_{$chapter['id']}");
|
||||||
|
|
||||||
|
// Заголовок главы
|
||||||
|
$section->addText($chapter['title'], ['bold' => true, 'size' => 14]);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
|
||||||
|
// Получаем очищенный текст и разбиваем на абзацы
|
||||||
|
|
||||||
|
$cleanContent = strip_tags($chapter['content']);
|
||||||
|
$paragraphs = $this->htmlToParagraphs($chapter['content']);
|
||||||
|
|
||||||
|
|
||||||
|
// Добавляем каждый абзац
|
||||||
|
foreach ($paragraphs as $paragraph) {
|
||||||
|
if (!empty(trim($paragraph))) {
|
||||||
|
$section->addText($paragraph);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем разрыв страницы между главами (кроме последней)
|
||||||
|
if ($index < count($chapters) - 1) {
|
||||||
|
$section->addPageBreak();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Футер
|
||||||
|
$section->addTextBreak(2);
|
||||||
|
$section->addText('Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), ['italic' => true, 'size' => 9]);
|
||||||
|
$section->addText('Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), ['italic' => true, 'size' => 9]);
|
||||||
|
|
||||||
|
// Сохраняем и отправляем
|
||||||
|
$filename = cleanFilename($book['title']) . '.docx';
|
||||||
|
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
|
||||||
|
$objWriter = IOFactory::createWriter($phpWord, 'Word2007');
|
||||||
|
$objWriter->save('php://output');
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportHTML($book, $chapters, $is_public, $author_name) {
|
||||||
|
|
||||||
|
$html = '<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>' . htmlspecialchars($book['title']) . '</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Times New Roman", serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 40px;
|
||||||
|
max-width: 900px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.book-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.book-author {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.book-cover {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.book-cover img {
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.book-genre {
|
||||||
|
text-align: center;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.book-description {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
.table-of-contents {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin: 20px 0;
|
||||||
|
columns: 1;
|
||||||
|
column-gap: 2rem;
|
||||||
|
}
|
||||||
|
.table-of-contents h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: center;
|
||||||
|
column-span: all;
|
||||||
|
}
|
||||||
|
.table-of-contents ul {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.table-of-contents li {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
padding: 5px 0;
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
.table-of-contents a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.table-of-contents a:hover {
|
||||||
|
color: #007bff;
|
||||||
|
}
|
||||||
|
.chapter-title {
|
||||||
|
border-bottom: 2px solid #007bff;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
margin-top: 30px;
|
||||||
|
font-size: 20px;
|
||||||
|
scroll-margin-top: 2rem;
|
||||||
|
}
|
||||||
|
.chapter-content {
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
/* Отображение абзацев */
|
||||||
|
.chapter-content p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
.dialogue {
|
||||||
|
margin-left: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #2c5aa0;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
/* Остальные стили */
|
||||||
|
.chapter-content h1, .chapter-content h2, .chapter-content h3 {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
.chapter-content blockquote {
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
padding-left: 15px;
|
||||||
|
margin-left: 0;
|
||||||
|
color: #555;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.chapter-content code {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.chapter-content pre {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.chapter-content ul, .chapter-content ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
.chapter-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.chapter-content th, .chapter-content td {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.chapter-content th {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.table-of-contents {
|
||||||
|
columns: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="book-title">' . htmlspecialchars($book['title']) . '</div>
|
||||||
|
<div class="book-author">' . htmlspecialchars($author_name) . '</div>';
|
||||||
|
|
||||||
|
if (!empty($book['genre'])) {
|
||||||
|
$html .= '<div class="book-genre">Жанр: ' . htmlspecialchars($book['genre']) . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обложка книги
|
||||||
|
if (!empty($book['cover_image'])) {
|
||||||
|
$cover_url = COVERS_URL . $book['cover_image'];
|
||||||
|
$html .= '<div class="book-cover">';
|
||||||
|
$html .= '<img src="' . $cover_url . '" alt="' . htmlspecialchars($book['title']) . '">';
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($book['description'])) {
|
||||||
|
$html .= '<div class="book-description">';
|
||||||
|
$html .= $book['description'];
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерактивное оглавление
|
||||||
|
if (!empty($chapters)) {
|
||||||
|
$html .= '<div class="table-of-contents">';
|
||||||
|
$html .= '<h3>Оглавление</h3>';
|
||||||
|
$html .= '<ul>';
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
|
$chapter_number = $index + 1;
|
||||||
|
$html .= '<li><a href="#chapter-' . $chapter['id'] . '">' . $chapter_number . '. ' . htmlspecialchars($chapter['title']) . '</a></li>';
|
||||||
|
}
|
||||||
|
$html .= '</ul>';
|
||||||
|
$html .= '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<hr style="margin: 30px 0;">';
|
||||||
|
|
||||||
|
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>';
|
||||||
|
$html .= '<div class="chapter-content">' . $chapter['content']. '</div>';
|
||||||
|
$html .= '</div>';
|
||||||
|
|
||||||
|
if ($index < count($chapters) - 1) {
|
||||||
|
$html .= '<hr style="margin: 30px 0;">';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$html .= '<div class="footer">
|
||||||
|
Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i') . '<br>
|
||||||
|
Автор: ' . htmlspecialchars($author_name) . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')) . '
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>';
|
||||||
|
|
||||||
|
$filename = cleanFilename($book['title']) . '.html';
|
||||||
|
header('Content-Type: text/html; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
echo $html;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportTXT($book, $chapters, $is_public, $author_name) {
|
||||||
|
$content = "=" . str_repeat("=", 80) . "=\n";
|
||||||
|
$content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n";
|
||||||
|
$content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n";
|
||||||
|
$content .= "=" . str_repeat("=", 80) . "=\n\n";
|
||||||
|
|
||||||
|
if (!empty($book['genre'])) {
|
||||||
|
$content .= "Жанр: " . $book['genre'] . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($book['description'])) {
|
||||||
|
$content .= "ОПИСАНИЕ:\n";
|
||||||
|
|
||||||
|
// Обрабатываем описание
|
||||||
|
$descriptionText = strip_tags($book['description']);
|
||||||
|
$content .= wordwrap($descriptionText, 144) . "\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оглавление
|
||||||
|
if (!empty($chapters)) {
|
||||||
|
$content .= "ОГЛАВЛЕНИЕ:\n";
|
||||||
|
$content .= str_repeat("-", 60) . "\n";
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
|
$chapter_number = $index + 1;
|
||||||
|
$content .= "{$chapter_number}. {$chapter['title']}\n";
|
||||||
|
}
|
||||||
|
$content .= "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$content .= str_repeat("-", 144) . "\n\n";
|
||||||
|
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
|
$content .= $chapter['title'] . "\n";
|
||||||
|
$content .= str_repeat("-", 60) . "\n\n";
|
||||||
|
|
||||||
|
// Получаем очищенный текст
|
||||||
|
$cleanContent = strip_tags($chapter['content']);
|
||||||
|
$paragraphs = $this->htmlToPlainTextParagraphs($cleanContent);
|
||||||
|
|
||||||
|
foreach ($paragraphs as $paragraph) {
|
||||||
|
if (!empty(trim($paragraph))) {
|
||||||
|
$content .= wordwrap($paragraph, 144) . "\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($index < count($chapters) - 1) {
|
||||||
|
$content .= str_repeat("-", 144) . "\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$content .= "\n" . str_repeat("=", 144) . "\n";
|
||||||
|
$content .= "Экспортировано из " . APP_NAME . " - " . date('d.m.Y H:i') . "\n";
|
||||||
|
$content .= "Автор: " . $author_name . " | Всего глав: " . count($chapters) . " | Всего слов: " . array_sum(array_column($chapters, 'word_count')) . "\n";
|
||||||
|
$content .= str_repeat("=", 144) . "\n";
|
||||||
|
|
||||||
|
$filename = cleanFilename($book['title']) . '.txt';
|
||||||
|
header('Content-Type: text/plain; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||||
|
echo $content;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для разбивки HTML на абзацы
|
||||||
|
function htmlToParagraphs($html) {
|
||||||
|
// Убираем HTML теги и нормализуем пробелы
|
||||||
|
$text = strip_tags($html);
|
||||||
|
$text = preg_replace('/\s+/', ' ', $text);
|
||||||
|
|
||||||
|
// Разбиваем на абзацы по точкам и переносам строк
|
||||||
|
$paragraphs = preg_split('/(?<=[.!?])\s+/', $text);
|
||||||
|
|
||||||
|
// Фильтруем пустые абзацы
|
||||||
|
$paragraphs = array_filter($paragraphs, function($paragraph) {
|
||||||
|
return !empty(trim($paragraph));
|
||||||
|
});
|
||||||
|
|
||||||
|
return $paragraphs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function htmlToPlainTextParagraphs($html) {
|
||||||
|
// Убираем HTML теги
|
||||||
|
$text = strip_tags($html);
|
||||||
|
|
||||||
|
// Заменяем HTML entities
|
||||||
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
// Нормализуем переносы строк
|
||||||
|
$text = str_replace(["\r\n", "\r"], "\n", $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 (!empty($currentParagraph)) {
|
||||||
|
$currentParagraph .= ' ' . $trimmedLine;
|
||||||
|
} else {
|
||||||
|
$currentParagraph = $trimmedLine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем последний абзац
|
||||||
|
if (!empty($currentParagraph)) {
|
||||||
|
$paragraphs[] = $currentParagraph;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $paragraphs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
<?php
|
||||||
|
// controllers/SeriesController.php
|
||||||
|
require_once 'controllers/BaseController.php';
|
||||||
|
require_once 'models/Series.php';
|
||||||
|
require_once 'models/Book.php';
|
||||||
|
|
||||||
|
class SeriesController extends BaseController {
|
||||||
|
|
||||||
|
public function index() {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
$series = $seriesModel->findByUser($user_id);
|
||||||
|
|
||||||
|
// Получаем статистику для каждой серии отдельно
|
||||||
|
foreach ($series as &$ser) {
|
||||||
|
$stats = $seriesModel->getSeriesStats($ser['id'], $user_id);
|
||||||
|
$ser['book_count'] = $stats['book_count'] ?? 0;
|
||||||
|
$ser['total_words'] = $stats['total_words'] ?? 0;
|
||||||
|
}
|
||||||
|
unset($ser);
|
||||||
|
|
||||||
|
$this->render('series/index', [
|
||||||
|
'series' => $series,
|
||||||
|
'page_title' => "Мои серии книг"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create() {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = "Ошибка безопасности";
|
||||||
|
} else {
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
$description = trim($_POST['description'] ?? '');
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$error = "Название серии обязательно";
|
||||||
|
} else {
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
$data = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'user_id' => $_SESSION['user_id']
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($seriesModel->create($data)) {
|
||||||
|
$_SESSION['success'] = "Серия успешно создана";
|
||||||
|
$new_series_id = $this->pdo->lastInsertId();
|
||||||
|
$this->redirect("/series/{$new_series_id}/edit");
|
||||||
|
} else {
|
||||||
|
$error = "Ошибка при создании серии";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('series/create', [
|
||||||
|
'error' => $error,
|
||||||
|
'page_title' => "Создание новой серии"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function edit($id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
$series = $seriesModel->findById($id);
|
||||||
|
|
||||||
|
if (!$series || !$seriesModel->userOwnsSeries($id, $user_id)) {
|
||||||
|
$_SESSION['error'] = "Серия не найдена или у вас нет доступа";
|
||||||
|
$this->redirect('/series');
|
||||||
|
}
|
||||||
|
|
||||||
|
$error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$error = "Ошибка безопасности";
|
||||||
|
} else {
|
||||||
|
$title = trim($_POST['title'] ?? '');
|
||||||
|
$description = trim($_POST['description'] ?? '');
|
||||||
|
|
||||||
|
if (empty($title)) {
|
||||||
|
$error = "Название серии обязательно";
|
||||||
|
} else {
|
||||||
|
$data = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'user_id' => $user_id
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($seriesModel->update($id, $data)) {
|
||||||
|
$_SESSION['success'] = "Серия успешно обновлена";
|
||||||
|
$this->redirect('/series');
|
||||||
|
} else {
|
||||||
|
$error = "Ошибка при обновлении серии";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем книги в серии
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$books_in_series = $bookModel->findBySeries($id);
|
||||||
|
$available_books = $bookModel->getBooksNotInSeries($user_id, $id);
|
||||||
|
|
||||||
|
$this->render('series/edit', [
|
||||||
|
'series' => $series,
|
||||||
|
'books_in_series' => $books_in_series,
|
||||||
|
'available_books' => $available_books,
|
||||||
|
'error' => $error,
|
||||||
|
'page_title' => "Редактирование серии: " . e($series['title'])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete($id) {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
$_SESSION['error'] = "Неверный метод запроса";
|
||||||
|
$this->redirect('/series');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$_SESSION['error'] = "Ошибка безопасности";
|
||||||
|
$this->redirect('/series');
|
||||||
|
}
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
|
||||||
|
if (!$seriesModel->userOwnsSeries($id, $user_id)) {
|
||||||
|
$_SESSION['error'] = "У вас нет доступа к этой серии";
|
||||||
|
$this->redirect('/series');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seriesModel->delete($id, $user_id)) {
|
||||||
|
$_SESSION['success'] = "Серия успешно удалена";
|
||||||
|
} else {
|
||||||
|
$_SESSION['error'] = "Ошибка при удалении серии";
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->redirect('/series');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewPublic($id) {
|
||||||
|
$seriesModel = new Series($this->pdo);
|
||||||
|
$series = $seriesModel->findById($id);
|
||||||
|
|
||||||
|
if (!$series) {
|
||||||
|
http_response_code(404);
|
||||||
|
$this->render('errors/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем только опубликованные книги серии
|
||||||
|
$books = $seriesModel->getBooksInSeries($id, true);
|
||||||
|
|
||||||
|
// Получаем информацию об авторе
|
||||||
|
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
||||||
|
$stmt->execute([$series['user_id']]);
|
||||||
|
$author = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Получаем статистику по опубликованным книгам
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$total_words = 0;
|
||||||
|
$total_chapters = 0;
|
||||||
|
|
||||||
|
foreach ($books as $book) {
|
||||||
|
$book_stats = $bookModel->getBookStats($book['id'], true);
|
||||||
|
$total_words += $book_stats['total_words'] ?? 0;
|
||||||
|
$total_chapters += $book_stats['chapter_count'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('series/view_public', [
|
||||||
|
'series' => $series,
|
||||||
|
'books' => $books,
|
||||||
|
'author' => $author,
|
||||||
|
'total_words' => $total_words,
|
||||||
|
'total_chapters' => $total_chapters,
|
||||||
|
'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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
// controllers/UserController.php
|
||||||
|
require_once 'controllers/BaseController.php';
|
||||||
|
require_once 'models/User.php';
|
||||||
|
require_once 'models/Book.php';
|
||||||
|
|
||||||
|
class UserController extends BaseController {
|
||||||
|
|
||||||
|
public function profile() {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$userModel = new User($this->pdo);
|
||||||
|
$user = $userModel->findById($user_id);
|
||||||
|
|
||||||
|
$message = '';
|
||||||
|
$avatar_error = '';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
$message = "Ошибка безопасности";
|
||||||
|
} else {
|
||||||
|
$display_name = trim($_POST['display_name'] ?? '');
|
||||||
|
$email = trim($_POST['email'] ?? '');
|
||||||
|
$bio = trim($_POST['bio'] ?? '');
|
||||||
|
|
||||||
|
// Обработка загрузки аватарки
|
||||||
|
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === UPLOAD_ERR_OK) {
|
||||||
|
$avatar_result = handleAvatarUpload($_FILES['avatar'], $user_id);
|
||||||
|
if ($avatar_result['success']) {
|
||||||
|
$userModel->updateAvatar($user_id, $avatar_result['filename']);
|
||||||
|
// Обновляем данные пользователя
|
||||||
|
$user = $userModel->findById($user_id);
|
||||||
|
} else {
|
||||||
|
$avatar_error = $avatar_result['error'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка удаления аватарки
|
||||||
|
if (isset($_POST['delete_avatar']) && $_POST['delete_avatar'] == '1') {
|
||||||
|
deleteUserAvatar($user_id);
|
||||||
|
$user = $userModel->findById($user_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем основные данные
|
||||||
|
$data = [
|
||||||
|
'display_name' => $display_name,
|
||||||
|
'email' => $email,
|
||||||
|
'bio' => $bio
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($userModel->updateProfile($user_id, $data)) {
|
||||||
|
$_SESSION['display_name'] = $display_name ?: $user['username'];
|
||||||
|
$message = "Профиль обновлен";
|
||||||
|
// Обновляем данные пользователя
|
||||||
|
$user = $userModel->findById($user_id);
|
||||||
|
} else {
|
||||||
|
$message = "Ошибка при обновлении профиля";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('user/profile', [
|
||||||
|
'user' => $user,
|
||||||
|
'message' => $message,
|
||||||
|
'avatar_error' => $avatar_error,
|
||||||
|
'page_title' => "Мой профиль"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateProfile() {
|
||||||
|
$this->requireLogin();
|
||||||
|
|
||||||
|
// Эта функция обрабатывает AJAX или прямые POST запросы для обновления профиля
|
||||||
|
// Можно объединить с методом profile() или оставить отдельно для API-like операций
|
||||||
|
$this->profile(); // Перенаправляем на основной метод
|
||||||
|
}
|
||||||
|
|
||||||
|
public function viewPublic($id) {
|
||||||
|
$userModel = new User($this->pdo);
|
||||||
|
$user = $userModel->findById($id);
|
||||||
|
|
||||||
|
if (!$user) {
|
||||||
|
http_response_code(404);
|
||||||
|
$this->render('errors/404');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bookModel = new Book($this->pdo);
|
||||||
|
$books = $bookModel->findByUser($id, true); // только опубликованные
|
||||||
|
|
||||||
|
// Получаем статистику автора
|
||||||
|
$total_books = count($books);
|
||||||
|
$total_words = 0;
|
||||||
|
$total_chapters = 0;
|
||||||
|
|
||||||
|
foreach ($books as $book) {
|
||||||
|
$book_stats = $bookModel->getBookStats($book['id'], true);
|
||||||
|
$total_words += $book_stats['total_words'] ?? 0;
|
||||||
|
$total_chapters += $book_stats['chapter_count'] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
$this->render('user/view_public', [
|
||||||
|
'user' => $user,
|
||||||
|
'books' => $books,
|
||||||
|
'total_books' => $total_books,
|
||||||
|
'total_words' => $total_words,
|
||||||
|
'total_chapters' => $total_chapters,
|
||||||
|
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
191
dashboard.php
191
dashboard.php
|
|
@ -1,191 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
$seriesModel = new Series($pdo);
|
|
||||||
|
|
||||||
$books = $bookModel->findByUser($user_id);
|
|
||||||
$series = $seriesModel->findByUser($user_id);
|
|
||||||
|
|
||||||
// Статистика по книгам
|
|
||||||
$total_chapters = 0;
|
|
||||||
$total_words = 0;
|
|
||||||
foreach ($books as $book) {
|
|
||||||
$total_chapters += $book['chapter_count'];
|
|
||||||
$total_words += $book['total_words'];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Статистика по сериям
|
|
||||||
$series_stats = [
|
|
||||||
'total_series' => count($series),
|
|
||||||
'series_with_books' => 0,
|
|
||||||
'total_books_in_series' => 0
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($series as $ser) {
|
|
||||||
$series_books = $seriesModel->getBooksInSeries($ser['id']);
|
|
||||||
$series_stats['total_books_in_series'] += count($series_books);
|
|
||||||
if (count($series_books) > 0) {
|
|
||||||
$series_stats['series_with_books']++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$page_title = "Панель управления";
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<h1>Добро пожаловать, <?= e($_SESSION['display_name']) ?>!</h1>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<a href="profile.php" class="adaptive-button secondary">✏️ Редактировать профиль</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid">
|
|
||||||
<article>
|
|
||||||
<h2>📚 Мои книги</h2>
|
|
||||||
<p>Управляйте вашими книгами и главами</p>
|
|
||||||
<div class="dashboard-buttons">
|
|
||||||
<a href="books.php" role="button" class="dashboard-button">
|
|
||||||
Мои книги (<?= count($books) ?>)
|
|
||||||
</a>
|
|
||||||
<a href="book_edit.php" role="button" class="dashboard-button new">
|
|
||||||
➕ Новая книга
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h2>📊 Статистика</h2>
|
|
||||||
<div class="stats-list">
|
|
||||||
<p><strong>Книг:</strong> <?= count($books) ?></p>
|
|
||||||
<p><strong>Глав:</strong> <?= $total_chapters ?></p>
|
|
||||||
<p><strong>Всего слов:</strong> <?= $total_words ?></p>
|
|
||||||
<?php if ($total_words > 0): ?>
|
|
||||||
<p><strong>Средняя глава:</strong> <?= round($total_words / max(1, $total_chapters)) ?> слов</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h2>📖 Мои серии</h2>
|
|
||||||
<p>Управляйте сериями книг</p>
|
|
||||||
<div class="dashboard-buttons">
|
|
||||||
<a href="series.php" role="button" class="dashboard-button">
|
|
||||||
Мои серии (<?= $series_stats['total_series'] ?>)
|
|
||||||
</a>
|
|
||||||
<a href="series_edit.php" role="button" class="dashboard-button new">
|
|
||||||
➕ Новая серия
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if ($series_stats['total_series'] > 0): ?>
|
|
||||||
<div class="series-stats">
|
|
||||||
<p><strong>Книг в сериях:</strong> <?= $series_stats['total_books_in_series'] ?></p>
|
|
||||||
<p><strong>Заполненных серий:</strong> <?= $series_stats['series_with_books'] ?></p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (!empty($books)): ?>
|
|
||||||
<div class="dashboard-section">
|
|
||||||
<h2>Недавние книги</h2>
|
|
||||||
<div class="grid">
|
|
||||||
<?php foreach (array_slice($books, 0, 3) as $book): ?>
|
|
||||||
<article class="dashboard-item">
|
|
||||||
<h4>
|
|
||||||
<?= e($book['title']) ?>
|
|
||||||
<?php if ($book['series_id']): ?>
|
|
||||||
<?php
|
|
||||||
$series_stmt = $pdo->prepare("SELECT title FROM series WHERE id = ?");
|
|
||||||
$series_stmt->execute([$book['series_id']]);
|
|
||||||
$series_title = $series_stmt->fetch()['title'] ?? '';
|
|
||||||
?>
|
|
||||||
<?php if ($series_title): ?>
|
|
||||||
<br><small style="color: #007bff;">📚 <?= e($series_title) ?></small>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</h4>
|
|
||||||
<p>Глав: <?= $book['chapter_count'] ?> | Слов: <?= $book['total_words'] ?></p>
|
|
||||||
<div class="action-buttons">
|
|
||||||
<a href="book_edit.php?id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
|
|
||||||
Редактировать
|
|
||||||
</a>
|
|
||||||
<a href="chapters.php?book_id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
|
|
||||||
Главы
|
|
||||||
</a>
|
|
||||||
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" role="button" class="compact-button secondary" target="_blank">
|
|
||||||
Просмотр
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (count($books) > 3): ?>
|
|
||||||
<div style="text-align: center; margin-top: 1rem;">
|
|
||||||
<a href="books.php" role="button" class="secondary">📚 Показать все книги (<?= count($books) ?>)</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (!empty($series)): ?>
|
|
||||||
<div class="dashboard-section">
|
|
||||||
<h2>Недавние серии</h2>
|
|
||||||
<div class="grid">
|
|
||||||
<?php foreach (array_slice($series, 0, 3) as $ser): ?>
|
|
||||||
<article class="dashboard-item">
|
|
||||||
<h4><?= e($ser['title']) ?></h4>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$books_in_series = $seriesModel->getBooksInSeries($ser['id']);
|
|
||||||
$series_words = 0;
|
|
||||||
$series_chapters = 0;
|
|
||||||
|
|
||||||
foreach ($books_in_series as $book) {
|
|
||||||
$book_stats = $bookModel->getBookStats($book['id']);
|
|
||||||
$series_words += $book_stats['total_words'] ?? 0;
|
|
||||||
$series_chapters += $book_stats['chapter_count'] ?? 0;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
<p>Книг: <?= count($books_in_series) ?> | Глав: <?= $series_chapters ?> | Слов: <?= $series_words ?></p>
|
|
||||||
|
|
||||||
<div class="action-buttons">
|
|
||||||
<a href="series_edit.php?id=<?= $ser['id'] ?>" role="button" class="compact-button secondary">
|
|
||||||
Редактировать
|
|
||||||
</a>
|
|
||||||
<a href="view_series.php?id=<?= $ser['id'] ?>" role="button" class="compact-button secondary" target="_blank">
|
|
||||||
Просмотр
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (count($series) > 3): ?>
|
|
||||||
<div style="text-align: center; margin-top: 1rem;">
|
|
||||||
<a href="series.php" role="button" class="secondary">📖 Показать все серии (<?= count($series) ?>)</a>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (empty($books) && empty($series)): ?>
|
|
||||||
<div class="welcome-message">
|
|
||||||
<h3>Добро пожаловать в <?= e(APP_NAME) ?>!</h3>
|
|
||||||
<p>Начните создавать свои литературные произведения</p>
|
|
||||||
<div class="welcome-buttons">
|
|
||||||
<a href="book_edit.php" role="button" class="contrast">📖 Создать первую книгу</a>
|
|
||||||
<a href="series_edit.php" role="button" class="secondary">📚 Создать первую серию</a>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 1.5rem;">
|
|
||||||
<a href="profile.php" role="button" class="secondary">✏️ Настроить профиль</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
869
export_book.php
869
export_book.php
|
|
@ -1,869 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_once 'vendor/autoload.php';
|
|
||||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
|
||||||
|
|
||||||
use PhpOffice\PhpWord\PhpWord;
|
|
||||||
use PhpOffice\PhpWord\IOFactory;
|
|
||||||
use TCPDF;
|
|
||||||
|
|
||||||
// Проверяем авторизацию или share_token
|
|
||||||
$user_id = $_SESSION['user_id'] ?? null;
|
|
||||||
$share_token = $_GET['share_token'] ?? null;
|
|
||||||
$book_id = $_GET['book_id'] ?? null;
|
|
||||||
$format = $_GET['format'] ?? 'pdf';
|
|
||||||
|
|
||||||
if (!$user_id && !$share_token) {
|
|
||||||
$_SESSION['error'] = "Доступ запрещен";
|
|
||||||
redirect('login.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
$chapterModel = new Chapter($pdo);
|
|
||||||
$Parsedown = new ParsedownExtra();
|
|
||||||
|
|
||||||
// Получаем книгу
|
|
||||||
if ($share_token) {
|
|
||||||
$book = $bookModel->findByShareToken($share_token);
|
|
||||||
// Для публичного доступа - только опубликованные главы
|
|
||||||
$chapters = $bookModel->getPublishedChapters($book['id']);
|
|
||||||
$is_public = true;
|
|
||||||
} elseif ($book_id && $user_id) {
|
|
||||||
$book = $bookModel->findById($book_id);
|
|
||||||
if (!$book || $book['user_id'] != $user_id) {
|
|
||||||
$_SESSION['error'] = "Доступ запрещен";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
// Для автора - все главы
|
|
||||||
$chapters = $chapterModel->findByBook($book_id);
|
|
||||||
$is_public = false;
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = "Книга не найдена";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$book) {
|
|
||||||
$_SESSION['error'] = "Книга не найдена";
|
|
||||||
redirect('books.php');
|
|
||||||
}
|
|
||||||
// Получаем информацию об авторе
|
|
||||||
$author_info = "Неизвестный автор";
|
|
||||||
if ($book) {
|
|
||||||
$stmt = $pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
|
|
||||||
$stmt->execute([$book['user_id']]);
|
|
||||||
$author_info = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
if ($author_info['display_name'] !=""){
|
|
||||||
$author_name = $author_info['display_name'];
|
|
||||||
}else{
|
|
||||||
$author_name = $author_info['username'] ;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Функция для преобразования 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);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработка экспорта
|
|
||||||
switch ($format) {
|
|
||||||
case 'pdf':
|
|
||||||
exportPDF($book, $chapters, $is_public, $author_name);
|
|
||||||
break;
|
|
||||||
case 'docx':
|
|
||||||
exportDOCX($book, $chapters, $is_public, $author_name);
|
|
||||||
break;
|
|
||||||
case 'html':
|
|
||||||
exportHTML($book, $chapters, $is_public, $author_name);
|
|
||||||
break;
|
|
||||||
case 'txt':
|
|
||||||
exportTXT($book, $chapters, $is_public, $author_name);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
$_SESSION['error'] = "Неверный формат экспорта";
|
|
||||||
redirect($share_token ? "view_book.php?share_token=$share_token" : "book_edit.php?id=$book_id");
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Устанавливаем метаданные документа
|
|
||||||
$pdf->SetCreator(APP_NAME);
|
|
||||||
$pdf->SetAuthor($author_name);
|
|
||||||
$pdf->SetTitle($book['title']);
|
|
||||||
$pdf->SetSubject($book['genre'] ?? '');
|
|
||||||
|
|
||||||
// Устанавливаем margins
|
|
||||||
$pdf->SetMargins(15, 25, 15);
|
|
||||||
$pdf->SetHeaderMargin(10);
|
|
||||||
$pdf->SetFooterMargin(10);
|
|
||||||
|
|
||||||
// Устанавливаем авто разрыв страниц
|
|
||||||
$pdf->SetAutoPageBreak(TRUE, 15);
|
|
||||||
|
|
||||||
// Добавляем страницу
|
|
||||||
$pdf->AddPage();
|
|
||||||
|
|
||||||
// Устанавливаем шрифт с поддержкой кириллицы
|
|
||||||
$pdf->SetFont('dejavusans', '', 12);
|
|
||||||
|
|
||||||
// Заголовок книги
|
|
||||||
$pdf->SetFont('dejavusans', 'B', 18);
|
|
||||||
$pdf->Cell(0, 10, $book['title'], 0, 1, 'C');
|
|
||||||
$pdf->Ln(2);
|
|
||||||
|
|
||||||
// Автор
|
|
||||||
$pdf->SetFont('dejavusans', 'I', 14);
|
|
||||||
$pdf->Cell(0, 10, $author_name, 0, 1, 'C');
|
|
||||||
$pdf->Ln(5);
|
|
||||||
|
|
||||||
// Обложка книги
|
|
||||||
if (!empty($book['cover_image'])) {
|
|
||||||
$cover_path = COVERS_PATH . $book['cover_image'];
|
|
||||||
if (file_exists($cover_path)) {
|
|
||||||
list($width, $height) = getimagesize($cover_path);
|
|
||||||
$max_width = 80;
|
|
||||||
$ratio = $width / $height;
|
|
||||||
$new_height = $max_width / $ratio;
|
|
||||||
|
|
||||||
$x = (210 - $max_width) / 2;
|
|
||||||
$pdf->Image($cover_path, $x, $pdf->GetY(), $max_width, $new_height, '', '', 'N', false, 300, '', false, false, 0, false, false, false);
|
|
||||||
$pdf->Ln($new_height + 5);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Жанр
|
|
||||||
if (!empty($book['genre'])) {
|
|
||||||
$pdf->SetFont('dejavusans', 'I', 12);
|
|
||||||
$pdf->Cell(0, 10, 'Жанр: ' . $book['genre'], 0, 1, 'C');
|
|
||||||
$pdf->Ln(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Описание
|
|
||||||
if (!empty($book['description'])) {
|
|
||||||
$pdf->SetFont('dejavusans', '', 11);
|
|
||||||
$pdf->MultiCell(0, 6, $book['description'], 0, 'J');
|
|
||||||
$pdf->Ln(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Интерактивное оглавление
|
|
||||||
$chapterLinks = [];
|
|
||||||
if (!empty($chapters)) {
|
|
||||||
$pdf->SetFont('dejavusans', 'B', 14);
|
|
||||||
$pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C');
|
|
||||||
$pdf->Ln(5);
|
|
||||||
|
|
||||||
$toc_page = $pdf->getPage();
|
|
||||||
|
|
||||||
$pdf->SetFont('dejavusans', '', 11);
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
$chapter_number = $index + 1;
|
|
||||||
$link = $pdf->AddLink();
|
|
||||||
$chapterLinks[$chapter['id']] = $link; // Сохраняем ссылку для этой главы
|
|
||||||
$pdf->Cell(0, 6, "{$chapter_number}. {$chapter['title']}", 0, 1, 'L', false, $link);
|
|
||||||
}
|
|
||||||
$pdf->Ln(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Разделитель
|
|
||||||
$pdf->Line(15, $pdf->GetY(), 195, $pdf->GetY());
|
|
||||||
$pdf->Ln(10);
|
|
||||||
|
|
||||||
// Главы с закладками и правильными ссылками
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
// Добавляем новую страницу для каждой главы
|
|
||||||
$pdf->AddPage();
|
|
||||||
|
|
||||||
// УСТАНАВЛИВАЕМ ЯКОРЬ ДЛЯ ССЫЛКИ
|
|
||||||
if (isset($chapterLinks[$chapter['id']])) {
|
|
||||||
$pdf->SetLink($chapterLinks[$chapter['id']]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Устанавливаем закладку для этой главы
|
|
||||||
$pdf->Bookmark($chapter['title'], 0, 0, '', 'B', array(0, 0, 0));
|
|
||||||
|
|
||||||
// Название главы
|
|
||||||
$pdf->SetFont('dejavusans', 'B', 14);
|
|
||||||
$pdf->Cell(0, 8, $chapter['title'], 0, 1);
|
|
||||||
$pdf->Ln(2);
|
|
||||||
|
|
||||||
// Контент главы
|
|
||||||
$pdf->SetFont('dejavusans', '', 11);
|
|
||||||
if ($book['editor_type'] == 'markdown') {
|
|
||||||
$htmlContent = $Parsedown->text($chapter['content']);
|
|
||||||
} else {
|
|
||||||
$htmlContent = $chapter['content'];
|
|
||||||
}
|
|
||||||
$pdf->writeHTML($htmlContent, true, false, true, false, '');
|
|
||||||
|
|
||||||
$pdf->Ln(8);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Футер с информацией
|
|
||||||
$pdf->SetY(-25);
|
|
||||||
$pdf->SetFont('dejavusans', 'I', 8);
|
|
||||||
$pdf->Cell(0, 6, 'Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), 0, 1, 'C');
|
|
||||||
$pdf->Cell(0, 6, 'Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), 0, 1, 'C');
|
|
||||||
|
|
||||||
// Отправляем файл
|
|
||||||
$filename = cleanFilename($book['title']) . '.pdf';
|
|
||||||
$pdf->Output($filename, 'D');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportDOCX($book, $chapters, $is_public, $author_name) {
|
|
||||||
global $Parsedown;
|
|
||||||
|
|
||||||
$phpWord = new PhpWord();
|
|
||||||
|
|
||||||
// Стили документа
|
|
||||||
$phpWord->setDefaultFontName('Times New Roman');
|
|
||||||
$phpWord->setDefaultFontSize(12);
|
|
||||||
|
|
||||||
// Секция документа
|
|
||||||
$section = $phpWord->addSection();
|
|
||||||
|
|
||||||
// Заголовок книги
|
|
||||||
$section->addText($book['title'], ['bold' => true, 'size' => 16], ['alignment' => 'center']);
|
|
||||||
$section->addTextBreak(1);
|
|
||||||
|
|
||||||
// Автор
|
|
||||||
$section->addText($author_name, ['italic' => true, 'size' => 14], ['alignment' => 'center']);
|
|
||||||
$section->addTextBreak(2);
|
|
||||||
|
|
||||||
// Обложка книги
|
|
||||||
if (!empty($book['cover_image'])) {
|
|
||||||
$cover_path = COVERS_PATH . $book['cover_image'];
|
|
||||||
if (file_exists($cover_path)) {
|
|
||||||
$section->addImage($cover_path, [
|
|
||||||
'width' => 150,
|
|
||||||
'height' => 225,
|
|
||||||
'alignment' => 'center'
|
|
||||||
]);
|
|
||||||
$section->addTextBreak(2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Жанр
|
|
||||||
if (!empty($book['genre'])) {
|
|
||||||
$section->addText('Жанр: ' . $book['genre'], ['italic' => true], ['alignment' => 'center']);
|
|
||||||
$section->addTextBreak(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Описание
|
|
||||||
if (!empty($book['description'])) {
|
|
||||||
if ($book['editor_type'] == 'markdown') {
|
|
||||||
$descriptionParagraphs = markdownToParagraphs($book['description']);
|
|
||||||
} else {
|
|
||||||
$descriptionParagraphs = htmlToParagraphs($book['description']);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($descriptionParagraphs as $paragraph) {
|
|
||||||
if (!empty(trim($paragraph))) {
|
|
||||||
$section->addText($paragraph);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$section->addTextBreak(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Интерактивное оглавление
|
|
||||||
if (!empty($chapters)) {
|
|
||||||
$section->addText('Оглавление', ['bold' => true, 'size' => 14], ['alignment' => 'center']);
|
|
||||||
$section->addTextBreak(1);
|
|
||||||
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
$chapter_number = $index + 1;
|
|
||||||
// Создаем гиперссылку на заголовок главы
|
|
||||||
$section->addLink("chapter_{$chapter['id']}", "{$chapter_number}. {$chapter['title']}", null, null, true);
|
|
||||||
$section->addTextBreak(1);
|
|
||||||
}
|
|
||||||
$section->addTextBreak(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Разделитель
|
|
||||||
$section->addPageBreak();
|
|
||||||
|
|
||||||
// Главы с закладками
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
// Добавляем закладку для главы
|
|
||||||
$section->addBookmark("chapter_{$chapter['id']}");
|
|
||||||
|
|
||||||
// Заголовок главы
|
|
||||||
$section->addText($chapter['title'], ['bold' => true, 'size' => 14]);
|
|
||||||
$section->addTextBreak(1);
|
|
||||||
|
|
||||||
// Получаем очищенный текст и разбиваем на абзацы в зависимости от типа редактора
|
|
||||||
if ($book['editor_type'] == 'markdown') {
|
|
||||||
$cleanContent = cleanMarkdown($chapter['content']);
|
|
||||||
$paragraphs = markdownToParagraphs($cleanContent);
|
|
||||||
} else {
|
|
||||||
$cleanContent = strip_tags($chapter['content']);
|
|
||||||
$paragraphs = htmlToParagraphs($chapter['content']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем каждый абзац
|
|
||||||
foreach ($paragraphs as $paragraph) {
|
|
||||||
if (!empty(trim($paragraph))) {
|
|
||||||
$section->addText($paragraph);
|
|
||||||
$section->addTextBreak(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем разрыв страницы между главами (кроме последней)
|
|
||||||
if ($index < count($chapters) - 1) {
|
|
||||||
$section->addPageBreak();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Футер
|
|
||||||
$section->addTextBreak(2);
|
|
||||||
$section->addText('Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), ['italic' => true, 'size' => 9]);
|
|
||||||
$section->addText('Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), ['italic' => true, 'size' => 9]);
|
|
||||||
|
|
||||||
// Сохраняем и отправляем
|
|
||||||
$filename = cleanFilename($book['title']) . '.docx';
|
|
||||||
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
|
||||||
|
|
||||||
$objWriter = IOFactory::createWriter($phpWord, 'Word2007');
|
|
||||||
$objWriter->save('php://output');
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Новая функция для разбивки HTML на абзацы
|
|
||||||
function htmlToParagraphs($html) {
|
|
||||||
// Убираем HTML теги и нормализуем пробелы
|
|
||||||
$text = strip_tags($html);
|
|
||||||
$text = preg_replace('/\s+/', ' ', $text);
|
|
||||||
|
|
||||||
// Разбиваем на абзацы по точкам и переносам строк
|
|
||||||
$paragraphs = preg_split('/(?<=[.!?])\s+/', $text);
|
|
||||||
|
|
||||||
// Фильтруем пустые абзацы
|
|
||||||
$paragraphs = array_filter($paragraphs, function($paragraph) {
|
|
||||||
return !empty(trim($paragraph));
|
|
||||||
});
|
|
||||||
|
|
||||||
return $paragraphs;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportHTML($book, $chapters, $is_public, $author_name) {
|
|
||||||
global $Parsedown;
|
|
||||||
|
|
||||||
$html = '<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>' . htmlspecialchars($book['title']) . '</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: "Times New Roman", serif;
|
|
||||||
line-height: 1.6;
|
|
||||||
margin: 40px;
|
|
||||||
max-width: 900px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.book-title {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
}
|
|
||||||
.book-author {
|
|
||||||
text-align: center;
|
|
||||||
font-size: 18px;
|
|
||||||
font-style: italic;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.book-cover {
|
|
||||||
text-align: center;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.book-cover img {
|
|
||||||
max-width: 200px;
|
|
||||||
height: auto;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.book-genre {
|
|
||||||
text-align: center;
|
|
||||||
font-style: italic;
|
|
||||||
color: #666;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.book-description {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
}
|
|
||||||
.table-of-contents {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 5px;
|
|
||||||
margin: 20px 0;
|
|
||||||
columns: 1;
|
|
||||||
column-gap: 2rem;
|
|
||||||
}
|
|
||||||
.table-of-contents h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
text-align: center;
|
|
||||||
column-span: all;
|
|
||||||
}
|
|
||||||
.table-of-contents ul {
|
|
||||||
list-style-type: none;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
|
||||||
.table-of-contents li {
|
|
||||||
margin-bottom: 5px;
|
|
||||||
padding: 5px 0;
|
|
||||||
break-inside: avoid;
|
|
||||||
}
|
|
||||||
.table-of-contents a {
|
|
||||||
text-decoration: none;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.table-of-contents a:hover {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
.chapter-title {
|
|
||||||
border-bottom: 2px solid #007bff;
|
|
||||||
padding-bottom: 10px;
|
|
||||||
margin-top: 30px;
|
|
||||||
font-size: 20px;
|
|
||||||
scroll-margin-top: 2rem;
|
|
||||||
}
|
|
||||||
.chapter-content {
|
|
||||||
margin: 20px 0;
|
|
||||||
text-align: justify;
|
|
||||||
}
|
|
||||||
.footer {
|
|
||||||
margin-top: 40px;
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #ddd;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
/* Отображение абзацев */
|
|
||||||
.chapter-content p {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
text-align: justify;
|
|
||||||
}
|
|
||||||
.dialogue {
|
|
||||||
margin-left: 2rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: #2c5aa0;
|
|
||||||
margin-bottom: 1em;
|
|
||||||
}
|
|
||||||
/* Остальные стили */
|
|
||||||
.chapter-content h1, .chapter-content h2, .chapter-content h3 {
|
|
||||||
margin-top: 1.5em;
|
|
||||||
margin-bottom: 0.5em;
|
|
||||||
}
|
|
||||||
.chapter-content blockquote {
|
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
padding-left: 15px;
|
|
||||||
margin-left: 0;
|
|
||||||
color: #555;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
.chapter-content code {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
.chapter-content pre {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
.chapter-content ul, .chapter-content ol {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-left: 2rem;
|
|
||||||
}
|
|
||||||
.chapter-content table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
.chapter-content th, .chapter-content td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.chapter-content th {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.table-of-contents {
|
|
||||||
columns: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="book-title">' . htmlspecialchars($book['title']) . '</div>
|
|
||||||
<div class="book-author">' . htmlspecialchars($author_name) . '</div>';
|
|
||||||
|
|
||||||
if (!empty($book['genre'])) {
|
|
||||||
$html .= '<div class="book-genre">Жанр: ' . htmlspecialchars($book['genre']) . '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обложка книги
|
|
||||||
if (!empty($book['cover_image'])) {
|
|
||||||
$cover_url = COVERS_URL . $book['cover_image'];
|
|
||||||
$html .= '<div class="book-cover">';
|
|
||||||
$html .= '<img src="' . $cover_url . '" alt="' . htmlspecialchars($book['title']) . '">';
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($book['description'])) {
|
|
||||||
$html .= '<div class="book-description">';
|
|
||||||
if ($book['editor_type'] == 'markdown') {
|
|
||||||
$html .= nl2br(htmlspecialchars($book['description']));
|
|
||||||
} else {
|
|
||||||
$html .= $book['description'];
|
|
||||||
}
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Интерактивное оглавление
|
|
||||||
if (!empty($chapters)) {
|
|
||||||
$html .= '<div class="table-of-contents">';
|
|
||||||
$html .= '<h3>Оглавление</h3>';
|
|
||||||
$html .= '<ul>';
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
$chapter_number = $index + 1;
|
|
||||||
$html .= '<li><a href="#chapter-' . $chapter['id'] . '">' . $chapter_number . '. ' . htmlspecialchars($chapter['title']) . '</a></li>';
|
|
||||||
}
|
|
||||||
$html .= '</ul>';
|
|
||||||
$html .= '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '<hr style="margin: 30px 0;">';
|
|
||||||
|
|
||||||
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>';
|
|
||||||
|
|
||||||
if ($index < count($chapters) - 1) {
|
|
||||||
$html .= '<hr style="margin: 30px 0;">';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$html .= '<div class="footer">
|
|
||||||
Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i') . '<br>
|
|
||||||
Автор: ' . htmlspecialchars($author_name) . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')) . '
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>';
|
|
||||||
|
|
||||||
$filename = cleanFilename($book['title']) . '.html';
|
|
||||||
header('Content-Type: text/html; charset=utf-8');
|
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
|
||||||
echo $html;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exportTXT($book, $chapters, $is_public, $author_name) {
|
|
||||||
$content = "=" . str_repeat("=", 80) . "=\n";
|
|
||||||
$content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n";
|
|
||||||
$content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n";
|
|
||||||
$content .= "=" . str_repeat("=", 80) . "=\n\n";
|
|
||||||
|
|
||||||
if (!empty($book['genre'])) {
|
|
||||||
$content .= "Жанр: " . $book['genre'] . "\n\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($book['description'])) {
|
|
||||||
$content .= "ОПИСАНИЕ:\n";
|
|
||||||
|
|
||||||
// Обрабатываем описание в зависимости от типа редактора
|
|
||||||
if ($book['editor_type'] == 'markdown') {
|
|
||||||
$descriptionText = cleanMarkdown($book['description']);
|
|
||||||
} else {
|
|
||||||
$descriptionText = strip_tags($book['description']);
|
|
||||||
}
|
|
||||||
|
|
||||||
$content .= wordwrap($descriptionText, 144) . "\n\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Оглавление
|
|
||||||
if (!empty($chapters)) {
|
|
||||||
$content .= "ОГЛАВЛЕНИЕ:\n";
|
|
||||||
$content .= str_repeat("-", 60) . "\n";
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
$chapter_number = $index + 1;
|
|
||||||
$content .= "{$chapter_number}. {$chapter['title']}\n";
|
|
||||||
}
|
|
||||||
$content .= "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$content .= str_repeat("-", 144) . "\n\n";
|
|
||||||
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
$content .= $chapter['title'] . "\n";
|
|
||||||
$content .= str_repeat("-", 60) . "\n\n";
|
|
||||||
|
|
||||||
// Получаем очищенный текст в зависимости от типа редактора
|
|
||||||
if ($book['editor_type'] == 'markdown') {
|
|
||||||
$cleanContent = cleanMarkdown($chapter['content']);
|
|
||||||
$paragraphs = markdownToParagraphs($cleanContent);
|
|
||||||
} else {
|
|
||||||
$cleanContent = strip_tags($chapter['content']);
|
|
||||||
$paragraphs = htmlToPlainTextParagraphs($cleanContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($paragraphs as $paragraph) {
|
|
||||||
if (!empty(trim($paragraph))) {
|
|
||||||
$content .= wordwrap($paragraph, 144) . "\n\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($index < count($chapters) - 1) {
|
|
||||||
$content .= str_repeat("-", 144) . "\n\n";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$content .= "\n" . str_repeat("=", 144) . "\n";
|
|
||||||
$content .= "Экспортировано из " . APP_NAME . " - " . date('d.m.Y H:i') . "\n";
|
|
||||||
$content .= "Автор: " . $author_name . " | Всего глав: " . count($chapters) . " | Всего слов: " . array_sum(array_column($chapters, 'word_count')) . "\n";
|
|
||||||
$content .= str_repeat("=", 144) . "\n";
|
|
||||||
|
|
||||||
$filename = cleanFilename($book['title']) . '.txt';
|
|
||||||
header('Content-Type: text/plain; charset=utf-8');
|
|
||||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
|
||||||
echo $content;
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Новая функция для разбивки HTML на абзацы в виде простого текста
|
|
||||||
function htmlToPlainTextParagraphs($html) {
|
|
||||||
// Убираем HTML теги
|
|
||||||
$text = strip_tags($html);
|
|
||||||
|
|
||||||
// Заменяем HTML entities
|
|
||||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
||||||
|
|
||||||
// Нормализуем переносы строк
|
|
||||||
$text = str_replace(["\r\n", "\r"], "\n", $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 (!empty($currentParagraph)) {
|
|
||||||
$currentParagraph .= ' ' . $trimmedLine;
|
|
||||||
} else {
|
|
||||||
$currentParagraph = $trimmedLine;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем последний абзац
|
|
||||||
if (!empty($currentParagraph)) {
|
|
||||||
$paragraphs[] = $currentParagraph;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $paragraphs;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
185
index.php
185
index.php
|
|
@ -1,9 +1,186 @@
|
||||||
<?php
|
<?php
|
||||||
|
// index.php - единая точка входа
|
||||||
require_once 'config/config.php';
|
require_once 'config/config.php';
|
||||||
|
|
||||||
if (is_logged_in()) {
|
// Получаем путь к запрашиваемому ресурсу
|
||||||
redirect('dashboard.php');
|
$requestUri = $_SERVER['REQUEST_URI'];
|
||||||
} else {
|
$requestPath = parse_url($requestUri, PHP_URL_PATH);
|
||||||
redirect('login.php');
|
|
||||||
|
// Убираем базовый URL (SITE_URL) из пути
|
||||||
|
$basePath = parse_url(SITE_URL, PHP_URL_PATH) ?? '';
|
||||||
|
if ($basePath && strpos($requestPath, $basePath) === 0) {
|
||||||
|
$requestPath = substr($requestPath, strlen($basePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Убираем ведущий слеш
|
||||||
|
$requestPath = ltrim($requestPath, '/');
|
||||||
|
|
||||||
|
// Проверяем, существует ли запрашиваемый файл
|
||||||
|
$physicalPath = __DIR__ . '/' . $requestPath;
|
||||||
|
if (pathinfo($physicalPath, PATHINFO_EXTENSION) != 'php') {
|
||||||
|
if (file_exists($physicalPath) && !is_dir($physicalPath) && !str_contains($physicalPath, '..')) {
|
||||||
|
// Определяем MIME-тип
|
||||||
|
$mimeTypes = [
|
||||||
|
'css' => 'text/css',
|
||||||
|
'js' => 'application/javascript',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'jpg' => 'image/jpeg',
|
||||||
|
'jpeg' => 'image/jpeg',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'svg' => 'image/svg+xml',
|
||||||
|
'ico' => 'image/x-icon',
|
||||||
|
'json' => 'application/json',
|
||||||
|
'woff' => 'font/woff',
|
||||||
|
'woff2' => 'font/woff2',
|
||||||
|
'ttf' => 'font/ttf',
|
||||||
|
'eot' => 'application/vnd.ms-fontobject',
|
||||||
|
];
|
||||||
|
|
||||||
|
$extension = strtolower(pathinfo($physicalPath, PATHINFO_EXTENSION));
|
||||||
|
if (isset($mimeTypes[$extension])) {
|
||||||
|
header('Content-Type: ' . $mimeTypes[$extension]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запрещаем кэширование для разработки, в продакшене можно увеличить время
|
||||||
|
header('Cache-Control: public, max-age=3600');
|
||||||
|
|
||||||
|
// Отправляем файл
|
||||||
|
readfile($physicalPath);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Простой роутер
|
||||||
|
class Router {
|
||||||
|
private $routes = [];
|
||||||
|
|
||||||
|
public function add($pattern, $handler) {
|
||||||
|
$this->routes[$pattern] = $handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle($uri) {
|
||||||
|
// Убираем базовый URL если есть
|
||||||
|
$basePath = parse_url(SITE_URL, PHP_URL_PATH) ?? '';
|
||||||
|
$uri = str_replace($basePath, '', $uri);
|
||||||
|
$uri = parse_url($uri, PHP_URL_PATH) ?? '/';
|
||||||
|
|
||||||
|
foreach ($this->routes as $pattern => $handler) {
|
||||||
|
if ($this->match($pattern, $uri)) {
|
||||||
|
return $this->callHandler($handler, $this->params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404
|
||||||
|
http_response_code(404);
|
||||||
|
include 'views/errors/404.php';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function match($pattern, $uri) {
|
||||||
|
$pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
|
||||||
|
$pattern = "#^$pattern$#";
|
||||||
|
|
||||||
|
if (preg_match($pattern, $uri, $matches)) {
|
||||||
|
$this->params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function callHandler($handler, $params) {
|
||||||
|
if (is_callable($handler)) {
|
||||||
|
return call_user_func_array($handler, array_values($params));
|
||||||
|
}
|
||||||
|
if (is_string($handler)) {
|
||||||
|
list($controller, $method) = explode('@', $handler);
|
||||||
|
$controllerFile = "controllers/{$controller}.php";
|
||||||
|
if (file_exists($controllerFile)) {
|
||||||
|
require_once $controllerFile;
|
||||||
|
$controllerInstance = new $controller();
|
||||||
|
if (method_exists($controllerInstance, $method)) {
|
||||||
|
return call_user_func_array([$controllerInstance, $method], array_values($params));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Exception("Handler not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация роутера
|
||||||
|
$router = new Router();
|
||||||
|
|
||||||
|
// Маршруты
|
||||||
|
$router->add('/', 'DashboardController@index');
|
||||||
|
$router->add('/dashboard', 'DashboardController@index');
|
||||||
|
$router->add('/index.php', 'DashboardController@index');
|
||||||
|
$router->add('/login', 'AuthController@login');
|
||||||
|
$router->add('/logout', 'AuthController@logout');
|
||||||
|
$router->add('/register', 'AuthController@register');
|
||||||
|
|
||||||
|
// Книги
|
||||||
|
$router->add('/books', 'BookController@index');
|
||||||
|
$router->add('/book/all/{id}', 'BookController@viewAll');
|
||||||
|
$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');
|
||||||
|
|
||||||
|
// Главы
|
||||||
|
$router->add('/books/{book_id}/chapters', 'ChapterController@index');
|
||||||
|
$router->add('/books/{book_id}/chapters/create', 'ChapterController@create');
|
||||||
|
$router->add('/chapters/{id}/edit', 'ChapterController@edit');
|
||||||
|
$router->add('/chapters/{id}/delete', 'ChapterController@delete');
|
||||||
|
$router->add('/chapters/preview', 'ChapterController@preview');
|
||||||
|
|
||||||
|
// Серии
|
||||||
|
$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');
|
||||||
|
$router->add('/profile/update', 'UserController@updateProfile');
|
||||||
|
|
||||||
|
// Экспорт с параметром формата
|
||||||
|
//публичный экспорт
|
||||||
|
$router->add('/export/shared/{share_token}/{format}', 'ExportController@exportShared');
|
||||||
|
$router->add('/export/shared/{share_token}', 'ExportController@exportShared'); // по умолчанию pdf
|
||||||
|
//авторсикй экспорт
|
||||||
|
$router->add('/export/{book_id}/{format}', 'ExportController@export');
|
||||||
|
$router->add('/export/{book_id}', 'ExportController@export'); // по умолчанию pdf
|
||||||
|
|
||||||
|
// Публичные страницы
|
||||||
|
$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);
|
||||||
|
|
||||||
|
// Редирект с корня на dashboard для авторизованных
|
||||||
|
$router->add('/', function() {
|
||||||
|
if (is_logged_in()) {
|
||||||
|
header("Location: " . SITE_URL . "/dashboard");
|
||||||
|
} else {
|
||||||
|
header("Location: " . SITE_URL . "/login");
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
@ -61,7 +61,6 @@ CREATE TABLE `books` (
|
||||||
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
|
||||||
`share_token` varchar(32) DEFAULT NULL,
|
`share_token` varchar(32) DEFAULT NULL,
|
||||||
`published` tinyint(1) NOT NULL DEFAULT 0,
|
`published` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
`editor_type` ENUM('markdown', 'html') DEFAULT 'markdown',
|
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
UNIQUE KEY `share_token` (`share_token`),
|
UNIQUE KEY `share_token` (`share_token`),
|
||||||
KEY `user_id` (`user_id`),
|
KEY `user_id` (`user_id`),
|
||||||
|
|
@ -219,6 +218,11 @@ define('SITE_URL', '{$site_url}');
|
||||||
|
|
||||||
// Настройки приложения
|
// Настройки приложения
|
||||||
define('APP_NAME', 'Web Writer');
|
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('UPLOAD_PATH', __DIR__ . '/../uploads/');
|
||||||
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
|
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
|
||||||
define('COVERS_URL', SITE_URL . '/uploads/covers/');
|
define('COVERS_URL', SITE_URL . '/uploads/covers/');
|
||||||
|
|
@ -242,6 +246,8 @@ try {
|
||||||
die("Ошибка подключения к базе данных");
|
die("Ошибка подключения к базе данных");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Автозагрузка моделей
|
// Автозагрузка моделей
|
||||||
spl_autoload_register(function (\$class_name) {
|
spl_autoload_register(function (\$class_name) {
|
||||||
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
|
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
|
||||||
|
|
|
||||||
100
login.php
100
login.php
|
|
@ -1,100 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
|
|
||||||
// Если пользователь уже авторизован, перенаправляем на dashboard
|
|
||||||
if (is_logged_in()) {
|
|
||||||
redirect('dashboard.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$error = '';
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$error = "Ошибка безопасности";
|
|
||||||
} else {
|
|
||||||
$username = trim($_POST['username'] ?? '');
|
|
||||||
$password = $_POST['password'] ?? '';
|
|
||||||
|
|
||||||
if (empty($username) || empty($password)) {
|
|
||||||
$error = 'Пожалуйста, введите имя пользователя и пароль';
|
|
||||||
} else {
|
|
||||||
$userModel = new User($pdo);
|
|
||||||
$user = $userModel->findByUsername($username);
|
|
||||||
|
|
||||||
if ($user && $userModel->verifyPassword($password, $user['password_hash'])) {
|
|
||||||
if (!$user['is_active']) {
|
|
||||||
$error = 'Ваш аккаунт деактивирован или ожидает активации администратором.';
|
|
||||||
} else {
|
|
||||||
// Успешный вход
|
|
||||||
$_SESSION['user_id'] = $user['id'];
|
|
||||||
$_SESSION['username'] = $user['username'];
|
|
||||||
$_SESSION['display_name'] = $user['display_name'] ?: $user['username'];
|
|
||||||
$_SESSION['avatar'] = $user['avatar'] ?? null;
|
|
||||||
// Обновляем время последнего входа
|
|
||||||
$userModel->updateLastLogin($user['id']);
|
|
||||||
|
|
||||||
$_SESSION['success'] = 'Добро пожаловать, ' . e($user['display_name'] ?: $user['username']) . '!';
|
|
||||||
redirect('dashboard.php');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$error = 'Неверное имя пользователя или пароль';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$page_title = 'Вход в систему';
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<h1>Вход в систему</h1>
|
|
||||||
|
|
||||||
<?php if ($error): ?>
|
|
||||||
<div class="alert alert-error">
|
|
||||||
<?= e($error) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (isset($_SESSION['success'])): ?>
|
|
||||||
<div class="alert alert-success">
|
|
||||||
<?= e($_SESSION['success']) ?>
|
|
||||||
<?php unset($_SESSION['success']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<form method="post" style="max-width: 400px; 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-bottom: 1.5rem;">
|
|
||||||
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<input type="password" id="password" name="password"
|
|
||||||
placeholder="Введите пароль"
|
|
||||||
style="width: 100%;"
|
|
||||||
required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="contrast" style="width: 100%;">
|
|
||||||
🔑 Войти
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div style="text-align: center; margin-top: 1rem;">
|
|
||||||
<p>Нет аккаунта? <a href="register.php">Зарегистрируйтесь здесь</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
20
logout.php
20
logout.php
|
|
@ -1,20 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
|
|
||||||
// Очищаем все данные сессии
|
|
||||||
$_SESSION = [];
|
|
||||||
|
|
||||||
|
|
||||||
if (ini_get("session.use_cookies")) {
|
|
||||||
$params = session_get_cookie_params();
|
|
||||||
setcookie(session_name(), '', time() - 42000,
|
|
||||||
$params["path"], $params["domain"],
|
|
||||||
$params["secure"], $params["httponly"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
session_destroy();
|
|
||||||
|
|
||||||
// Редирект на страницу входа
|
|
||||||
redirect('login.php');
|
|
||||||
?>
|
|
||||||
368
models/Book.php
368
models/Book.php
|
|
@ -1,6 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
// models/Book.php
|
// models/Book.php
|
||||||
require_once __DIR__ . '/../includes/parsedown/ParsedownExtra.php';
|
|
||||||
class Book {
|
class Book {
|
||||||
private $pdo;
|
private $pdo;
|
||||||
|
|
||||||
|
|
@ -41,11 +40,10 @@ class Book {
|
||||||
public function create($data) {
|
public function create($data) {
|
||||||
$share_token = bin2hex(random_bytes(16));
|
$share_token = bin2hex(random_bytes(16));
|
||||||
$published = isset($data['published']) ? (int)$data['published'] : 0;
|
$published = isset($data['published']) ? (int)$data['published'] : 0;
|
||||||
$editor_type = $data['editor_type'] ?? 'markdown';
|
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare("
|
$stmt = $this->pdo->prepare("
|
||||||
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, editor_type)
|
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
");
|
");
|
||||||
return $stmt->execute([
|
return $stmt->execute([
|
||||||
$data['title'],
|
$data['title'],
|
||||||
|
|
@ -55,28 +53,29 @@ class Book {
|
||||||
$data['series_id'] ?? null,
|
$data['series_id'] ?? null,
|
||||||
$data['sort_order_in_series'] ?? null,
|
$data['sort_order_in_series'] ?? null,
|
||||||
$share_token,
|
$share_token,
|
||||||
$published,
|
$published
|
||||||
$editor_type
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update($id, $data) {
|
public function update($id, $data) {
|
||||||
$published = isset($data['published']) ? (int)$data['published'] : 0;
|
$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;
|
||||||
|
$sort_order_in_series = !empty($data['sort_order_in_series']) ? (int)$data['sort_order_in_series'] : null;
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare("
|
$stmt = $this->pdo->prepare("
|
||||||
UPDATE books
|
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 = ?
|
WHERE id = ? AND user_id = ?
|
||||||
");
|
");
|
||||||
return $stmt->execute([
|
return $stmt->execute([
|
||||||
$data['title'],
|
$data['title'],
|
||||||
$data['description'] ?? null,
|
$data['description'] ?? null,
|
||||||
$data['genre'] ?? null,
|
$data['genre'] ?? null,
|
||||||
$data['series_id'] ?? null,
|
$series_id, // Теперь это либо integer, либо NULL
|
||||||
$data['sort_order_in_series'] ?? null,
|
$sort_order_in_series, // Теперь это либо integer, либо NULL
|
||||||
$published,
|
$published,
|
||||||
$editor_type,
|
|
||||||
$id,
|
$id,
|
||||||
$data['user_id']
|
$data['user_id']
|
||||||
]);
|
]);
|
||||||
|
|
@ -102,6 +101,39 @@ class Book {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
public function userOwnsBook($book_id, $user_id) {
|
||||||
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE id = ? AND user_id = ?");
|
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE id = ? AND user_id = ?");
|
||||||
|
|
@ -116,15 +148,7 @@ class Book {
|
||||||
return $success ? $new_token : false;
|
return $success ? $new_token : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getPublishedChapters($book_id) {
|
|
||||||
$stmt = $this->pdo->prepare("
|
|
||||||
SELECT * FROM chapters
|
|
||||||
WHERE book_id = ? AND status = 'published'
|
|
||||||
ORDER BY sort_order, created_at
|
|
||||||
");
|
|
||||||
$stmt->execute([$book_id]);
|
|
||||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateCover($book_id, $filename) {
|
public function updateCover($book_id, $filename) {
|
||||||
$stmt = $this->pdo->prepare("UPDATE books SET cover_image = ? WHERE id = ?");
|
$stmt = $this->pdo->prepare("UPDATE books SET cover_image = ? WHERE id = ?");
|
||||||
|
|
@ -168,24 +192,6 @@ class Book {
|
||||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
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) {
|
public function getBookStats($book_id, $only_published_chapters = false) {
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT
|
SELECT
|
||||||
|
|
@ -204,48 +210,35 @@ class Book {
|
||||||
$stmt->execute([$book_id]);
|
$stmt->execute([$book_id]);
|
||||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function convertChaptersContent($book_id, $from_editor, $to_editor) {
|
public function getBooksNotInSeries($user_id, $series_id = null) {
|
||||||
|
$sql = "SELECT * FROM books
|
||||||
|
WHERE user_id = ?
|
||||||
|
AND (series_id IS NULL OR series_id != ? OR series_id = 0)";
|
||||||
|
$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 {
|
try {
|
||||||
$this->pdo->beginTransaction();
|
$this->pdo->beginTransaction();
|
||||||
|
|
||||||
$chapters = $this->getAllChapters($book_id);
|
foreach ($new_order as $order => $book_id) {
|
||||||
|
$stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
|
||||||
foreach ($chapters as $chapter) {
|
$stmt->execute([$order + 1, $book_id, $series_id]);
|
||||||
$converted_content = $this->convertContent(
|
|
||||||
$chapter['content'],
|
|
||||||
$from_editor,
|
|
||||||
$to_editor
|
|
||||||
);
|
|
||||||
|
|
||||||
$this->updateChapterContent($chapter['id'], $converted_content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->pdo->commit();
|
$this->pdo->commit();
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->pdo->rollBack();
|
$this->pdo->rollBack();
|
||||||
error_log("Error converting chapters: " . $e->getMessage());
|
error_log("Ошибка при обновлении порядка книг: " . $e->getMessage());
|
||||||
return false;
|
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 countWords($text) {
|
private function countWords($text) {
|
||||||
$text = strip_tags($text);
|
$text = strip_tags($text);
|
||||||
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
|
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
|
||||||
|
|
@ -253,247 +246,6 @@ class Book {
|
||||||
$words = array_filter($words);
|
$words = array_filter($words);
|
||||||
return count($words);
|
return count($words);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function convertContent($content, $from_editor, $to_editor) {
|
|
||||||
if ($from_editor === $to_editor) {
|
|
||||||
return $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if ($from_editor === 'markdown' && $to_editor === 'html') {
|
|
||||||
// Markdown to HTML с улучшенной обработкой абзацев
|
|
||||||
return $this->markdownToHtmlWithParagraphs($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 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;
|
|
||||||
|
|
||||||
// Обрабатываем абзацы - заменяем на двойные переносы строк
|
|
||||||
$markdown = preg_replace('/<p[^>]*>(.*?)<\/p>/is', "$1\n\n", $markdown);
|
|
||||||
|
|
||||||
// Обрабатываем разрывы строк
|
|
||||||
$markdown = preg_replace('/<br[^>]*>\s*<\/br[^>]*>/i', "\n", $markdown);
|
|
||||||
$markdown = preg_replace('/<br[^>]*>/i', " \n", $markdown); // Два пробела для Markdown разрыва
|
|
||||||
|
|
||||||
// Заголовки
|
|
||||||
$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);
|
|
||||||
|
|
||||||
// Жирный текст
|
|
||||||
$markdown = preg_replace('/<strong[^>]*>(.*?)<\/strong>/is', '**$1**', $markdown);
|
|
||||||
$markdown = preg_replace('/<b[^>]*>(.*?)<\/b>/is', '**$1**', $markdown);
|
|
||||||
|
|
||||||
// Курсив
|
|
||||||
$markdown = preg_replace('/<em[^>]*>(.*?)<\/em>/is', '*$1*', $markdown);
|
|
||||||
$markdown = preg_replace('/<i[^>]*>(.*?)<\/i>/is', '*$1*', $markdown);
|
|
||||||
|
|
||||||
// Подчеркивание (не стандартно в Markdown, но обрабатываем)
|
|
||||||
$markdown = preg_replace('/<u[^>]*>(.*?)<\/u>/is', '<u>$1</u>', $markdown);
|
|
||||||
|
|
||||||
// Зачеркивание
|
|
||||||
$markdown = preg_replace('/<s[^>]*>(.*?)<\/s>/is', '~~$1~~', $markdown);
|
|
||||||
$markdown = preg_replace('/<strike[^>]*>(.*?)<\/strike>/is', '~~$1~~', $markdown);
|
|
||||||
$markdown = preg_replace('/<del[^>]*>(.*?)<\/del>/is', '~~$1~~', $markdown);
|
|
||||||
|
|
||||||
// Списки
|
|
||||||
$markdown = preg_replace('/<li[^>]*>(.*?)<\/li>/is', '- $1', $markdown);
|
|
||||||
$markdown = preg_replace('/<ul[^>]*>(.*?)<\/ul>/is', "$1\n", $markdown);
|
|
||||||
$markdown = preg_replace('/<ol[^>]*>(.*?)<\/ol>/is', "$1\n", $markdown);
|
|
||||||
|
|
||||||
// Блочные цитаты
|
|
||||||
$markdown = preg_replace('/<blockquote[^>]*>(.*?)<\/blockquote>/is', "> $1\n", $markdown);
|
|
||||||
|
|
||||||
// Код
|
|
||||||
$markdown = preg_replace('/<code[^>]*>(.*?)<\/code>/is', '`$1`', $markdown);
|
|
||||||
$markdown = preg_replace('/<pre[^>]*>(.*?)<\/pre>/is', "```\n$1\n```", $markdown);
|
|
||||||
|
|
||||||
// Ссылки
|
|
||||||
$markdown = preg_replace('/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/is', '[$2]($1)', $markdown);
|
|
||||||
|
|
||||||
// Изображения
|
|
||||||
$markdown = preg_replace('/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/is', '', $markdown);
|
|
||||||
|
|
||||||
// Удаляем все остальные HTML-теги
|
|
||||||
$markdown = strip_tags($markdown);
|
|
||||||
|
|
||||||
// Чистим лишние пробелы и переносы
|
|
||||||
$markdown = preg_replace('/\n\s*\n\s*\n/', "\n\n", $markdown);
|
|
||||||
$markdown = preg_replace('/^\s+|\s+$/m', '', $markdown); // Trim каждой строки
|
|
||||||
$markdown = trim($markdown);
|
|
||||||
|
|
||||||
return $markdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
@ -97,5 +97,32 @@ class Chapter {
|
||||||
return $stmt->fetch() !== false;
|
return $stmt->fetch() !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function getPublishedChapters($book_id) {
|
||||||
|
$stmt = $this->pdo->prepare("
|
||||||
|
SELECT * FROM chapters
|
||||||
|
WHERE book_id = ? AND status = 'published'
|
||||||
|
ORDER BY sort_order, created_at
|
||||||
|
");
|
||||||
|
$stmt->execute([$book_id]);
|
||||||
|
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
// }
|
||||||
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
@ -66,17 +66,17 @@ class User {
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$stmt = $this->pdo->prepare($sql);
|
||||||
return $stmt->execute($params);
|
return $stmt->execute($params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function delete($id) {
|
||||||
|
$stmt = $this->pdo->prepare("DELETE FROM users WHERE id = ?");
|
||||||
|
return $stmt->execute([$id]);
|
||||||
|
}
|
||||||
|
|
||||||
public function updateStatus($id, $is_active) {
|
public function updateStatus($id, $is_active) {
|
||||||
$stmt = $this->pdo->prepare("UPDATE users SET is_active = ? WHERE id = ?");
|
$stmt = $this->pdo->prepare("UPDATE users SET is_active = ? WHERE id = ?");
|
||||||
return $stmt->execute([$is_active, $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 updateLastLogin($id) {
|
public function updateLastLogin($id) {
|
||||||
$stmt = $this->pdo->prepare("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?");
|
$stmt = $this->pdo->prepare("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
return $stmt->execute([$id]);
|
return $stmt->execute([$id]);
|
||||||
|
|
|
||||||
180
register.php
180
register.php
|
|
@ -1,180 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
|
|
||||||
// Если пользователь уже авторизован И он не администратор (ID != 1), перенаправляем на dashboard
|
|
||||||
if (is_logged_in() && $_SESSION['user_id'] != 1) {
|
|
||||||
redirect('dashboard.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$error = '';
|
|
||||||
$success = '';
|
|
||||||
|
|
||||||
// Проверяем, является ли текущий пользователь администратором
|
|
||||||
$is_admin = is_logged_in() && $_SESSION['user_id'] == 1;
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$error = "Ошибка безопасности";
|
|
||||||
} else {
|
|
||||||
$username = trim($_POST['username'] ?? '');
|
|
||||||
$display_name = trim($_POST['display_name'] ?? '');
|
|
||||||
$email = trim($_POST['email'] ?? '');
|
|
||||||
$password = $_POST['password'] ?? '';
|
|
||||||
$confirm_password = $_POST['confirm_password'] ?? '';
|
|
||||||
|
|
||||||
// Валидация
|
|
||||||
if (empty($username) || empty($password)) {
|
|
||||||
$error = 'Имя пользователя и пароль обязательны для заполнения';
|
|
||||||
} elseif ($password !== $confirm_password) {
|
|
||||||
$error = 'Пароли не совпадают';
|
|
||||||
} elseif (strlen($password) < 6) {
|
|
||||||
$error = 'Пароль должен содержать не менее 6 символов';
|
|
||||||
} elseif (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
|
|
||||||
$error = 'Имя пользователя может содержать только латинские буквы, цифры и символ подчеркивания';
|
|
||||||
} else {
|
|
||||||
$userModel = new User($pdo);
|
|
||||||
|
|
||||||
// Проверяем, не занят ли username
|
|
||||||
if ($userModel->findByUsername($username)) {
|
|
||||||
$error = 'Это имя пользователя уже занято';
|
|
||||||
} elseif (!empty($email) && $userModel->findByEmail($email)) {
|
|
||||||
$error = 'Этот email уже используется';
|
|
||||||
} else {
|
|
||||||
// Подготавливаем данные для создания пользователя
|
|
||||||
$user_data = [
|
|
||||||
'username' => $username,
|
|
||||||
'display_name' => $display_name ?: $username,
|
|
||||||
'email' => $email,
|
|
||||||
'password' => $password
|
|
||||||
];
|
|
||||||
|
|
||||||
// Если пользователя создает администратор - сразу активный
|
|
||||||
// Если пользователь регистрируется сам - требует активации
|
|
||||||
if ($is_admin) {
|
|
||||||
$user_data['is_active'] = 1;
|
|
||||||
} else {
|
|
||||||
$user_data['is_active'] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаем пользователя
|
|
||||||
$success = $userModel->create($user_data);
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
if ($is_admin) {
|
|
||||||
$_SESSION['success'] = 'Пользователь успешно создан и активирован';
|
|
||||||
redirect('admin/users.php');
|
|
||||||
} else {
|
|
||||||
$_SESSION['success'] = 'Регистрация прошла успешно. Ваш аккаунт ожидает активации администратором.';
|
|
||||||
redirect('login.php');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$error = 'Произошла ошибка при регистрации. Попробуйте еще раз.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$page_title = $is_admin ? 'Добавление пользователя' : 'Регистрация';
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<h1><?= $is_admin ? 'Добавление пользователя' : 'Регистрация' ?></h1>
|
|
||||||
|
|
||||||
<?php if ($error): ?>
|
|
||||||
<div class="alert alert-error">
|
|
||||||
<?= e($error) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($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: 1.5rem;">
|
|
||||||
<label for="confirm_password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Подтверждение пароля *
|
|
||||||
</label>
|
|
||||||
<input type="password" id="confirm_password" name="confirm_password"
|
|
||||||
placeholder="Повторите пароль"
|
|
||||||
style="width: 100%;"
|
|
||||||
required
|
|
||||||
minlength="6">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px;">
|
|
||||||
<button type="submit" class="contrast" style="flex: 1;">
|
|
||||||
<?= $is_admin ? '👥 Добавить пользователя' : '📝 Зарегистрироваться' ?>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<?php if ($is_admin): ?>
|
|
||||||
<a href="admin/users.php" class="secondary" style="display: flex; align-items: center; justify-content: center; padding: 0.75rem; text-decoration: none;">
|
|
||||||
❌ Отмена
|
|
||||||
</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php if (!$is_admin): ?>
|
|
||||||
<div style="text-align: center; margin-top: 1rem;">
|
|
||||||
<p>Уже есть аккаунт? <a href="login.php">Войдите здесь</a></p>
|
|
||||||
<?php if (!$is_admin): ?>
|
|
||||||
<p style="color: #666; font-size: 0.9em; margin-top: 0.5rem;">
|
|
||||||
<strong>Примечание:</strong> После регистрации ваш аккаунт должен быть активирован администратором.
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
95
series.php
95
series.php
|
|
@ -1,95 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$seriesModel = new Series($pdo);
|
|
||||||
$series = $seriesModel->findByUser($user_id);
|
|
||||||
|
|
||||||
// Получаем статистику для каждой серии отдельно
|
|
||||||
foreach ($series as &$ser) {
|
|
||||||
$stats = $seriesModel->getSeriesStats($ser['id'], $user_id);
|
|
||||||
$ser['book_count'] = $stats['book_count'] ?? 0;
|
|
||||||
$ser['total_words'] = $stats['total_words'] ?? 0;
|
|
||||||
}
|
|
||||||
unset($ser);
|
|
||||||
|
|
||||||
$page_title = "Мои серии книг";
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<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($series) ?></h2>
|
|
||||||
<a href="series_edit.php" class="action-button primary">➕ Новая серия</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (empty($series)): ?>
|
|
||||||
<article style="text-align: center; padding: 2rem;">
|
|
||||||
<h3>У вас пока нет серий книг</h3>
|
|
||||||
<p>Создайте свою первую серию для организации книг!</p>
|
|
||||||
<a href="series_edit.php" role="button">📚 Создать первую серию</a>
|
|
||||||
</article>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="grid">
|
|
||||||
<?php foreach ($series as $ser): ?>
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<h3>
|
|
||||||
<?= e($ser['title']) ?>
|
|
||||||
<div style="display: flex; gap: 3px; float:right;">
|
|
||||||
<a href="series_edit.php?id=<?= $ser['id'] ?>" class="compact-button secondary" title="Редактировать серию">
|
|
||||||
✏️
|
|
||||||
</a>
|
|
||||||
<a href="view_series.php?id=<?= $ser['id'] ?>" class="compact-button secondary" title="Просмотреть серию">
|
|
||||||
👁️
|
|
||||||
</a>
|
|
||||||
<form method="post" action="series_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить серию «<?= e($ser['title']) ?>»? Книги останутся, но будут убраны из серии.');">
|
|
||||||
<input type="hidden" name="series_id" value="<?= $ser['id'] ?>">
|
|
||||||
<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>
|
|
||||||
</h3>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<?php if ($ser['description']): ?>
|
|
||||||
<p><?= e(mb_strimwidth($ser['description'], 0, 200, '...')) ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<footer>
|
|
||||||
<div>
|
|
||||||
<small>
|
|
||||||
Книг: <?= $ser['book_count'] ?> |
|
|
||||||
Слов: <?= $ser['total_words'] ?>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 0.5rem;">
|
|
||||||
<a href="view_series.php?id=<?= $ser['id'] ?>" class="adaptive-button secondary">
|
|
||||||
📖 Смотреть книги
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
||||||
$_SESSION['error'] = "Неверный метод запроса";
|
|
||||||
redirect('series.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect('series.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$series_id = $_POST['series_id'] ?? null;
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
|
|
||||||
if (!$series_id) {
|
|
||||||
$_SESSION['error'] = "Не указана серия для удаления";
|
|
||||||
redirect('series.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$seriesModel = new Series($pdo);
|
|
||||||
|
|
||||||
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
|
|
||||||
$_SESSION['error'] = "У вас нет доступа к этой серии";
|
|
||||||
redirect('series.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$series = $seriesModel->findById($series_id);
|
|
||||||
|
|
||||||
if ($seriesModel->delete($series_id, $user_id)) {
|
|
||||||
$_SESSION['success'] = "Серия «" . e($series['title']) . "» успешно удалена";
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = "Ошибка при удалении серии";
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect('series.php');
|
|
||||||
?>
|
|
||||||
179
series_edit.php
179
series_edit.php
|
|
@ -1,179 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$seriesModel = new Series($pdo);
|
|
||||||
|
|
||||||
$series_id = $_GET['id'] ?? null;
|
|
||||||
$series = null;
|
|
||||||
$is_edit = false;
|
|
||||||
|
|
||||||
if ($series_id) {
|
|
||||||
$series = $seriesModel->findById($series_id);
|
|
||||||
if (!$series || !$seriesModel->userOwnsSeries($series_id, $user_id)) {
|
|
||||||
$_SESSION['error'] = "Серия не найдена или у вас нет доступа";
|
|
||||||
redirect('series.php');
|
|
||||||
}
|
|
||||||
$is_edit = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
redirect($is_edit ? "series_edit.php?id=$series_id" : 'series_edit.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = trim($_POST['title'] ?? '');
|
|
||||||
$description = trim($_POST['description'] ?? '');
|
|
||||||
|
|
||||||
if (empty($title)) {
|
|
||||||
$_SESSION['error'] = "Название серии обязательно";
|
|
||||||
} else {
|
|
||||||
$data = [
|
|
||||||
'title' => $title,
|
|
||||||
'description' => $description,
|
|
||||||
'user_id' => $user_id
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($is_edit) {
|
|
||||||
$success = $seriesModel->update($series_id, $data);
|
|
||||||
$message = $success ? "Серия успешно обновлена" : "Ошибка при обновлении серии";
|
|
||||||
} else {
|
|
||||||
$success = $seriesModel->create($data);
|
|
||||||
$message = $success ? "Серия успешно создана" : "Ошибка при создании серии";
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
$new_series_id = $pdo->lastInsertId();
|
|
||||||
redirect("series_edit.php?id=$new_series_id");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($success) {
|
|
||||||
$_SESSION['success'] = $message;
|
|
||||||
redirect('series.php');
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = $message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$page_title = $is_edit ? "Редактирование серии" : "Создание новой серии";
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<h1><?= $is_edit ? "Редактирование серии" : "Создание новой серии" ?></h1>
|
|
||||||
|
|
||||||
<?php if (isset($_SESSION['error'])): ?>
|
|
||||||
<div class="alert alert-error">
|
|
||||||
<?= e($_SESSION['error']) ?>
|
|
||||||
<?php unset($_SESSION['error']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<form method="post">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
|
|
||||||
<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'] ?? $_POST['title'] ?? '') ?>"
|
|
||||||
placeholder="Введите название серии"
|
|
||||||
style="width: 100%; margin-bottom: 1.5rem;"
|
|
||||||
required>
|
|
||||||
|
|
||||||
<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'] ?? $_POST['description'] ?? '') ?></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
||||||
<button type="submit" class="contrast">
|
|
||||||
<?= $is_edit ? '💾 Сохранить изменения' : '📚 Создать серию' ?>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<a href="series.php" role="button" class="secondary">
|
|
||||||
❌ Отмена
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php if ($is_edit): ?>
|
|
||||||
<div style="margin-top: 3rem;">
|
|
||||||
<h3>Книги в этой серии</h3>
|
|
||||||
|
|
||||||
<?php
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
$books_in_series = $bookModel->findBySeries($series_id);
|
|
||||||
|
|
||||||
// Вычисляем общую статистику
|
|
||||||
$total_chapters = 0;
|
|
||||||
$total_words = 0;
|
|
||||||
foreach ($books_in_series as $book) {
|
|
||||||
$stats = $bookModel->getBookStats($book['id']);
|
|
||||||
$total_chapters += $stats['chapter_count'] ?? 0;
|
|
||||||
$total_words += $stats['total_words'] ?? 0;
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
<?php if (empty($books_in_series)): ?>
|
|
||||||
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px;">
|
|
||||||
<p>В этой серии пока нет книг.</p>
|
|
||||||
<a href="books.php" 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: #666;"><?= 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="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary">
|
|
||||||
Редактировать
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top: 1rem; padding: 0.5rem; background: #f5f5f5; border-radius: 3px;">
|
|
||||||
<strong>Статистика серии:</strong>
|
|
||||||
Книг: <?= count($books_in_series) ?> |
|
|
||||||
Глав: <?= $total_chapters ?> |
|
|
||||||
Слов: <?= $total_words ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
276
view_book.php
276
view_book.php
|
|
@ -1,276 +0,0 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
|
||||||
|
|
||||||
$Parsedown = new ParsedownExtra();
|
|
||||||
|
|
||||||
// Получаем книгу по share_token или id
|
|
||||||
$share_token = $_GET['share_token'] ?? null;
|
|
||||||
$book_id = $_GET['id'] ?? null;
|
|
||||||
|
|
||||||
$bookModel = new Book($pdo);
|
|
||||||
$book = null;
|
|
||||||
|
|
||||||
if ($share_token) {
|
|
||||||
$book = $bookModel->findByShareToken($share_token);
|
|
||||||
} elseif ($book_id) {
|
|
||||||
$book = $bookModel->findById($book_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$book) {
|
|
||||||
http_response_code(404);
|
|
||||||
$page_title = "Книга не найдена";
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
<div class="container">
|
|
||||||
<article style="text-align: center; padding: 2rem;">
|
|
||||||
<h1>Книга не найдена</h1>
|
|
||||||
<p>Запрошенная книга не существует или была удалена.</p>
|
|
||||||
<a href="index.php" role="button">На главную</a>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
<?php
|
|
||||||
include 'views/footer.php';
|
|
||||||
exit;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Получаем опубликованные главы
|
|
||||||
$chapters = $bookModel->getPublishedChapters($book['id']);
|
|
||||||
$total_words = array_sum(array_column($chapters, 'word_count'));
|
|
||||||
|
|
||||||
// Получаем информацию об авторе
|
|
||||||
$stmt = $pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
|
|
||||||
$stmt->execute([$book['user_id']]);
|
|
||||||
$author_info = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
||||||
$author_name = $author_info['display_name'] ?? $author_info['username'] ?? 'Неизвестный автор';
|
|
||||||
|
|
||||||
$page_title = $book['title'];
|
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<article style="max-width: 800px; margin: 0 auto;">
|
|
||||||
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid #eee; padding-bottom: 1rem;">
|
|
||||||
<?php if ($book['cover_image']): ?>
|
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
|
||||||
alt="<?= e($book['title']) ?>"
|
|
||||||
style="max-width: 200px; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"
|
|
||||||
onerror="this.style.display='none'">
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<h1 style="margin-bottom: 0.5rem;"><?= e($book['title']) ?></h1>
|
|
||||||
<?php if ($book['series_id']): ?>
|
|
||||||
<?php
|
|
||||||
$series_stmt = $pdo->prepare("SELECT id, title FROM series WHERE id = ?");
|
|
||||||
$series_stmt->execute([$book['series_id']]);
|
|
||||||
$series = $series_stmt->fetch();
|
|
||||||
?>
|
|
||||||
<?php if ($series): ?>
|
|
||||||
<p style="color: #666; margin-bottom: 0.5rem;">
|
|
||||||
📚 Часть серии:
|
|
||||||
<a href="view_series.php?id=<?= $series['id'] ?>" style="color: #007bff;">
|
|
||||||
<?= e($series['title']) ?>
|
|
||||||
<?php if ($book['sort_order_in_series']): ?>
|
|
||||||
(Книга <?= $book['sort_order_in_series'] ?>)
|
|
||||||
<?php endif; ?>
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;"><?= e($author_name) ?></p>
|
|
||||||
|
|
||||||
<?php if ($book['genre']): ?>
|
|
||||||
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
|
|
||||||
Жанр: <?= e($book['genre']) ?>
|
|
||||||
</p>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if ($book['description']): ?>
|
|
||||||
<div style="background: #f8f9fa; padding: 1rem; border-radius: 5px; margin: 1rem 0;">
|
|
||||||
<p style="margin: 0; font-size: 1.1em;"><?= nl2br(e($book['description'])) ?></p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
|
|
||||||
<span>Глав: <?= count($chapters) ?></span>
|
|
||||||
<span>Слов: <?= $total_words ?></span>
|
|
||||||
<?php if (is_logged_in() && $book['user_id'] == $_SESSION['user_id']): ?>
|
|
||||||
<span>|</span>
|
|
||||||
<a href="books.php" style="color: #007bff;">Вернуться к редактированию</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Интерактивное оглавление -->
|
|
||||||
<?php if (!empty($chapters)): ?>
|
|
||||||
<div style="margin: 2rem 0; padding: 1.5rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;">
|
|
||||||
<h3 style="margin-top: 0; color: #007bff;">📖 Оглавление</h3>
|
|
||||||
<a name="start"></a>
|
|
||||||
<div style="columns: 1;">
|
|
||||||
<?php foreach ($chapters as $index => $chapter): ?>
|
|
||||||
<div style="break-inside: avoid; margin-bottom: 0.5rem;">
|
|
||||||
<a href="#chapter-<?= $chapter['id'] ?>"
|
|
||||||
style="text-decoration: none; color: #333; display: block; padding: 0.3rem 0;"
|
|
||||||
onmouseover="this.style.color='#007bff'"
|
|
||||||
onmouseout="this.style.color='#333'">
|
|
||||||
<span style="color: #666; font-size: 0.9em;"><?= $index + 1 ?>.</span>
|
|
||||||
<?= e($chapter['title']) ?>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div style="margin: 1rem 0; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
|
|
||||||
<h3 style="margin: 0 0 0.5rem 0;">Экспорт книги</h3>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
|
|
||||||
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=pdf" class="adaptive-button secondary" target="_blank">
|
|
||||||
📄 PDF
|
|
||||||
</a>
|
|
||||||
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=docx" class="adaptive-button secondary" target="_blank">
|
|
||||||
📝 DOCX
|
|
||||||
</a>
|
|
||||||
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=html" class="adaptive-button secondary" target="_blank">
|
|
||||||
🌐 HTML
|
|
||||||
</a>
|
|
||||||
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=txt" class="adaptive-button secondary" target="_blank">
|
|
||||||
📄 TXT
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style="margin-top: 0.5rem; font-size: 0.9em; color: #666;">
|
|
||||||
<strong>Примечание:</strong> Экспортируются только опубликованные главы
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (empty($chapters)): ?>
|
|
||||||
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
|
|
||||||
<h3>В этой книге пока нет опубликованных глав</h3>
|
|
||||||
<p>Автор еще не опубликовал ни одной главы</p>
|
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
|
||||||
<div class="book-content">
|
|
||||||
<?php foreach ($chapters as $index => $chapter): ?>
|
|
||||||
<section class="chapter" id="chapter-<?= $chapter['id'] ?>" style="margin-bottom: 3rem; scroll-margin-top: 2rem;">
|
|
||||||
<h2 style="border-bottom: 1px solid #eee; padding-bottom: 0.5rem;">
|
|
||||||
<?= e($chapter['title']) ?>
|
|
||||||
<a href="#start" style="text-decoration: none; color: #666; font-size: 0.8em; margin-left: 1rem;">🔗</a>
|
|
||||||
</h2>
|
|
||||||
<div class="chapter-content" style="line-height: 1.6; font-size: 1.1em;">
|
|
||||||
<?php if ($book['editor_type'] == 'markdown'): ?>
|
|
||||||
<?= $Parsedown->text($chapter['content']) ?>
|
|
||||||
<?php else: ?>
|
|
||||||
<?= $chapter['content'] ?>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div style="margin-top: 1rem; padding-top: 0.5rem; border-top: 1px dashed #eee; color: #666; font-size: 0.9em;">
|
|
||||||
<small>Обновлено: <?= date('d.m.Y', strtotime($chapter['updated_at'])) ?></small>
|
|
||||||
<a href="#top" style="float: right; color: #007bff; text-decoration: none;">↑ Наверх</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid #eee; text-align: center;">
|
|
||||||
<p style="color: #666;">
|
|
||||||
Книга создана в <?= e(APP_NAME) ?> •
|
|
||||||
Автор: <?= e($author_name) ?> •
|
|
||||||
<?= date('d.m.Y', strtotime($book['created_at'])) ?>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.book-content {
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content h1, .book-content h2, .book-content h3, .book-content h4, .book-content h5, .book-content h6 {
|
|
||||||
margin-top: 2rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content p {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
text-align: justify;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content blockquote {
|
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
padding-left: 1rem;
|
|
||||||
margin-left: 0;
|
|
||||||
color: #555;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content code {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 2px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content pre {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 5px;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content ul, .book-content ol {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
padding-left: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content th, .book-content td {
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content th {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.book-content {
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content h1 {
|
|
||||||
font-size: 1.6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content h2 {
|
|
||||||
font-size: 1.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content h3 {
|
|
||||||
font-size: 1.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-content pre {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div[style*="columns: 2"] {
|
|
||||||
columns: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -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'; ?>
|
||||||
|
|
@ -1,88 +1,35 @@
|
||||||
<?php
|
<?php include 'views/layouts/header.php'; ?>
|
||||||
require_once '../config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
// Проверяем права администратора (простая проверка - первый пользователь считается администратором)
|
<div class="container" style="margin:0; width: auto;">
|
||||||
if ($_SESSION['user_id'] != 1) {
|
|
||||||
$_SESSION['error'] = "У вас нет доступа к этой странице";
|
|
||||||
redirect('../dashboard.php');
|
|
||||||
}
|
|
||||||
|
|
||||||
$userModel = new User($pdo);
|
|
||||||
$users = $userModel->findAll();
|
|
||||||
|
|
||||||
// Обработка действий
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$_SESSION['error'] = "Ошибка безопасности";
|
|
||||||
} else {
|
|
||||||
$action = $_POST['action'] ?? '';
|
|
||||||
$user_id = $_POST['user_id'] ?? null;
|
|
||||||
|
|
||||||
if ($user_id && $user_id != $_SESSION['user_id']) { // Нельзя изменять себя
|
|
||||||
switch ($action) {
|
|
||||||
case 'toggle_active':
|
|
||||||
$user = $userModel->findById($user_id);
|
|
||||||
if ($user) {
|
|
||||||
$new_status = $user['is_active'] ? 0 : 1;
|
|
||||||
if ($userModel->updateStatus($user_id, $new_status)) {
|
|
||||||
$_SESSION['success'] = 'Статус пользователя обновлен';
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = 'Ошибка при обновлении статуса';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'delete':
|
|
||||||
if ($userModel->delete($user_id)) {
|
|
||||||
$_SESSION['success'] = 'Пользователь удален';
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = 'Ошибка при удалении пользователя';
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$_SESSION['error'] = 'Нельзя изменить собственный аккаунт';
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect('users.php');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$page_title = "Управление пользователями";
|
|
||||||
include '../views/header.php';
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<h1>Управление пользователями</h1>
|
<h1>Управление пользователями</h1>
|
||||||
|
|
||||||
<?php if (isset($_SESSION['success'])): ?>
|
<?php if (isset($_SESSION['success'])): ?>
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
<?= e($_SESSION['success']) ?>
|
<?= e($_SESSION['success']) ?>
|
||||||
<?php unset($_SESSION['success']); ?>
|
<?php unset($_SESSION['success']); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (isset($_SESSION['error'])): ?>
|
<?php if (isset($_SESSION['error'])): ?>
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<?= e($_SESSION['error']) ?>
|
<?= e($_SESSION['error']) ?>
|
||||||
<?php unset($_SESSION['error']); ?>
|
<?php unset($_SESSION['error']); ?>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||||
<h2 style="margin: 0;">Всего пользователей: <?= count($users) ?></h2>
|
<h2 style="margin: 0;">Всего пользователей: <?= count($users) ?></h2>
|
||||||
<a href="../register.php" role="button">➕ Добавить пользователя</a>
|
<a href="<?= SITE_URL ?>/admin/add-user" class="action-button primary">➕ Добавить пользователя</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if (empty($users)): ?>
|
<?php if (empty($users)): ?>
|
||||||
<article style="text-align: center; padding: 2rem;">
|
<article style="text-align: center; padding: 2rem;">
|
||||||
<h3>Пользователи не найдены</h3>
|
<h3>Пользователи не найдены</h3>
|
||||||
<p>Зарегистрируйте первого пользователя</p>
|
<p>Зарегистрируйте первого пользователя</p>
|
||||||
<a href="../register.php" role="button">📝 Добавить пользователя</a>
|
<a href="<?= SITE_URL ?>/admin/add-user" role="button">📝 Добавить пользователя</a>
|
||||||
</article>
|
</article>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<div style="overflow-x: auto;">
|
<div style="overflow-x: auto; width:100%;">
|
||||||
<table class="compact-table">
|
<table class="compact-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
@ -100,7 +47,7 @@ include '../views/header.php';
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= $user['id'] ?></td>
|
<td><?= $user['id'] ?></td>
|
||||||
<td>
|
<td>
|
||||||
<strong><a href="/author.php?id=<?= $user['id'] ?>"><?= e($user['username']) ?></a></strong>
|
<strong><a href="<?= SITE_URL ?>/author/<?= $user['id'] ?>"><?= e($user['username']) ?></a></strong>
|
||||||
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
||||||
<br><small style="color: #666;">(Вы)</small>
|
<br><small style="color: #666;">(Вы)</small>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
@ -121,18 +68,14 @@ include '../views/header.php';
|
||||||
<td>
|
<td>
|
||||||
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
||||||
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
|
||||||
<form method="post" style="display: inline;">
|
<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() ?>">
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
<input type="hidden" name="user_id" value="<?= $user['id'] ?>">
|
|
||||||
<input type="hidden" name="action" value="toggle_active">
|
|
||||||
<button type="submit" class="compact-button secondary" title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
|
<button type="submit" class="compact-button secondary" title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
|
||||||
<?= $user['is_active'] ? '⏸️' : '▶️' ?>
|
<?= $user['is_active'] ? '⏸️' : '▶️' ?>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');">
|
<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() ?>">
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
<input type="hidden" name="user_id" value="<?= $user['id'] ?>">
|
|
||||||
<input type="hidden" name="action" value="delete">
|
|
||||||
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить">
|
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить">
|
||||||
🗑️
|
🗑️
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -150,4 +93,4 @@ include '../views/header.php';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php include '../views/footer.php'; ?>
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
// views/auth/login.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; ?>
|
||||||
|
|
||||||
|
<form method="post" style="max-width: 400px; 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input type="password" id="password" name="password"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
style="width: 100%;"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="contrast" style="width: 100%;">
|
||||||
|
🔑 Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 1rem;">
|
||||||
|
<p>Нет аккаунта? <a href="<?= SITE_URL ?>/register">Зарегистрируйтесь здесь</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
<?php
|
||||||
|
// views/auth/register.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: 400px; 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>
|
||||||
|
</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@example.com"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="contrast" style="width: 100%;">
|
||||||
|
📝 Зарегистрироваться
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 1rem;">
|
||||||
|
<p>Уже есть аккаунт? <a href="<?= SITE_URL ?>/login">Войдите здесь</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
// views/books/create.php
|
||||||
|
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;">
|
||||||
|
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Название книги *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="title" name="title"
|
||||||
|
value="<?= e($_POST['title'] ?? '') ?>"
|
||||||
|
placeholder="Введите название книги"
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;"
|
||||||
|
required>
|
||||||
|
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Жанр
|
||||||
|
</label>
|
||||||
|
<input type="text" id="genre" name="genre"
|
||||||
|
value="<?= e($_POST['genre'] ?? '') ?>"
|
||||||
|
placeholder="Например: Фантастика, Роман, Детектив..."
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;">
|
||||||
|
|
||||||
|
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Серия
|
||||||
|
</label>
|
||||||
|
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
|
||||||
|
<option value="">-- Без серии --</option>
|
||||||
|
<?php foreach ($series as $ser): ?>
|
||||||
|
<option value="<?= $ser['id'] ?>" <?= (($_POST['series_id'] ?? '') == $ser['id']) ? 'selected' : '' ?>>
|
||||||
|
<?= e($ser['title']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<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($_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"
|
||||||
|
<?= (!empty($_POST['published']) && $_POST['published']) ? 'checked' : '' ?>>
|
||||||
|
Опубликовать книгу (показывать на публичной странице автора)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||||
|
<button type="submit" class="contrast">
|
||||||
|
📖 Создать книгу
|
||||||
|
</button>
|
||||||
|
<a href="<?= SITE_URL ?>/books" role="button" class="secondary">
|
||||||
|
❌ Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
<?php
|
||||||
|
// 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() ?>">
|
||||||
|
<div style="max-width: 100%; margin-bottom: 0.5rem;">
|
||||||
|
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Название книги *
|
||||||
|
</label>
|
||||||
|
<input type="text" id="title" name="title"
|
||||||
|
value="<?= e($book['title'] ?? '') ?>"
|
||||||
|
placeholder="Введите название книги"
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;"
|
||||||
|
required>
|
||||||
|
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Жанр
|
||||||
|
</label>
|
||||||
|
<input type="text" id="genre" name="genre"
|
||||||
|
value="<?= e($book['genre'] ?? '') ?>"
|
||||||
|
placeholder="Например: Фантастика, Роман, Детектив..."
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;">
|
||||||
|
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Серия
|
||||||
|
</label>
|
||||||
|
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
|
||||||
|
<option value="">-- Без серии --</option>
|
||||||
|
<?php foreach ($series as $ser): ?>
|
||||||
|
<option value="<?= $ser['id'] ?>" <?= ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '' ?>>
|
||||||
|
<?= e($ser['title']) ?>
|
||||||
|
</option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
<label for="sort_order_in_series" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Порядок в серии
|
||||||
|
</label>
|
||||||
|
<input type="number" id="sort_order_in_series" name="sort_order_in_series"
|
||||||
|
value="<?= e($book['sort_order_in_series'] ?? '') ?>"
|
||||||
|
placeholder="Номер по порядку в серии"
|
||||||
|
min="1"
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;">
|
||||||
|
<!-- Обложка -->
|
||||||
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Обложка книги
|
||||||
|
</label>
|
||||||
|
<?php if (!empty($book['cover_image'])): ?>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<p><strong>Текущая обложка:</strong></p>
|
||||||
|
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||||
|
alt="Обложка"
|
||||||
|
style="max-width: 200px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);">
|
||||||
|
<div style="margin-top: 0.5rem;">
|
||||||
|
<label style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||||
|
<input type="checkbox" name="delete_cover" value="1">
|
||||||
|
Удалить обложку
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<input type="file" id="cover_image" name="cover_image"
|
||||||
|
accept="image/jpeg, image/png, image/gif, image/webp"
|
||||||
|
style="height: 2.6rem;">
|
||||||
|
<small style="color: var(--muted-color);">
|
||||||
|
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
|
||||||
|
Рекомендуемый размер: 300×450 пикселей.
|
||||||
|
</small>
|
||||||
|
<?php if (!empty($cover_error)): ?>
|
||||||
|
<div style="color: #d32f2f; margin-top: 0.5rem;">
|
||||||
|
❌ <?= e($cover_error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<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($book['description'] ?? '') ?></textarea>
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<label for="published">
|
||||||
|
<input type="checkbox" id="published" name="published" value="1"
|
||||||
|
<?= !empty($book['published']) ? 'checked' : '' ?>>
|
||||||
|
Опубликовать книгу (показывать на публичной странице автора)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||||
|
<button type="submit" class="contrast">
|
||||||
|
💾 Сохранить изменения
|
||||||
|
</button>
|
||||||
|
<!-- <a href="<?= SITE_URL ?>/books" role="button" class="secondary">
|
||||||
|
❌ Отмена
|
||||||
|
</a> -->
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<?php if ($book): ?>
|
||||||
|
<div style="margin-top: 0.5rem; padding: 0rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
|
<h3>Публичная ссылка для чтения</h3>
|
||||||
|
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;">
|
||||||
|
<input type="text"
|
||||||
|
id="share-link"
|
||||||
|
value="<?= e(SITE_URL . '/book/' . $book['share_token']) ?>"
|
||||||
|
readonly
|
||||||
|
style="flex: 1; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: white;">
|
||||||
|
<button type="button" onclick="copyShareLink()" class="adaptive-button">
|
||||||
|
📋 Копировать
|
||||||
|
</button>
|
||||||
|
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/regenerate-token" style="display: inline;">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
<button type="submit" class="adaptive-button secondary" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')">
|
||||||
|
🔄 Обновить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 0.5rem; font-size: 0.8em; color: var(--muted-color);">
|
||||||
|
<strong>Примечание:</strong> В публичном просмотре отображаются только главы со статусом "Опубликована"
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.5rem; padding: 0rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
|
<h3>Экспорт книги</h3>
|
||||||
|
<p style="margin-bottom: 0.5rem;">Экспортируйте книгу в различные форматы:</p>
|
||||||
|
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📄 PDF
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📝 DOCX
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
🌐 HTML
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📄 TXT
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
|
||||||
|
<strong>Примечание:</strong> Экспортируются все главы книги (включая черновики)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.5rem;">
|
||||||
|
<h2>Главы этой книги</h2>
|
||||||
|
<div style="display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||||
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="adaptive-button secondary" role="button">
|
||||||
|
📑 Все главы
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary" role="button">
|
||||||
|
✏️ Добавить главу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php if (!empty($chapters)): ?>
|
||||||
|
<div style="overflow-x: auto;">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="text-align: left; padding: 12px 8px;">Название</th>
|
||||||
|
<th style="text-align: left; padding: 12px 8px;">Статус</th>
|
||||||
|
<th style="text-align: left; padding: 12px 8px;">Слов</th>
|
||||||
|
<th style="text-align: left; padding: 12px 8px;">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<?php foreach ($chapters as $chapter): ?>
|
||||||
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||||
|
<td style="padding: 12px 8px;"><?= e($chapter['title']) ?></td>
|
||||||
|
<td style="padding: 12px 8px;">
|
||||||
|
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>">
|
||||||
|
<?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="padding: 12px 8px;"><?= $chapter['word_count'] ?></td>
|
||||||
|
<td style="padding: 12px 8px;">
|
||||||
|
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary">
|
||||||
|
Редактировать
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div style="text-align: center; padding: 0.5rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
|
<p style="margin-bottom: 1rem;">В этой книге пока нет глав.</p>
|
||||||
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary" role="button">
|
||||||
|
✏️ Добавить первую главу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 0.5rem; text-align: center;">
|
||||||
|
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
<button type="submit" class="adaptive-button red-btn">
|
||||||
|
🗑️ Удалить книгу
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
|
// Копирование ссылки для чтения
|
||||||
|
window.copyShareLink = function() {
|
||||||
|
const shareLink = document.getElementById('share-link');
|
||||||
|
shareLink.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
const button = event.target;
|
||||||
|
const originalText = '📋 Копировать';
|
||||||
|
button.textContent = '✅ Скопировано';
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?php
|
||||||
|
// views/books/index.php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1>Мои книги <small style="color: #ccc; font-size:1rem;">(Всего книг: <?= count($books) ?>)</small></h1>
|
||||||
|
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: left; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;">
|
||||||
|
<a href="<?= SITE_URL ?>/books/create" class="action-button primary" role="button">➕ Новая книга</a>
|
||||||
|
<?php if (!empty($books)): ?>
|
||||||
|
<a href="#" onclick="showDeleteAllConfirmation()" class="action-button delete">🗑️ Удалить все книги</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($books)): ?>
|
||||||
|
<article style="text-align: center; padding: 2rem;">
|
||||||
|
<h3>У вас пока нет книг</h3>
|
||||||
|
<p>Создайте свою первую книгу и начните писать!</p>
|
||||||
|
<a href="<?= SITE_URL ?>/books/create" role="button">📖 Создать первую книгу</a>
|
||||||
|
</article>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="books-grid">
|
||||||
|
<?php foreach ($books as $book): ?>
|
||||||
|
<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>
|
||||||
|
</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 green-btn" target="_blank">
|
||||||
|
👁️ Просмотр
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика внизу -->
|
||||||
|
<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'; ?>
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
<?php
|
||||||
|
// views/books/view_public.php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="container" style="padding: 0em; margin: 0em auto; width: 90%;">
|
||||||
|
<article style="margin: 0 auto;">
|
||||||
|
<header style="text-align: center; margin-bottom: 2rem;">
|
||||||
|
<?php if (!empty($book['cover_image'])): ?>
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||||
|
alt="<?= e($book['title']) ?>"
|
||||||
|
style="max-width: 200px; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"
|
||||||
|
onerror="this.style.display='none'">
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h1 style="margin-bottom: 0.5rem;"><?= e($book['title']) ?></h1>
|
||||||
|
|
||||||
|
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
|
||||||
|
Автор: <a href="<?= SITE_URL ?>/author/<?= $book['user_id'] ?>"><?= e($author['display_name']??$author['username']) ?></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if (!empty($book['genre'])): ?>
|
||||||
|
<p style="color: #666; font-style: italic; margin-bottom: 1rem;">
|
||||||
|
<?= e($book['genre']) ?>
|
||||||
|
</p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($book['description'])): ?>
|
||||||
|
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
|
||||||
|
<?= nl2br(e($book['description'])) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div style="display: block; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
|
||||||
|
<span>Глав: <?= count($chapters) ?></span>
|
||||||
|
<span>Слов: <?= array_sum(array_column($chapters, 'word_count')) ?></span>
|
||||||
|
<p>
|
||||||
|
<?php if (!is_logged_in()): ?>
|
||||||
|
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: center;">
|
||||||
|
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📄 PDF
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📝 DOCX
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
🌐 HTML
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📄 TXT
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (is_logged_in()): ?>
|
||||||
|
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: center;">
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📄 PDF
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📝 DOCX
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
🌐 HTML
|
||||||
|
</a>
|
||||||
|
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
|
||||||
|
📄 TXT
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<?php if (empty($chapters)): ?>
|
||||||
|
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
|
<h3>В этой книге пока нет глав</h3>
|
||||||
|
<p>Автор еще не опубликовал содержание книги</p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<h3 style="text-align: center; margin-bottom: 2rem; margin-top: 0em;">Оглавление</h3>
|
||||||
|
<div class="chapters-list">
|
||||||
|
<?php foreach ($chapters as $index => $chapter): ?>
|
||||||
|
|
||||||
|
<h6 style="margin-top: 0; margin-bottom: 0em;">
|
||||||
|
<a href="#chapter-<?= $chapter['id'] ?>" style="text-decoration: none;">
|
||||||
|
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?>
|
||||||
|
</a>
|
||||||
|
</h6>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 2rem 0;">
|
||||||
|
|
||||||
|
<?php foreach ($chapters as $index => $chapter): ?>
|
||||||
|
<div class="chapter-content" id="chapter-<?= $chapter['id'] ?>" style="margin-bottom: 3rem;">
|
||||||
|
<h2 style="border-bottom: 2px solid var(--primary); padding-bottom: 0.5rem;">
|
||||||
|
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div style="margin-top: 1.5rem; line-height: 1.6;">
|
||||||
|
<?= $chapter['content'] ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;">
|
||||||
|
<p style="color: var(--muted-color);">
|
||||||
|
Книга создана в <?= e(APP_NAME) ?> •
|
||||||
|
<?= date('Y') ?>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.chapter-content h1, .chapter-content h2, .chapter-content h3 {
|
||||||
|
margin-top: 1.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-content p {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
text-align: justify;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-content .dialogue {
|
||||||
|
margin-left: 2rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #2c5aa0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-content blockquote {
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: 0;
|
||||||
|
color: #555;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-content code {
|
||||||
|
background: var(--card-background-color);
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-content pre {
|
||||||
|
background: var(--card-background-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-content ul, .chapter-content ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
<?php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1>Новая глава для: <?= e($book['title']) ?></h1>
|
||||||
|
|
||||||
|
<?php if (isset($error) && $error): ?>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" id="chapter-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
|
||||||
|
<div style="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($_POST['title'] ?? '') ?>"
|
||||||
|
placeholder="Введите название главы"
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;"
|
||||||
|
required>
|
||||||
|
|
||||||
|
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Содержание главы *
|
||||||
|
</label>
|
||||||
|
<!-- Контейнер Quill -->
|
||||||
|
<div id="quill-editor"
|
||||||
|
class="writer-editor-container"
|
||||||
|
style="height:500px;"
|
||||||
|
data-content="<?= htmlspecialchars($_POST['content'] ?? '', ENT_QUOTES) ?>">
|
||||||
|
</div>
|
||||||
|
<!-- Скрытый textarea для формы -->
|
||||||
|
<textarea id="content" name="content" 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;">
|
||||||
|
Статус главы
|
||||||
|
</label>
|
||||||
|
<select id="status" name="status" style="width: 100%;">
|
||||||
|
<option value="draft" <?= (($_POST['status'] ?? 'draft') == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
|
||||||
|
<option value="published" <?= (($_POST['status'] ?? '') == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
|
||||||
|
</select>
|
||||||
|
<small style="color: var(--muted-color);">
|
||||||
|
Опубликованные главы видны в публичном доступе
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||||
|
<button type="submit" class="contrast">💾 Сохранить главу</button>
|
||||||
|
<button type="button" onclick="previewChapter()" class="secondary">👁️ Предпросмотр</button>
|
||||||
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary">❌ Отмена</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<link href="/assets/css/quill_reset.css" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
function previewChapter() {
|
||||||
|
const form = document.getElementById('chapter-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
const tempForm = document.createElement('form');
|
||||||
|
tempForm.method = 'POST';
|
||||||
|
tempForm.action = '<?= SITE_URL ?>/chapters/preview';
|
||||||
|
tempForm.target = '_blank';
|
||||||
|
tempForm.style.display = 'none';
|
||||||
|
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.name = 'csrf_token';
|
||||||
|
csrfInput.value = '<?= generate_csrf_token() ?>';
|
||||||
|
tempForm.appendChild(csrfInput);
|
||||||
|
|
||||||
|
const contentInput = document.createElement('input');
|
||||||
|
contentInput.name = 'content';
|
||||||
|
contentInput.value = document.getElementById('content').value;
|
||||||
|
tempForm.appendChild(contentInput);
|
||||||
|
|
||||||
|
const titleInput = document.createElement('input');
|
||||||
|
titleInput.name = 'title';
|
||||||
|
titleInput.value = document.getElementById('title').value || 'Предпросмотр главы';
|
||||||
|
tempForm.appendChild(titleInput);
|
||||||
|
|
||||||
|
const editorTypeInput = document.createElement('input');
|
||||||
|
editorTypeInput.name = 'editor_type';
|
||||||
|
editorTypeInput.value = '<?= $book['editor_type'] ?? 'markdown' ?>';
|
||||||
|
tempForm.appendChild(editorTypeInput);
|
||||||
|
|
||||||
|
document.body.appendChild(tempForm);
|
||||||
|
tempForm.submit();
|
||||||
|
document.body.removeChild(tempForm);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/editor.js"></script>
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
<?php
|
||||||
|
// views/chapters/edit.php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1>Редактирование главы: <?= e($chapter['title']) ?></h1>
|
||||||
|
|
||||||
|
<?php if (isset($error) && $error): ?>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post" id="chapter-form">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
|
||||||
|
<div style="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($chapter['title']) ?>"
|
||||||
|
placeholder="Введите название главы"
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;"
|
||||||
|
required>
|
||||||
|
|
||||||
|
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Содержание главы *
|
||||||
|
</label>
|
||||||
|
<!-- Контейнер Quill -->
|
||||||
|
<div id="quill-editor"
|
||||||
|
class="writer-editor-container"
|
||||||
|
style="height:500px;"
|
||||||
|
data-content="<?= htmlspecialchars($chapter['content'] ?? '', ENT_QUOTES) ?>">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Скрытый textarea для формы -->
|
||||||
|
<textarea id="content" name="content" style="display:none;"></textarea>
|
||||||
|
|
||||||
|
|
||||||
|
<div style="margin-top: 1rem;">
|
||||||
|
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
|
Статус главы
|
||||||
|
</label>
|
||||||
|
<select id="status" name="status" style="width: 100%;">
|
||||||
|
<option value="draft" <?= ($chapter['status'] == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
|
||||||
|
<option value="published" <?= ($chapter['status'] == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
|
||||||
|
</select>
|
||||||
|
<small style="color: var(--muted-color);">
|
||||||
|
Опубликованные главы видны в публичном доступе
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||||
|
<button type="submit" class="contrast">
|
||||||
|
💾 Сохранить изменения
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" onclick="previewChapter()" class="secondary">
|
||||||
|
👁️ Предпросмотр
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary">
|
||||||
|
❌ Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
|
<h3>Информация о главе</h3>
|
||||||
|
<p><strong>Книга:</strong> <a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit"><?= e($book['title']) ?></a></p>
|
||||||
|
<p><strong>Количество слов:</strong> <?= $chapter['word_count'] ?></p>
|
||||||
|
<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');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
const tempForm = document.createElement('form');
|
||||||
|
tempForm.method = 'POST';
|
||||||
|
tempForm.action = '<?= SITE_URL ?>/chapters/preview';
|
||||||
|
tempForm.target = '_blank';
|
||||||
|
tempForm.style.display = 'none';
|
||||||
|
|
||||||
|
const csrfInput = document.createElement('input');
|
||||||
|
csrfInput.name = 'csrf_token';
|
||||||
|
csrfInput.value = '<?= generate_csrf_token() ?>';
|
||||||
|
tempForm.appendChild(csrfInput);
|
||||||
|
|
||||||
|
const contentInput = document.createElement('input');
|
||||||
|
contentInput.name = 'content';
|
||||||
|
contentInput.value = document.getElementById('content').value;
|
||||||
|
tempForm.appendChild(contentInput);
|
||||||
|
|
||||||
|
const titleInput = document.createElement('input');
|
||||||
|
titleInput.name = 'title';
|
||||||
|
titleInput.value = document.getElementById('title').value || 'Предпросмотр главы';
|
||||||
|
tempForm.appendChild(titleInput);
|
||||||
|
|
||||||
|
const editorTypeInput = document.createElement('input');
|
||||||
|
editorTypeInput.name = 'editor_type';
|
||||||
|
editorTypeInput.value = '<?= $book['editor_type'] ?? 'markdown' ?>';
|
||||||
|
tempForm.appendChild(editorTypeInput);
|
||||||
|
|
||||||
|
document.body.appendChild(tempForm);
|
||||||
|
tempForm.submit();
|
||||||
|
document.body.removeChild(tempForm);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="/assets/js/editor.js"></script>
|
||||||
|
<script src="/assets/js/autosave.js"></script>
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -1,120 +1,81 @@
|
||||||
<?php
|
<?php
|
||||||
require_once 'config/config.php';
|
// views/chapters/index.php
|
||||||
require_login();
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$book_id = $_GET['book_id'] ?? null;
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem;">Главы книги: <?= e($book['title']) ?></h1>
|
||||||
if (!$book_id) {
|
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content:center;">
|
||||||
$_SESSION['error'] = "Не указана книга";
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button" role="button">➕ Новая глава</a>
|
||||||
redirect('books.php');
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="adaptive-button secondary" role="button">✏️ Редактировать книгу</a>
|
||||||
}
|
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="adaptive-button green-btn" role="button" target="_blank">👁️ Публичный доступ</a>
|
||||||
|
<a href="<?= SITE_URL ?>/book/all/<?= $book['id'] ?>" class="adaptive-button" role="button" target="_blank">👁️ Полный обзор</a>
|
||||||
$bookModel = new Book($pdo);
|
</div>
|
||||||
$chapterModel = new Chapter($pdo);
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($chapters)): ?>
|
||||||
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
|
<div style="text-align: center; padding: 2rem; background: var(--card-background-color); border-radius: 5px; margin-top: 1rem;">
|
||||||
$_SESSION['error'] = "У вас нет доступа к этой книге";
|
<h3>В этой книге пока нет глав</h3>
|
||||||
redirect('books.php');
|
<p>Создайте первую главу для вашей книги</p>
|
||||||
}
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button">📝 Создать первую главу</a>
|
||||||
|
</div>
|
||||||
// Получаем информацию о книге и главах
|
<?php else: ?>
|
||||||
$book = $bookModel->findById($book_id);
|
<div style="overflow-x: auto; margin-top: 1rem;">
|
||||||
$chapters = $chapterModel->findByBook($book_id);
|
<table class="compact-table">
|
||||||
|
<thead>
|
||||||
$page_title = "Главы книги: " . e($book['title']);
|
<tr>
|
||||||
include 'views/header.php';
|
<th style="width: 5%;">№</th>
|
||||||
?>
|
<th style="width: 40%;">Название главы</th>
|
||||||
|
<th style="width: 15%;">Статус</th>
|
||||||
<div style="margin-bottom: 1rem;">
|
<th style="width: 10%;">Слов</th>
|
||||||
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem;">Главы книги: <?= e($book['title']) ?></h1>
|
<th style="width: 20%;">Обновлено</th>
|
||||||
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
|
<th style="width: 10%;">Действия</th>
|
||||||
<a href="chapter_edit.php?book_id=<?= $book_id ?>" class="adaptive-button">➕ Новая глава</a>
|
</tr>
|
||||||
<a href="book_edit.php?id=<?= $book_id ?>" class="adaptive-button secondary">✏️ Редактировать книгу</a>
|
</thead>
|
||||||
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" class="adaptive-button secondary" target="_blank">👁️ Просмотреть книгу</a>
|
<tbody>
|
||||||
<a href="books.php" class="adaptive-button secondary">📚 Все книги</a>
|
<?php foreach ($chapters as $index => $chapter): ?>
|
||||||
</div>
|
<tr>
|
||||||
</div>
|
<td><?= $index + 1 ?></td>
|
||||||
|
<td>
|
||||||
<?php if (isset($_SESSION['success'])): ?>
|
<strong><?= e($chapter['title']) ?></strong>
|
||||||
<div class="alert alert-success">
|
<?php if ($chapter['content']): ?>
|
||||||
<?= e($_SESSION['success']) ?>
|
<br><small style="color: var(--muted-color);"><?= e(mb_strimwidth($chapter['content'], 0, 100, '...')) ?></small>
|
||||||
<?php unset($_SESSION['success']); ?>
|
<?php endif; ?>
|
||||||
</div>
|
</td>
|
||||||
<?php endif; ?>
|
<td>
|
||||||
|
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>">
|
||||||
<?php if (isset($_SESSION['error'])): ?>
|
<?= $chapter['status'] == 'published' ? '✅ Опубликована' : '📝 Черновик' ?>
|
||||||
<div class="alert alert-error">
|
</span>
|
||||||
<?= e($_SESSION['error']) ?>
|
</td>
|
||||||
<?php unset($_SESSION['error']); ?>
|
<td><?= $chapter['word_count'] ?></td>
|
||||||
</div>
|
<td>
|
||||||
<?php endif; ?>
|
<small><?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></small>
|
||||||
|
</td>
|
||||||
<?php if (empty($chapters)): ?>
|
<td>
|
||||||
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px; margin-top: 1rem;">
|
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
|
||||||
<h3>В этой книге пока нет глав</h3>
|
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary" title="Редактировать" role="button">
|
||||||
<p>Создайте первую главу для вашей книги</p>
|
✏️
|
||||||
<a href="chapter_edit.php?book_id=<?= $book_id ?>" class="adaptive-button">📝 Создать первую главу</a>
|
</a>
|
||||||
</div>
|
<form method="post" action="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');">
|
||||||
<?php else: ?>
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
<div style="overflow-x: auto; margin-top: 1rem;">
|
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить">
|
||||||
<table class="compact-table">
|
🗑️
|
||||||
<thead>
|
</button>
|
||||||
<tr>
|
</form>
|
||||||
<th style="width: 5%;">№</th>
|
</div>
|
||||||
<th style="width: 40%;">Название главы</th>
|
</td>
|
||||||
<th style="width: 15%;">Статус</th>
|
</tr>
|
||||||
<th style="width: 10%;">Слов</th>
|
<?php endforeach; ?>
|
||||||
<th style="width: 20%;">Обновлено</th>
|
</tbody>
|
||||||
<th style="width: 10%;">Действия</th>
|
</table>
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
|
||||||
<tbody>
|
<div style="margin-top: 1rem; padding: 0.5rem; background: var(--card-background-color); border-radius: 3px;">
|
||||||
<?php foreach ($chapters as $index => $chapter): ?>
|
<strong>Статистика:</strong>
|
||||||
<tr>
|
Всего глав: <?= count($chapters) ?> |
|
||||||
<td><?= $index + 1 ?></td>
|
Всего слов: <?= array_sum(array_column($chapters, 'word_count')) ?> |
|
||||||
<td>
|
Опубликовано: <?= count(array_filter($chapters, function($ch) { return $ch['status'] == 'published'; })) ?>
|
||||||
<strong><?= e($chapter['title']) ?></strong>
|
</div>
|
||||||
<?php if ($chapter['description']): ?>
|
<?php endif; ?>
|
||||||
<br><small style="color: #666;"><?= e(mb_strimwidth($chapter['description'], 0, 100, '...')) ?></small>
|
|
||||||
<?php endif; ?>
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>">
|
|
||||||
<?= $chapter['status'] == 'published' ? '✅ Опубликована' : '📝 Черновик' ?>
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td><?= $chapter['word_count'] ?></td>
|
|
||||||
<td>
|
|
||||||
<small><?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></small>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
|
|
||||||
<a href="chapter_edit.php?id=<?= $chapter['id'] ?>" class="compact-button secondary" title="Редактировать">
|
|
||||||
✏️
|
|
||||||
</a>
|
|
||||||
<form method="post" action="chapter_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');">
|
|
||||||
<input type="hidden" name="chapter_id" value="<?= $chapter['id'] ?>">
|
|
||||||
<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>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="margin-top: 1rem; padding: 0.5rem; background: #f5f5f5; border-radius: 3px;">
|
|
||||||
<strong>Статистика:</strong>
|
|
||||||
Всего глав: <?= count($chapters) ?> |
|
|
||||||
Всего слов: <?= array_sum(array_column($chapters, 'word_count')) ?> |
|
|
||||||
Опубликовано: <?= count(array_filter($chapters, function($ch) { return $ch['status'] == 'published'; })) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -1,31 +1,11 @@
|
||||||
<?php
|
|
||||||
require_once 'config/config.php';
|
|
||||||
require_login();
|
|
||||||
|
|
||||||
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') {
|
|
||||||
$html_content = $Parsedown->text($content);
|
|
||||||
} else {
|
|
||||||
$html_content = $content;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
$page_title = "Предпросмотр: " . e($title);
|
|
||||||
?>
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><?= e($page_title) ?></title>
|
<title><?= e($page_title) ?></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/pico.min.css">
|
||||||
|
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|
@ -41,33 +21,35 @@ $page_title = "Предпросмотр: " . e($title);
|
||||||
margin-top: 1.5em;
|
margin-top: 1.5em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 0.5em;
|
||||||
}
|
}
|
||||||
h1 { border-bottom: 2px solid #007bff; padding-bottom: 0.3em; }
|
h1 { border-bottom: 2px solid var(--primary); padding-bottom: 0.3em; }
|
||||||
h2 { border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
|
h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; }
|
||||||
p {
|
p {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
|
text-align: justify;
|
||||||
}
|
}
|
||||||
code {
|
code {
|
||||||
background: #f5f5f5;
|
background: var(--card-background-color);
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
}
|
}
|
||||||
pre {
|
pre {
|
||||||
background: #f5f5f5;
|
background: var(--card-background-color);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
border-left: 4px solid #007bff;
|
border-left: 4px solid var(--primary);
|
||||||
}
|
}
|
||||||
pre code {
|
pre code {
|
||||||
background: none;
|
background: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 4px solid #ddd;
|
border-left: 4px solid var(--border-color);
|
||||||
padding-left: 1rem;
|
padding-left: 1rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
color: #666;
|
color: var(--muted-color);
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
strong { font-weight: bold; }
|
strong { font-weight: bold; }
|
||||||
|
|
@ -77,6 +59,7 @@ $page_title = "Предпросмотр: " . e($title);
|
||||||
.dialogue {
|
.dialogue {
|
||||||
margin-left: 2rem;
|
margin-left: 2rem;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
color: #2c5aa0;
|
||||||
}
|
}
|
||||||
table {
|
table {
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
|
|
@ -84,14 +67,25 @@ $page_title = "Предпросмотр: " . e($title);
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
}
|
}
|
||||||
table, th, td {
|
table, th, td {
|
||||||
border: 1px solid #ddd;
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
th, td {
|
th, td {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
th {
|
th {
|
||||||
background: #f5f5f5;
|
background: var(--card-background-color);
|
||||||
|
}
|
||||||
|
ul, ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 2rem;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -100,13 +94,14 @@ $page_title = "Предпросмотр: " . e($title);
|
||||||
<h1><?= e($title) ?></h1>
|
<h1><?= e($title) ?></h1>
|
||||||
<hr>
|
<hr>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
<?= $html_content ?>
|
<?= $content ?>
|
||||||
</main>
|
</main>
|
||||||
|
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
|
||||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #ddd;">
|
<small>Сгенерировано <?= date('d.m.Y H:i') ?> | Предпросмотр</small>
|
||||||
<small>Сгенерировано <?= date('d.m.Y H:i') ?> | Markdown Preview</small>
|
<br>
|
||||||
|
<a href="javascript:window.close()" class="button secondary">Закрыть</a>
|
||||||
|
<a href="javascript:window.print()" class="button">Печать</a>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
<?php
|
||||||
|
// views/dashboard/index.php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1>Панель управления</h1>
|
||||||
|
|
||||||
|
<div class="grid" style="margin-bottom: 2rem;">
|
||||||
|
<article style="text-align: center;">
|
||||||
|
<h2>📚 Книги</h2>
|
||||||
|
<div style="font-size: 2rem; font-weight: bold; color: var(--primary);">
|
||||||
|
<?= $total_books ?>
|
||||||
|
</div>
|
||||||
|
<small>Всего книг</small>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article style="text-align: center;">
|
||||||
|
<h2>📑 Главы</h2>
|
||||||
|
<div style="font-size: 2rem; font-weight: bold; color: var(--success);">
|
||||||
|
<?= $total_chapters ?>
|
||||||
|
</div>
|
||||||
|
<small>Всего глав</small>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article style="text-align: center;">
|
||||||
|
<h2>📝 Слова</h2>
|
||||||
|
<div style="font-size: 2rem; font-weight: bold; color: var(--warning);">
|
||||||
|
<?= number_format($total_words) ?>
|
||||||
|
</div>
|
||||||
|
<small>Всего слов</small>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article style="text-align: center;">
|
||||||
|
<h2>🌐 Публикации</h2>
|
||||||
|
<div style="font-size: 2rem; font-weight: bold; color: var(--info);">
|
||||||
|
<?= $published_books_count ?>
|
||||||
|
</div>
|
||||||
|
<small>Опубликовано книг</small>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<h2>Недавние книги</h2>
|
||||||
|
<?php if (!empty($recent_books)): ?>
|
||||||
|
<?php foreach ($recent_books as $book): ?>
|
||||||
|
<article style="margin-bottom: 1em; padding-top: 0.5em;">
|
||||||
|
<h3 style="margin-bottom: 0.5rem; margin-top: 0.5em;">
|
||||||
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit">
|
||||||
|
<?= e($book['title']) ?>
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<?php if ($book['genre']): ?>
|
||||||
|
<p style="margin: 0; color: var(--muted-color); font-size:small;"><em><?= e($book['genre']) ?></em></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($book['description']): ?>
|
||||||
|
<p style="margin: 0; color: var(--muted-color);"><?= e($book['description']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<footer>
|
||||||
|
<small>
|
||||||
|
Глав: <?= $book['chapter_count'] ?? 0 ?> |
|
||||||
|
Слов: <?= $book['total_words'] ?? 0 ?> |
|
||||||
|
Статус: <?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<?php if (count($recent_books) < count($books)): ?>
|
||||||
|
<div style="text-align: center; margin-top: 1rem;">
|
||||||
|
<a href="<?= SITE_URL ?>/books" class="button">Все книги</a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php else: ?>
|
||||||
|
<article>
|
||||||
|
<p>У вас пока нет книг.</p>
|
||||||
|
<a href="<?= SITE_URL ?>/books/create" class="button">Создать первую книгу</a>
|
||||||
|
</article>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2>Мои серии</h2>
|
||||||
|
<?php if (!empty($series)): ?>
|
||||||
|
<?php foreach ($series as $ser): ?>
|
||||||
|
<article>
|
||||||
|
<h3 style="margin-bottom: 0.5rem;">
|
||||||
|
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/edit">
|
||||||
|
<?= e($ser['title']) ?>
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<?php if ($ser['description']): ?>
|
||||||
|
<p><?= e(mb_strimwidth($ser['description'], 0, 100, '...')) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
<footer>
|
||||||
|
<small>
|
||||||
|
Книг: <?= $ser['book_count'] ?> |
|
||||||
|
Слов: <?= $ser['total_words'] ?>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
|
||||||
|
<div style="text-align: center; margin-top: 1rem;">
|
||||||
|
<a href="<?= SITE_URL ?>/series" class="button">Все серии</a>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<article>
|
||||||
|
<p>У вас пока нет серий.</p>
|
||||||
|
<a href="<?= SITE_URL ?>/series/create" class="button">Создать первую серию</a>
|
||||||
|
</article>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<h2 style="margin-top: 2rem;">Быстрые действия</h2>
|
||||||
|
<div class="button-group">
|
||||||
|
<a href="<?= SITE_URL ?>/books/create" class="button">📖 Новая книга</a>
|
||||||
|
<a href="<?= SITE_URL ?>/series/create" class="button secondary">📚 Новая серия</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
// views/errors/404.php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="container" style="text-align: center; padding: 4rem 1rem;">
|
||||||
|
<h1>404 - Страница не найдена</h1>
|
||||||
|
<p style="font-size: 1.2rem; margin-bottom: 2rem;">
|
||||||
|
Запрашиваемая страница не существует или была перемещена.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
|
||||||
|
<a href="<?= SITE_URL ?>/" class="button">🏠 На главную</a>
|
||||||
|
<a href="<?= SITE_URL ?>/books" class="button secondary">📚 К книгам</a>
|
||||||
|
<?php if (!is_logged_in()): ?>
|
||||||
|
<a href="<?= SITE_URL ?>/login" class="button secondary">🔑 Войти</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
<?php
|
|
||||||
// views/footer.php
|
|
||||||
?>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<?php
|
|
||||||
// views/header.php
|
|
||||||
?>
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title><?= e(APP_NAME) ?> - <?= e($page_title ?? 'Платформа для писателей') ?></title>
|
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.10/css/pico.min.css">
|
|
||||||
<link rel="stylesheet" href="/assets/css/style.css">
|
|
||||||
<link rel="stylesheet" href="/assets/css/foundation-icons.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="container-fluid black">
|
|
||||||
<ul>
|
|
||||||
<li><strong><a href="/" style="text-decoration: none;"><?= e(APP_NAME) ?></a></strong></li>
|
|
||||||
</ul>
|
|
||||||
<ul>
|
|
||||||
<?php if (is_logged_in()): ?>
|
|
||||||
<li><a href="/dashboard.php">📊 Панель</a></li>
|
|
||||||
<li><a href="/series.php">📚 Мои серии</a></li>
|
|
||||||
<li><a href="/books.php">📚 Мои книги</a></li>
|
|
||||||
<li>
|
|
||||||
<details role="list" dir="rtl">
|
|
||||||
<summary aria-haspopup="listbox" role="link" style="display: flex; align-items: center; gap: 0.5rem;">
|
|
||||||
<?php if (!empty($_SESSION['avatar'])): ?>
|
|
||||||
<img src="<?= AVATARS_URL . e($_SESSION['avatar']) ?>"
|
|
||||||
alt="Аватар"
|
|
||||||
style="width: 32px; height: 32px; border-radius: 50%; object-fit: cover;"
|
|
||||||
onerror="this.style.display='none'">
|
|
||||||
<?php endif; ?>
|
|
||||||
👤 <?= e($_SESSION['display_name']) ?>
|
|
||||||
</summary>
|
|
||||||
<ul role="listbox">
|
|
||||||
<li><a href="/profile.php">Настройки профиля</a></li>
|
|
||||||
<li><a href="/author.php?id=<?= $_SESSION['user_id'] ?>" target="_blank">Моя публичная страница</a></li>
|
|
||||||
<?php if ($_SESSION['user_id'] == 1): ?>
|
|
||||||
<li><a href="/admin/users.php">👥 Пользователи</a></li>
|
|
||||||
<?php endif; ?>
|
|
||||||
<li><a href="/logout.php">Выйти</a></li>
|
|
||||||
</ul>
|
|
||||||
</details>
|
|
||||||
</li>
|
|
||||||
<?php else: ?>
|
|
||||||
<li><a href="/login.php">Войти</a></li>
|
|
||||||
<li><a href="/register.php">Регистрация</a></li>
|
|
||||||
<?php endif; ?>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<main class="container">
|
|
||||||
<?php if (isset($_SESSION['info'])): ?>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<?= e($_SESSION['info']) ?>
|
|
||||||
<?php unset($_SESSION['info']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<?php if (isset($_SESSION['warning'])): ?>
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<?= e($_SESSION['warning']) ?>
|
|
||||||
<?php unset($_SESSION['warning']); ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
// views/layouts/footer.php
|
||||||
|
?>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="container" style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--muted-border-color);">
|
||||||
|
<small>
|
||||||
|
© <?= date('Y') ?> <?= e(APP_NAME) ?>.
|
||||||
|
<?php if (is_logged_in()): ?>
|
||||||
|
<a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">Моя публичная страница</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</small>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Глобальные функции JavaScript
|
||||||
|
function confirmAction(message) {
|
||||||
|
return confirm(message || 'Вы уверены?');
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
alert('Скопировано в буфер обмена');
|
||||||
|
}, function(err) {
|
||||||
|
console.error('Ошибка копирования: ', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?php
|
||||||
|
// views/layouts/header.php
|
||||||
|
?>
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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">
|
||||||
|
<link href="<?= SITE_URL ?>/assets/css/quill.snow.css" rel="stylesheet">
|
||||||
|
<script src="<?= SITE_URL ?>/assets/js/quill.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="container-fluid">
|
||||||
|
<ul>
|
||||||
|
<li><strong><a href="<?= SITE_URL ?>/"><?= e(APP_NAME) ?></a></strong></li>
|
||||||
|
</ul>
|
||||||
|
<ul>
|
||||||
|
<?php if (is_logged_in()): ?>
|
||||||
|
<li><a href="<?= SITE_URL ?>/dashboard">📊 Панель управления</a></li>
|
||||||
|
<li><a href="<?= SITE_URL ?>/books">📚 Мои книги</a></li>
|
||||||
|
<li><a href="<?= SITE_URL ?>/series">📑 Серии</a></li>
|
||||||
|
<li>
|
||||||
|
<details role="list" dir="rtl">
|
||||||
|
<summary aria-haspopup="listbox" role="link">
|
||||||
|
👤 <?= e($_SESSION['display_name']) ?>
|
||||||
|
</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>
|
||||||
|
</li>
|
||||||
|
<?php else: ?>
|
||||||
|
<li><a href="<?= SITE_URL ?>/login">🔑 Вход</a></li>
|
||||||
|
<li><a href="<?= SITE_URL ?>/register">📝 Регистрация</a></li>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<?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; ?>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['warning'])): ?>
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<?= e($_SESSION['warning']) ?>
|
||||||
|
<?php unset($_SESSION['warning']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (isset($_SESSION['info'])): ?>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<?= e($_SESSION['info']) ?>
|
||||||
|
<?php unset($_SESSION['info']); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
// views/series/create.php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1>Создание новой серии</h1>
|
||||||
|
|
||||||
|
<?php if (isset($error) && $error): ?>
|
||||||
|
<div class="alert alert-error">
|
||||||
|
<?= e($error) ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
|
||||||
|
<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($_POST['title'] ?? '') ?>"
|
||||||
|
placeholder="Введите название серии"
|
||||||
|
style="width: 100%; margin-bottom: 1.5rem;"
|
||||||
|
required>
|
||||||
|
|
||||||
|
<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($_POST['description'] ?? '') ?></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||||
|
<button type="submit" class="contrast">
|
||||||
|
📚 Создать серию
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="<?= SITE_URL ?>/series" role="button" class="secondary">
|
||||||
|
❌ Отмена
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
|
<h3>Что такое серия?</h3>
|
||||||
|
<p>Серия позволяет объединить несколько книг в одну тематическую коллекцию. Это полезно для:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Циклов книг с общим сюжетом</li>
|
||||||
|
<li>Книг в одном мире или вселенной</li>
|
||||||
|
<li>Организации книг по темам или жанрам</li>
|
||||||
|
</ul>
|
||||||
|
<p>Вы сможете добавить книги в серию после её создания.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -0,0 +1,193 @@
|
||||||
|
<?php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<h1>Редактирование серии: <?= e($series['title']) ?></h1>
|
||||||
|
<article>
|
||||||
|
<h2>Основная информация</h2>
|
||||||
|
<form method="post" action="/series/<?= $series['id'] ?>/edit">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<label for="description">
|
||||||
|
Описание серии
|
||||||
|
<textarea id="description" name="description" rows="4"><?= e($series['description'] ?? '') ?></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="primary-btn">Сохранить изменения</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<h2>Добавить книгу в серию</h2>
|
||||||
|
<?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" role="button">Создать новую книгу</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<article>
|
||||||
|
<h2>Книги в серии (<?= count($books_in_series) ?>)</h2>
|
||||||
|
|
||||||
|
<?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" style="display: grid; min-width: 2rem; margin-top: 1rem;">
|
||||||
|
<a href="/books/<?= $book['id'] ?>/edit" class="compact-button" role="button" style="margin-top: 0em;">✏️</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 red-btn" style="margin-top: 0em;">🗑️</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.books-list {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,84 @@
|
||||||
|
<?php
|
||||||
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div style="display: block; 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" role="button">➕ Создать серию</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" role="button">Создать серию</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" style="display:grid;">
|
||||||
|
<a href="/series/<?= $ser['id'] ?>/edit" class="compact-button primary-btn" role="button">
|
||||||
|
✏️ Управление
|
||||||
|
</a>
|
||||||
|
<a href="/series/<?= $ser['id'] ?>/view" class="compact-button secondary-btn" target="_blank" role="button">
|
||||||
|
👁️ Публично
|
||||||
|
</a>
|
||||||
|
<form method="post" action="/series/<?= $ser['id'] ?>/delete"
|
||||||
|
onsubmit="return confirm('Удалить серию? Книги останутся, но будут удалены из серии.')">
|
||||||
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
<button type="submit" class="compact-button red-btn">🗑️ Удалить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
include 'views/layouts/footer.php';
|
||||||
|
?>
|
||||||
|
|
@ -1,161 +1,122 @@
|
||||||
<?php
|
<?php
|
||||||
require_once 'config/config.php';
|
// views/series/view_public.php
|
||||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
$Parsedown = new ParsedownExtra();
|
|
||||||
|
<div class="container">
|
||||||
$series_id = (int)($_GET['id'] ?? 0);
|
<article style="max-width: 800px; margin: 0 auto;">
|
||||||
if (!$series_id) {
|
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid var(--muted-border-color); padding-bottom: 1rem;">
|
||||||
http_response_code(400);
|
<h1 style="margin-bottom: 0.5rem;"><?= e($series['title']) ?></h1>
|
||||||
echo "<h2>Неверный запрос</h2>";
|
<p style="color: var(--muted-color); font-style: italic; margin-bottom: 0.5rem;">
|
||||||
include 'views/footer.php';
|
Серия книг от
|
||||||
exit;
|
<a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
|
||||||
}
|
</p>
|
||||||
|
|
||||||
$seriesModel = new Series($pdo);
|
<?php if ($series['description']): ?>
|
||||||
$series = $seriesModel->findById($series_id);
|
<div style="background: var(--card-background-color); padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;">
|
||||||
|
<?= e($series['description']) ?>
|
||||||
if (!$series) {
|
</div>
|
||||||
http_response_code(404);
|
<?php endif; ?>
|
||||||
echo "<h2>Серия не найдена</h2>";
|
|
||||||
include 'views/footer.php';
|
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);">
|
||||||
exit;
|
<span>Книг: <?= count($books) ?></span>
|
||||||
}
|
<span>Глав: <?= $total_chapters ?></span>
|
||||||
|
<span>Слов: <?= $total_words ?></span>
|
||||||
// Получаем только опубликованные книги серии
|
</div>
|
||||||
$books = $seriesModel->getBooksInSeries($series_id, true);
|
</header>
|
||||||
|
|
||||||
// Получаем информацию об авторе
|
<?php if (empty($books)): ?>
|
||||||
$stmt = $pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
$stmt->execute([$series['user_id']]);
|
<h3>В этой серии пока нет опубликованных книг</h3>
|
||||||
$author = $stmt->fetch(PDO::FETCH_ASSOC);
|
<p>Автор еще не опубликовал книги из этой серии</p>
|
||||||
|
</div>
|
||||||
// Получаем статистику по опубликованным книгам
|
<?php else: ?>
|
||||||
$bookModel = new Book($pdo);
|
<div class="series-books">
|
||||||
$total_words = 0;
|
<h2 style="text-align: center; margin-bottom: 2rem;">Книги серии</h2>
|
||||||
$total_chapters = 0;
|
|
||||||
|
<?php foreach ($books as $book): ?>
|
||||||
foreach ($books as $book) {
|
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 8px;">
|
||||||
$book_stats = $bookModel->getBookStats($book['id'], true); // true - только опубликованные главы
|
<?php if ($book['cover_image']): ?>
|
||||||
$total_words += $book_stats['total_words'] ?? 0;
|
<div style="flex-shrink: 0;">
|
||||||
$total_chapters += $book_stats['chapter_count'] ?? 0;
|
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||||
}
|
alt="<?= e($book['title']) ?>"
|
||||||
|
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);"
|
||||||
$page_title = $series['title'] . ' — серия книг';
|
onerror="this.style.display='none'">
|
||||||
include 'views/header.php';
|
</div>
|
||||||
?>
|
<?php else: ?>
|
||||||
|
<div style="flex-shrink: 0;">
|
||||||
<div class="container">
|
<div class="cover-placeholder" style="width: 120px; height: 160px; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
|
||||||
<article style="max-width: 800px; margin: 0 auto;">
|
📚
|
||||||
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid #eee; padding-bottom: 1rem;">
|
</div>
|
||||||
<h1 style="margin-bottom: 0.5rem;"><?= e($series['title']) ?></h1>
|
</div>
|
||||||
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
|
<?php endif; ?>
|
||||||
Серия книг от
|
|
||||||
<a href="author.php?id=<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
|
<div style="flex: 1;">
|
||||||
</p>
|
<h3 style="margin-top: 0;">
|
||||||
|
<?php if ($book['sort_order_in_series']): ?>
|
||||||
<?php if ($series['description']): ?>
|
<small style="color: var(--muted-color);">Книга <?= $book['sort_order_in_series'] ?></small><br>
|
||||||
<div style="background: #f8f9fa; padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;">
|
<?php endif; ?>
|
||||||
<?= $Parsedown->text($series['description']) ?>
|
<?= e($book['title']) ?>
|
||||||
</div>
|
</h3>
|
||||||
<?php endif; ?>
|
|
||||||
|
<?php if ($book['genre']): ?>
|
||||||
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
|
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
|
||||||
<span>Книг: <?= count($books) ?></span>
|
<?php endif; ?>
|
||||||
<span>Глав: <?= $total_chapters ?></span>
|
|
||||||
<span>Слов: <?= $total_words ?></span>
|
<?php if ($book['description']): ?>
|
||||||
</div>
|
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
|
||||||
</header>
|
<?php endif; ?>
|
||||||
|
|
||||||
<?php if (empty($books)): ?>
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
|
||||||
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
|
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button">
|
||||||
<h3>В этой серии пока нет опубликованных книг</h3>
|
Читать
|
||||||
<p>Автор еще не опубликовал книги из этой серии</p>
|
</a>
|
||||||
</div>
|
|
||||||
<?php else: ?>
|
<?php
|
||||||
<div class="series-books">
|
$bookModel = new Book($pdo);
|
||||||
<h2 style="text-align: center; margin-bottom: 2rem;">Книги серии</h2>
|
$book_stats = $bookModel->getBookStats($book['id'], true);
|
||||||
|
?>
|
||||||
<?php foreach ($books as $book): ?>
|
|
||||||
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 8px;">
|
<small style="color: var(--muted-color);">
|
||||||
<?php if ($book['cover_image']): ?>
|
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | Слов: <?= $book_stats['total_words'] ?? 0 ?>
|
||||||
<div style="flex-shrink: 0;">
|
</small>
|
||||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
</div>
|
||||||
alt="<?= e($book['title']) ?>"
|
</div>
|
||||||
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;"
|
</article>
|
||||||
onerror="this.style.display='none'">
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php endif; ?>
|
||||||
<div style="flex-shrink: 0;">
|
|
||||||
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
|
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;">
|
||||||
</div>
|
<p style="color: var(--muted-color);">
|
||||||
<?php endif; ?>
|
Серия создана в <?= e(APP_NAME) ?> •
|
||||||
|
Автор: <a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
|
||||||
<div style="flex: 1;">
|
</p>
|
||||||
<h3 style="margin-top: 0;">
|
</footer>
|
||||||
<?php if ($book['sort_order_in_series']): ?>
|
</article>
|
||||||
<small style="color: #666;">Книга <?= $book['sort_order_in_series'] ?></small><br>
|
</div>
|
||||||
<?php endif; ?>
|
|
||||||
<?= e($book['title']) ?>
|
<style>
|
||||||
</h3>
|
.series-books article {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
<?php if ($book['genre']): ?>
|
border: 1px solid var(--border-color);
|
||||||
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
|
}
|
||||||
<?php endif; ?>
|
|
||||||
|
.series-books article:hover {
|
||||||
<?php if ($book['description']): ?>
|
transform: translateY(-2px);
|
||||||
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
<?php endif; ?>
|
}
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
|
@media (max-width: 768px) {
|
||||||
<a href="view_book.php?share_token=<?= e($book['share_token']) ?>" class="adaptive-button">
|
.series-books article {
|
||||||
Читать
|
flex-direction: column;
|
||||||
</a>
|
text-align: center;
|
||||||
|
}
|
||||||
<?php
|
|
||||||
$bookModel = new Book($pdo);
|
.series-books .book-cover {
|
||||||
$book_stats = $bookModel->getBookStats($book['id'], true); // true - только опубликованные главы
|
align-self: center;
|
||||||
?>
|
}
|
||||||
|
}
|
||||||
<small style="color: #666;">
|
</style>
|
||||||
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | Слов: <?= $book_stats['total_words'] ?? 0 ?>
|
|
||||||
</small>
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid #eee; text-align: center;">
|
|
||||||
<p style="color: #666;">
|
|
||||||
Серия создана в <?= e(APP_NAME) ?> •
|
|
||||||
Автор: <a href="author.php?id=<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
|
|
||||||
</p>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.series-books article {
|
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-books article:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.series-books article {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.series-books .book-cover {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -1,195 +1,141 @@
|
||||||
<?php
|
<?php
|
||||||
require_once 'config/config.php';
|
// views/user/profile.php
|
||||||
require_login();
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
$user_id = $_SESSION['user_id'];
|
|
||||||
$userModel = new User($pdo);
|
<h1>Мой профиль</h1>
|
||||||
$user = $userModel->findById($user_id);
|
|
||||||
|
<?php if ($message): ?>
|
||||||
$message = '';
|
<div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-error' : 'alert-success' ?>">
|
||||||
$avatar_error = '';
|
<?= e($message) ?>
|
||||||
|
</div>
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
<?php endif; ?>
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
|
||||||
$message = "Ошибка безопасности";
|
<div class="grid">
|
||||||
} else {
|
<article>
|
||||||
$display_name = trim($_POST['display_name'] ?? '');
|
<h2>Основная информация</h2>
|
||||||
$email = trim($_POST['email'] ?? '');
|
<form method="post" enctype="multipart/form-data">
|
||||||
$bio = trim($_POST['bio'] ?? '');
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
|
||||||
// Обработка загрузки аватарки
|
<div style="margin-bottom: 1rem;">
|
||||||
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === UPLOAD_ERR_OK) {
|
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
$avatar_result = handleAvatarUpload($_FILES['avatar'], $user_id);
|
Имя пользователя (нельзя изменить)
|
||||||
if ($avatar_result['success']) {
|
</label>
|
||||||
$userModel->updateAvatar($user_id, $avatar_result['filename']);
|
<input type="text" id="username" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
|
||||||
// Обновляем данные пользователя
|
</div>
|
||||||
$user = $userModel->findById($user_id);
|
|
||||||
} else {
|
<div style="margin-bottom: 1rem;">
|
||||||
$avatar_error = $avatar_result['error'];
|
<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($user['display_name'] ?? $user['username']) ?>"
|
||||||
if (isset($_POST['delete_avatar']) && $_POST['delete_avatar'] == '1') {
|
style="width: 100%;" required>
|
||||||
deleteUserAvatar($user_id);
|
</div>
|
||||||
$user = $userModel->findById($user_id);
|
|
||||||
}
|
<div style="margin-bottom: 1.5rem;">
|
||||||
|
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
// Обновляем основные данные
|
Email
|
||||||
$data = [
|
</label>
|
||||||
'display_name' => $display_name,
|
<input type="email" id="email" name="email"
|
||||||
'email' => $email,
|
value="<?= e($user['email'] ?? '') ?>"
|
||||||
'bio' => $bio
|
style="width: 100%;">
|
||||||
];
|
</div>
|
||||||
|
|
||||||
if ($userModel->updateProfile($user_id, $data)) {
|
<div style="margin-bottom: 1.5rem;">
|
||||||
$_SESSION['display_name'] = $display_name ?: $user['username'];
|
<label for="bio" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
$message = "Профиль обновлен";
|
О себе (отображается на вашей публичной странице)
|
||||||
// Обновляем данные пользователя
|
</label>
|
||||||
$user = $userModel->findById($user_id);
|
<textarea id="bio" name="bio"
|
||||||
} else {
|
placeholder="Расскажите о себе, своих интересах, стиле письма..."
|
||||||
$message = "Ошибка при обновлении профиля";
|
rows="6"
|
||||||
}
|
style="width: 100%;"><?= e($user['bio'] ?? '') ?></textarea>
|
||||||
}
|
<small style="color: var(--muted-color);">
|
||||||
}
|
Поддерживается Markdown форматирование
|
||||||
|
</small>
|
||||||
$page_title = "Мой профиль";
|
</div>
|
||||||
include 'views/header.php';
|
|
||||||
?>
|
<div class="profile-buttons">
|
||||||
|
<button type="submit" class="profile-button primary">
|
||||||
<h1>Мой профиль</h1>
|
💾 Сохранить изменения
|
||||||
|
</button>
|
||||||
<?php if ($message): ?>
|
<a href="<?= SITE_URL ?>/dashboard" class="profile-button secondary">
|
||||||
<div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-error' : 'alert-success' ?>">
|
↩️ Назад
|
||||||
<?= e($message) ?>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
</form>
|
||||||
|
</article>
|
||||||
<div class="grid">
|
|
||||||
<article>
|
<article>
|
||||||
<h2>Основная информация</h2>
|
<h2>Аватарка</h2>
|
||||||
<form method="post" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
<div style="text-align: center; margin-bottom: 1.5rem;">
|
||||||
|
<?php if (!empty($user['avatar'])): ?>
|
||||||
<div style="margin-bottom: 1rem;">
|
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
|
||||||
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
alt="Аватарка"
|
||||||
Имя пользователя (нельзя изменить)
|
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid var(--primary);"
|
||||||
</label>
|
onerror="this.style.display='none'">
|
||||||
<input type="text" id="username" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
|
<?php else: ?>
|
||||||
</div>
|
<div style="width: 200px; height: 200px; border-radius: 50%; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 4rem; margin: 0 auto;">
|
||||||
|
<?= mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
|
||||||
<div style="margin-bottom: 1rem;">
|
</div>
|
||||||
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
<?php endif; ?>
|
||||||
Отображаемое имя *
|
</div>
|
||||||
</label>
|
|
||||||
<input type="text" id="display_name" name="display_name"
|
<form method="post" enctype="multipart/form-data">
|
||||||
value="<?= e($user['display_name'] ?? $user['username']) ?>"
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
style="width: 100%;" required>
|
|
||||||
</div>
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<label for="avatar" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||||
<div style="margin-bottom: 1.5rem;">
|
Загрузить новую аватарку
|
||||||
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
</label>
|
||||||
Email
|
<input type="file" id="avatar" name="avatar"
|
||||||
</label>
|
accept="image/jpeg, image/png, image/gif, image/webp"
|
||||||
<input type="email" id="email" name="email"
|
style="height: 2.6rem;">
|
||||||
value="<?= e($user['email'] ?? '') ?>"
|
<small style="color: var(--muted-color);">
|
||||||
style="width: 100%;">
|
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB.
|
||||||
</div>
|
Рекомендуемый размер: 200×200 пикселей.
|
||||||
|
</small>
|
||||||
<div style="margin-bottom: 1.5rem;">
|
|
||||||
<label for="bio" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
<?php if (!empty($avatar_error)): ?>
|
||||||
О себе (отображается на вашей публичной странице)
|
<div style="color: #d32f2f; margin-top: 0.5rem;">
|
||||||
</label>
|
❌ <?= e($avatar_error) ?>
|
||||||
<textarea id="bio" name="bio"
|
</div>
|
||||||
placeholder="Расскажите о себе, своих интересах, стиле письма..."
|
<?php endif; ?>
|
||||||
rows="6"
|
</div>
|
||||||
style="width: 100%;"><?= e($user['bio'] ?? '') ?></textarea>
|
|
||||||
<small style="color: #666;">
|
<div style="display: flex; gap: 10px;">
|
||||||
Поддерживается Markdown форматирование
|
<button type="submit" class="contrast" style="flex: 1;">
|
||||||
</small>
|
📤 Загрузить аватарку
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<div class="profile-buttons">
|
<?php if (!empty($user['avatar'])): ?>
|
||||||
<button type="submit" class="profile-button primary">
|
<button type="submit" name="delete_avatar" value="1" class="secondary" style="flex: 1; background: #ff4444; border-color: #ff4444; color: white;">
|
||||||
💾 Сохранить изменения
|
🗑️ Удалить аватарку
|
||||||
</button>
|
</button>
|
||||||
<a href="dashboard.php" class="profile-button secondary">
|
<?php endif; ?>
|
||||||
↩️ Назад
|
</div>
|
||||||
</a>
|
</form>
|
||||||
</div>
|
|
||||||
</form>
|
<?php if (!empty($user['avatar'])): ?>
|
||||||
</article>
|
<div style="margin-top: 1rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
|
<p style="margin: 0; font-size: 0.9em; color: var(--muted-color);">
|
||||||
<article>
|
<strong>Примечание:</strong> Аватарка отображается на вашей публичной странице автора
|
||||||
<h2>Аватарка</h2>
|
</p>
|
||||||
|
</div>
|
||||||
<div style="text-align: center; margin-bottom: 1.5rem;">
|
<?php endif; ?>
|
||||||
<?php if (!empty($user['avatar'])): ?>
|
</article>
|
||||||
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
|
</div>
|
||||||
alt="Аватарка"
|
|
||||||
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid #007bff;"
|
<article>
|
||||||
onerror="this.style.display='none'">
|
<h3>Информация об аккаунте</h3>
|
||||||
<?php else: ?>
|
<p><a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank" class="adaptive-button secondary">
|
||||||
<div style="width: 200px; height: 200px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 4rem; margin: 0 auto;">
|
👁️ Посмотреть мою публичную страницу
|
||||||
<?= mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
|
</a></p>
|
||||||
</div>
|
<p><strong>Дата регистрации:</strong> <?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></p>
|
||||||
<?php endif; ?>
|
<?php if ($user['last_login']): ?>
|
||||||
</div>
|
<p><strong>Последний вход:</strong> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
<form method="post" enctype="multipart/form-data">
|
</article>
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<label for="avatar" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
|
||||||
Загрузить новую аватарку
|
|
||||||
</label>
|
|
||||||
<input type="file" id="avatar" name="avatar"
|
|
||||||
accept="image/jpeg, image/png, image/gif, image/webp"
|
|
||||||
style="height: 2.6rem;">
|
|
||||||
<small style="color: #666;">
|
|
||||||
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB.
|
|
||||||
Рекомендуемый размер: 200×200 пикселей.
|
|
||||||
</small>
|
|
||||||
|
|
||||||
<?php if (!empty($avatar_error)): ?>
|
|
||||||
<div style="color: #d32f2f; margin-top: 0.5rem;">
|
|
||||||
❌ <?= e($avatar_error) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="display: flex; gap: 10px;">
|
|
||||||
<button type="submit" class="contrast" style="flex: 1;">
|
|
||||||
📤 Загрузить аватарку
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<?php if (!empty($user['avatar'])): ?>
|
|
||||||
<button type="submit" name="delete_avatar" value="1" class="secondary" style="flex: 1; background: #ff4444; border-color: #ff4444; color: white;">
|
|
||||||
🗑️ Удалить аватарку
|
|
||||||
</button>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<?php if (!empty($user['avatar'])): ?>
|
|
||||||
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
|
|
||||||
<p style="margin: 0; font-size: 0.9em; color: #666;">
|
|
||||||
<strong>Примечание:</strong> Аватарка отображается на вашей публичной странице автора
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article>
|
|
||||||
<h3>Информация об аккаунте</h3>
|
|
||||||
<p><a href="author.php?id=<?= $_SESSION['user_id'] ?>" target="_blank" class="adaptive-button secondary">
|
|
||||||
👁️ Посмотреть мою публичную страницу
|
|
||||||
</a></p>
|
|
||||||
<p><strong>Дата регистрации:</strong> <?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></p>
|
|
||||||
<?php if ($user['last_login']): ?>
|
|
||||||
<p><strong>Последний вход:</strong> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></p>
|
|
||||||
<?php endif; ?>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
|
|
@ -1,192 +1,156 @@
|
||||||
<?php
|
<?php
|
||||||
require_once 'config/config.php';
|
// views/user/view_public.php
|
||||||
require_once 'includes/parsedown/ParsedownExtra.php';
|
include 'views/layouts/header.php';
|
||||||
|
?>
|
||||||
$Parsedown = new ParsedownExtra();
|
|
||||||
|
<div class="container" style="width:100%; margin-left: 0em; margin-right: 0em auto;">
|
||||||
$author_id = (int)($_GET['id'] ?? 0);
|
<article>
|
||||||
if (!$author_id) {
|
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid var(--muted-border-color); padding-bottom: 1rem;">
|
||||||
http_response_code(400);
|
<!-- Аватарка автора -->
|
||||||
echo "<h2>Неверный запрос</h2>";
|
<div style="margin-bottom: 1rem;">
|
||||||
include 'views/footer.php';
|
<?php if (!empty($user['avatar'])): ?>
|
||||||
exit;
|
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
|
||||||
}
|
alt="<?= e($user['display_name'] ?: $user['username']) ?>"
|
||||||
|
style="width: 150px; height: 150px; border-radius: 50%; border: 3px solid var(--primary); object-fit: cover;"
|
||||||
$stmt = $pdo->prepare("SELECT id, username, display_name, avatar, bio FROM users WHERE id = ?");
|
onerror="this.style.display='none'">
|
||||||
$stmt->execute([$author_id]);
|
<?php else: ?>
|
||||||
$author = $stmt->fetch(PDO::FETCH_ASSOC);
|
<div style="width: 150px; height: 150px; border-radius: 50%; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem; margin: 0 auto;">
|
||||||
|
<?= mb_substr(e($user['display_name'] ?: $user['username']), 0, 1) ?>
|
||||||
if (!$author) {
|
</div>
|
||||||
http_response_code(404);
|
<?php endif; ?>
|
||||||
echo "<h2>Автор не найден</h2>";
|
</div>
|
||||||
include 'views/footer.php';
|
|
||||||
exit;
|
<h1 style="margin-bottom: 0.5rem;"><?= e($user['display_name'] ?: $user['username']) ?></h1>
|
||||||
}
|
|
||||||
|
<!-- Биография автора -->
|
||||||
$bookModel = new Book($pdo);
|
<?php if (!empty($user['bio'])): ?>
|
||||||
$books = $bookModel->findByUser($author_id, true); // только опубликованные
|
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
|
||||||
|
<?= e($user['bio']) ?>
|
||||||
// Получаем статистику автора
|
</div>
|
||||||
$total_books = count($books);
|
<?php endif; ?>
|
||||||
$total_words = 0;
|
|
||||||
$total_chapters = 0;
|
<!-- Статистика автора -->
|
||||||
|
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);">
|
||||||
foreach ($books as $book) {
|
<div style="text-align: center;">
|
||||||
$book_stats = $bookModel->getBookStats($book['id'], true);
|
<div style="font-size: 1.5em; font-weight: bold; color: var(--primary);"><?= $total_books ?></div>
|
||||||
$total_words += $book_stats['total_words'] ?? 0;
|
<div>Книг</div>
|
||||||
$total_chapters += $book_stats['chapter_count'] ?? 0;
|
</div>
|
||||||
}
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 1.5em; font-weight: bold; color: var(--success);"><?= $total_chapters ?></div>
|
||||||
$page_title = ($author['display_name'] ?: $author['username']) . ' — публичная страница';
|
<div>Глав</div>
|
||||||
include 'views/header.php';
|
</div>
|
||||||
?>
|
<div style="text-align: center;">
|
||||||
|
<div style="font-size: 1.5em; font-weight: bold; color: var(--warning);"><?= $total_words ?></div>
|
||||||
<div class="container">
|
<div>Слов</div>
|
||||||
<article style="max-width: 800px; margin: 0 auto;">
|
</div>
|
||||||
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid #eee; padding-bottom: 1rem;">
|
</div>
|
||||||
<!-- Аватарка автора -->
|
</header>
|
||||||
<div style="margin-bottom: 1rem;">
|
|
||||||
<?php if (!empty($author['avatar'])): ?>
|
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2>
|
||||||
<img src="<?= AVATARS_URL . e($author['avatar']) ?>"
|
|
||||||
alt="<?= e($author['display_name'] ?: $author['username']) ?>"
|
<?php if (empty($books)): ?>
|
||||||
style="width: 150px; height: 150px; border-radius: 50%; border: 3px solid #007bff; object-fit: cover;"
|
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
|
||||||
onerror="this.style.display='none'">
|
<h3>У этого автора пока нет опубликованных книг</h3>
|
||||||
<?php else: ?>
|
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p>
|
||||||
<div style="width: 150px; height: 150px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem; margin: 0 auto;">
|
</div>
|
||||||
<?= mb_substr(e($author['display_name'] ?: $author['username']), 0, 1) ?>
|
<?php else: ?>
|
||||||
</div>
|
<div class="author-books">
|
||||||
<?php endif; ?>
|
<?php foreach ($books as $book): ?>
|
||||||
</div>
|
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1.5rem; background: var(--card-background-color); border-radius: 8px;">
|
||||||
|
<?php if ($book['cover_image']): ?>
|
||||||
<h1 style="margin-bottom: 0.5rem;"><?= e($author['display_name'] ?: $author['username']) ?></h1>
|
<div style="flex-shrink: 0;">
|
||||||
|
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||||
<!-- Биография автора -->
|
alt="<?= e($book['title']) ?>"
|
||||||
<?php if (!empty($author['bio'])): ?>
|
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);"
|
||||||
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
|
onerror="this.style.display='none'">
|
||||||
<?= $Parsedown->text($author['bio']) ?>
|
</div>
|
||||||
</div>
|
<?php else: ?>
|
||||||
<?php endif; ?>
|
<div style="flex-shrink: 0;">
|
||||||
|
<div class="cover-placeholder" style="width: 120px; height: 160px; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
|
||||||
<!-- Статистика автора -->
|
📚
|
||||||
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
|
</div>
|
||||||
<div style="text-align: center;">
|
</div>
|
||||||
<div style="font-size: 1.5em; font-weight: bold; color: #007bff;"><?= $total_books ?></div>
|
<?php endif; ?>
|
||||||
<div>Книг</div>
|
|
||||||
</div>
|
<div style="flex: 1;">
|
||||||
<div style="text-align: center;">
|
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
|
||||||
<div style="font-size: 1.5em; font-weight: bold; color: #28a745;"><?= $total_chapters ?></div>
|
|
||||||
<div>Глав</div>
|
<?php if ($book['genre']): ?>
|
||||||
</div>
|
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
|
||||||
<div style="text-align: center;">
|
<?php endif; ?>
|
||||||
<div style="font-size: 1.5em; font-weight: bold; color: #6f42c1;"><?= $total_words ?></div>
|
|
||||||
<div>Слов</div>
|
<?php if ($book['description']): ?>
|
||||||
</div>
|
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
|
||||||
</div>
|
<?php endif; ?>
|
||||||
</header>
|
|
||||||
|
<?php
|
||||||
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2>
|
$book_stats = $bookModel->getBookStats($book['id'], true);
|
||||||
|
$chapter_count = $book_stats['chapter_count'] ?? 0;
|
||||||
<?php if (empty($books)): ?>
|
$word_count = $book_stats['total_words'] ?? 0;
|
||||||
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
|
?>
|
||||||
<h3>У этого автора пока нет опубликованных книг</h3>
|
|
||||||
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p>
|
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
|
||||||
</div>
|
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button">
|
||||||
<?php else: ?>
|
Читать книгу
|
||||||
<div class="author-books">
|
</a>
|
||||||
<?php foreach ($books as $book): ?>
|
|
||||||
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1.5rem; background: #f8f9fa; border-radius: 8px;">
|
<small style="color: var(--muted-color);">
|
||||||
<?php if ($book['cover_image']): ?>
|
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?>
|
||||||
<div style="flex-shrink: 0;">
|
</small>
|
||||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
</div>
|
||||||
alt="<?= e($book['title']) ?>"
|
</div>
|
||||||
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;"
|
</article>
|
||||||
onerror="this.style.display='none'">
|
<?php endforeach; ?>
|
||||||
</div>
|
</div>
|
||||||
<?php else: ?>
|
<?php endif; ?>
|
||||||
<div style="flex-shrink: 0;">
|
|
||||||
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
|
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;">
|
||||||
</div>
|
<p style="color: var(--muted-color);">
|
||||||
<?php endif; ?>
|
Страница автора создана в <?= e(APP_NAME) ?> •
|
||||||
|
<?= date('Y') ?>
|
||||||
<div style="flex: 1;">
|
</p>
|
||||||
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
|
</footer>
|
||||||
|
</article>
|
||||||
<?php if ($book['genre']): ?>
|
</div>
|
||||||
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
|
|
||||||
<?php endif; ?>
|
<style>
|
||||||
|
.author-books article {
|
||||||
<?php if ($book['description']): ?>
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
|
border: 1px solid var(--border-color);
|
||||||
<?php endif; ?>
|
}
|
||||||
|
|
||||||
<?php
|
.author-books article:hover {
|
||||||
$book_stats = $bookModel->getBookStats($book['id'], true);
|
transform: translateY(-2px);
|
||||||
$chapter_count = $book_stats['chapter_count'] ?? 0;
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
$word_count = $book_stats['total_words'] ?? 0;
|
}
|
||||||
?>
|
|
||||||
|
.cover-placeholder {
|
||||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
|
width: 120px;
|
||||||
<a href="view_book.php?share_token=<?= e($book['share_token']) ?>" class="adaptive-button">
|
height: 160px;
|
||||||
Читать книгу
|
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||||
</a>
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
<small style="color: #666;">
|
align-items: center;
|
||||||
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?>
|
justify-content: center;
|
||||||
</small>
|
color: white;
|
||||||
</div>
|
font-size: 2rem;
|
||||||
</div>
|
}
|
||||||
</article>
|
|
||||||
<?php endforeach; ?>
|
@media (max-width: 768px) {
|
||||||
</div>
|
.author-books article {
|
||||||
<?php endif; ?>
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid #eee; text-align: center;">
|
}
|
||||||
<p style="color: #666;">
|
|
||||||
Страница автора создана в <?= e(APP_NAME) ?> •
|
.author-books .book-cover {
|
||||||
<?= date('Y') ?>
|
align-self: center;
|
||||||
</p>
|
}
|
||||||
</footer>
|
|
||||||
</article>
|
header .author-stats {
|
||||||
</div>
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
<style>
|
}
|
||||||
.author-books article {
|
}
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
</style>
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
}
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
||||||
.author-books article:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.cover-placeholder {
|
|
||||||
width: 120px;
|
|
||||||
height: 160px;
|
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.author-books article {
|
|
||||||
flex-direction: column;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.author-books .book-cover {
|
|
||||||
align-self: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
header .author-stats {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<?php include 'views/footer.php'; ?>
|
|
||||||
Loading…
Reference in New Issue