many fixes in design and add shirt story type for creation book
This commit is contained in:
parent
9294399517
commit
ce6fffaa57
|
|
@ -1,5 +1,5 @@
|
||||||
/*!
|
/*!
|
||||||
* Quill Editor v2.0.3
|
* Quill Editor v2.0.2
|
||||||
* https://quilljs.com
|
* https://quilljs.com
|
||||||
* Copyright (c) 2017-2024, Slab
|
* Copyright (c) 2017-2024, Slab
|
||||||
* Copyright (c) 2014, Jason Chen
|
* Copyright (c) 2014, Jason Chen
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,6 @@
|
||||||
.writer-editor-container.fullscreen {
|
.writer-editor-container.fullscreen {
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
.ql-editor p {
|
||||||
|
text-indent: 2em;
|
||||||
|
}
|
||||||
|
|
@ -1,73 +1,157 @@
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const quill = window.quillEditorInstance;
|
// Поддержка WriterEditor (для глав и рассказов)
|
||||||
const textarea = window.quillTextarea;
|
const writerEditor = window.writerEditor;
|
||||||
if (!quill || !textarea) return;
|
if (writerEditor) {
|
||||||
|
const textarea = writerEditor.textarea;
|
||||||
|
if (!textarea) return;
|
||||||
|
|
||||||
let lastSavedContent = textarea.value;
|
// Для рассказов textarea может быть hidden input
|
||||||
let saveTimeout;
|
const contentField = textarea.tagName === 'TEXTAREA' ? textarea :
|
||||||
|
(document.getElementById('story-content') ? document.getElementById('story-content') : textarea);
|
||||||
|
|
||||||
function showMessage(message, isError = false) {
|
let lastSavedContent = contentField.value;
|
||||||
let msgEl = document.getElementById('autosave-message');
|
let saveTimeout;
|
||||||
if (!msgEl) {
|
|
||||||
msgEl = document.createElement('div');
|
|
||||||
msgEl.id = 'autosave-message';
|
|
||||||
msgEl.style.cssText = `
|
|
||||||
position: fixed;
|
|
||||||
top: 70px;
|
|
||||||
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;
|
|
||||||
msgEl.style.background = isError ? '#dc3545' : '#28a745';
|
|
||||||
msgEl.style.display = 'block';
|
|
||||||
setTimeout(() => msgEl.style.display = 'none', 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const autoSave = () => {
|
function showMessage(message, isError = false) {
|
||||||
const currentContent = textarea.value;
|
let msgEl = document.getElementById('autosave-message');
|
||||||
if (currentContent === lastSavedContent) return;
|
if (!msgEl) {
|
||||||
|
msgEl = document.createElement('div');
|
||||||
const form = document.getElementById('chapter-form');
|
msgEl.id = 'autosave-message';
|
||||||
const formData = new FormData(form);
|
msgEl.style.cssText = `
|
||||||
formData.append('autosave', 'true');
|
position: fixed;
|
||||||
|
top: 70px;
|
||||||
showMessage('Сохранение...');
|
right: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
fetch(window.location.href, {
|
background: ${isError ? '#dc3545' : '#28a745'};
|
||||||
method: 'POST',
|
color: white;
|
||||||
body: formData
|
border-radius: 3px;
|
||||||
})
|
z-index: 10000;
|
||||||
.then(res => {
|
font-size: 0.8rem;
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||||
return res.json();
|
`;
|
||||||
})
|
document.body.appendChild(msgEl);
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
lastSavedContent = currentContent;
|
|
||||||
showMessage('Автосохранено: ' + new Date().toLocaleTimeString());
|
|
||||||
} else {
|
|
||||||
throw new Error(data.error || 'Ошибка сервера');
|
|
||||||
}
|
}
|
||||||
})
|
msgEl.textContent = message;
|
||||||
.catch(err => {
|
msgEl.style.background = isError ? '#dc3545' : '#28a745';
|
||||||
console.error(err);
|
msgEl.style.display = 'block';
|
||||||
showMessage('Ошибка автосохранения: ' + err.message, true);
|
setTimeout(() => msgEl.style.display = 'none', 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoSave = () => {
|
||||||
|
const currentContent = contentField.value;
|
||||||
|
if (currentContent === lastSavedContent) return;
|
||||||
|
|
||||||
|
const form = writerEditor.form;
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.append('autosave', 'true');
|
||||||
|
formData.append('content', currentContent);
|
||||||
|
|
||||||
|
showMessage('Сохранение...');
|
||||||
|
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
lastSavedContent = currentContent;
|
||||||
|
showMessage('Автосохранено: ' + new Date().toLocaleTimeString());
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Ошибка сервера');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
showMessage('Ошибка автосохранения: ' + err.message, true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
writerEditor.quill.on('text-change', () => {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
saveTimeout = setTimeout(autoSave, 2000);
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
quill.on('text-change', () => {
|
// Периодическая автосохранение
|
||||||
clearTimeout(saveTimeout);
|
setInterval(autoSave, 30000);
|
||||||
saveTimeout = setTimeout(autoSave, 2000);
|
} else {
|
||||||
});
|
// Старая поддержка через window.quillEditorInstance
|
||||||
|
const quill = window.quillEditorInstance;
|
||||||
|
const textarea = window.quillTextarea;
|
||||||
|
if (!quill || !textarea) return;
|
||||||
|
|
||||||
// Периодическая автосохранение
|
let lastSavedContent = textarea.value;
|
||||||
setInterval(autoSave, 30000);
|
let saveTimeout;
|
||||||
|
|
||||||
|
function showMessage(message, isError = false) {
|
||||||
|
let msgEl = document.getElementById('autosave-message');
|
||||||
|
if (!msgEl) {
|
||||||
|
msgEl = document.createElement('div');
|
||||||
|
msgEl.id = 'autosave-message';
|
||||||
|
msgEl.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 70px;
|
||||||
|
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;
|
||||||
|
msgEl.style.background = isError ? '#dc3545' : '#28a745';
|
||||||
|
msgEl.style.display = 'block';
|
||||||
|
setTimeout(() => msgEl.style.display = 'none', 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
const autoSave = () => {
|
||||||
|
const currentContent = textarea.value;
|
||||||
|
if (currentContent === lastSavedContent) return;
|
||||||
|
|
||||||
|
const form = document.getElementById('chapter-form');
|
||||||
|
const formData = new FormData(form);
|
||||||
|
formData.append('autosave', 'true');
|
||||||
|
formData.append('content', currentContent);
|
||||||
|
|
||||||
|
showMessage('Сохранение...');
|
||||||
|
|
||||||
|
fetch(window.location.href, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
lastSavedContent = currentContent;
|
||||||
|
showMessage('Автосохранено: ' + new Date().toLocaleTimeString());
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Ошибка сервера');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
showMessage('Ошибка автосохранения: ' + err.message, true);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
quill.on('text-change', () => {
|
||||||
|
clearTimeout(saveTimeout);
|
||||||
|
saveTimeout = setTimeout(autoSave, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Периодическая автосохранение
|
||||||
|
setInterval(autoSave, 30000);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,34 @@
|
||||||
// editor.js
|
// editor.js
|
||||||
|
|
||||||
|
// Глобальная функция переключения редактора рассказа
|
||||||
|
function toggleStoryEditor() {
|
||||||
|
const checkbox = document.getElementById('is_short_story');
|
||||||
|
const storyEditorCard = document.getElementById('story-editor-card');
|
||||||
|
const chaptersCard = document.getElementById('chapters-card');
|
||||||
|
|
||||||
|
if (!checkbox || !storyEditorCard) return;
|
||||||
|
|
||||||
|
const isChecked = checkbox.checked;
|
||||||
|
|
||||||
|
if (isChecked) {
|
||||||
|
storyEditorCard.style.display = 'block';
|
||||||
|
chaptersCard.style.display = 'none';
|
||||||
|
if (!window.writerEditor) {
|
||||||
|
const textarea = document.getElementById('content');
|
||||||
|
if (textarea) {
|
||||||
|
window.writerEditor = new WriterEditor('#book-form', 'story-editor', 'content', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
storyEditorCard.style.display = 'none';
|
||||||
|
chaptersCard.style.display = 'block';
|
||||||
|
if (window.writerEditor) {
|
||||||
|
window.writerEditor.quill.destroy();
|
||||||
|
window.writerEditor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class DialogueFormatter {
|
class DialogueFormatter {
|
||||||
constructor(quill) {
|
constructor(quill) {
|
||||||
this.quill = quill;
|
this.quill = quill;
|
||||||
|
|
@ -79,10 +109,11 @@ class DialogueFormatter {
|
||||||
}
|
}
|
||||||
|
|
||||||
class WriterEditor {
|
class WriterEditor {
|
||||||
constructor(formSelector = '#chapter-form', editorContainerId = 'quill-editor', textareaId = 'content') {
|
constructor(formSelector = '#chapter-form', editorContainerId = 'quill-editor', textareaId = 'content', isShortStory = false) {
|
||||||
this.form = document.querySelector(formSelector);
|
this.form = document.querySelector(formSelector);
|
||||||
this.editorContainer = document.getElementById(editorContainerId);
|
this.editorContainer = document.getElementById(editorContainerId);
|
||||||
this.textarea = document.getElementById(textareaId);
|
this.textarea = document.getElementById(textareaId);
|
||||||
|
this.isShortStory = isShortStory;
|
||||||
this.isFullscreen = false;
|
this.isFullscreen = false;
|
||||||
this.originalStyles = {};
|
this.originalStyles = {};
|
||||||
this.init();
|
this.init();
|
||||||
|
|
@ -91,26 +122,37 @@ class WriterEditor {
|
||||||
init() {
|
init() {
|
||||||
if (!this.editorContainer || !this.textarea || !this.form) return;
|
if (!this.editorContainer || !this.textarea || !this.form) return;
|
||||||
|
|
||||||
|
// Упрощённый тулбар для рассказов
|
||||||
|
const toolbarOptions = this.isShortStory
|
||||||
|
? [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline'],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
['link', 'blockquote', 'code-block'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||||
|
['bold','italic','underline','strike'],
|
||||||
|
[{ 'align': [] }],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
['blockquote','code-block'],
|
||||||
|
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||||
|
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||||
|
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||||
|
['dialogue', 'undodialogue'],
|
||||||
|
[{ 'size': ['small', false, 'large', 'huge'] }],
|
||||||
|
[{ 'font': [] }],
|
||||||
|
['link','image','video'],
|
||||||
|
['clean'],
|
||||||
|
['fullscreen']
|
||||||
|
];
|
||||||
|
|
||||||
this.quill = new Quill(this.editorContainer, {
|
this.quill = new Quill(this.editorContainer, {
|
||||||
theme: 'snow',
|
theme: 'snow',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: {
|
toolbar: {
|
||||||
container: [
|
container: toolbarOptions,
|
||||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
|
||||||
['bold','italic','underline','strike'],
|
|
||||||
[{ 'align': [] }],
|
|
||||||
[{ 'color': [] }, { 'background': [] }],
|
|
||||||
['blockquote','code-block'],
|
|
||||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
|
||||||
[{ 'script': 'sub'}, { 'script': 'super' }],
|
|
||||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
|
||||||
['dialogue', 'undodialogue'],
|
|
||||||
[{ 'size': ['small', false, 'large', 'huge'] }],
|
|
||||||
[{ 'font': [] }],
|
|
||||||
['link','image','video'],
|
|
||||||
['clean'],
|
|
||||||
['fullscreen'] // Добавляем кнопку полноэкранного режима
|
|
||||||
],
|
|
||||||
handlers: {
|
handlers: {
|
||||||
'dialogue': () => {
|
'dialogue': () => {
|
||||||
if (this.dialogueFormatter) {
|
if (this.dialogueFormatter) {
|
||||||
|
|
@ -142,17 +184,18 @@ class WriterEditor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}, // Упрощённый тулбар для рассказов
|
||||||
placeholder: 'Введите текст главы...'
|
placeholder: this.isShortStory ? 'Напишите здесь ваш рассказ...' : 'Введите текст главы...'
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addCustomButtonsToToolbar();
|
this.dialogueFormatter = this.isShortStory ? null : new DialogueFormatter(this.quill);
|
||||||
this.dialogueFormatter = new DialogueFormatter(this.quill);
|
|
||||||
|
|
||||||
const rawContent = this.editorContainer.dataset.content || '';
|
const rawContent = this.editorContainer.dataset.content || '';
|
||||||
if (rawContent.trim()) this.quill.root.innerHTML = rawContent.trim();
|
if (rawContent.trim()) this.quill.root.innerHTML = rawContent.trim();
|
||||||
|
|
||||||
setTimeout(() => this.formatExistingDialogues(), 100);
|
if (!this.isShortStory) {
|
||||||
|
setTimeout(() => this.formatExistingDialogues(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
const sync = () => {
|
const sync = () => {
|
||||||
let html = this.quill.root.innerHTML;
|
let html = this.quill.root.innerHTML;
|
||||||
|
|
@ -161,7 +204,10 @@ class WriterEditor {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.quill.on('text-change', sync);
|
this.quill.on('text-change', sync);
|
||||||
this.form.addEventListener('submit', sync);
|
const submitHandler = function(e) {
|
||||||
|
sync();
|
||||||
|
};
|
||||||
|
this.form.addEventListener('submit', submitHandler);
|
||||||
|
|
||||||
// Обработчик изменения ориентации для мобильных устройств
|
// Обработчик изменения ориентации для мобильных устройств
|
||||||
window.addEventListener('orientationchange', () => {
|
window.addEventListener('orientationchange', () => {
|
||||||
|
|
@ -349,8 +395,47 @@ class WriterEditor {
|
||||||
totalOffset += line.length + 1;
|
totalOffset += line.length + 1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addCustomButtonsToToolbar() {
|
||||||
|
if (this.isShortStory) return;
|
||||||
|
|
||||||
|
const toolbar = this.quill.container.previousSibling;
|
||||||
|
if (!toolbar) return;
|
||||||
|
|
||||||
|
const dialogueBtn = toolbar.querySelector('.ql-dialogue');
|
||||||
|
const undoDialogueBtn = toolbar.querySelector('.ql-undodialogue');
|
||||||
|
const fullscreenBtn = toolbar.querySelector('.ql-fullscreen');
|
||||||
|
|
||||||
|
if (dialogueBtn) {
|
||||||
|
dialogueBtn.innerHTML = '—';
|
||||||
|
dialogueBtn.title = 'Форматировать диалоги (—)';
|
||||||
|
dialogueBtn.style.fontWeight = 'bold';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (undoDialogueBtn) {
|
||||||
|
undoDialogueBtn.innerHTML = '-';
|
||||||
|
undoDialogueBtn.title = 'Убрать форматирование диалогов (-)';
|
||||||
|
undoDialogueBtn.style.fontWeight = 'bold';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fullscreenBtn) {
|
||||||
|
fullscreenBtn.innerHTML = '⛶';
|
||||||
|
fullscreenBtn.title = 'Полноэкранный режим';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
window.writerEditor = new WriterEditor();
|
// Проверяем, это редактор рассказа или главы
|
||||||
|
const storyEditor = document.getElementById('story-editor');
|
||||||
|
const quillEditor = document.getElementById('quill-editor');
|
||||||
|
|
||||||
|
if (storyEditor) {
|
||||||
|
const textarea = document.getElementById('story-content');
|
||||||
|
if (textarea) {
|
||||||
|
window.writerEditor = new WriterEditor('#book-form', 'story-editor', 'story-content', true);
|
||||||
|
}
|
||||||
|
} else if (quillEditor) {
|
||||||
|
window.writerEditor = new WriterEditor();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -19,8 +19,12 @@ class AuthController extends BaseController {
|
||||||
} else {
|
} else {
|
||||||
$username = trim($_POST['username'] ?? '');
|
$username = trim($_POST['username'] ?? '');
|
||||||
$password = $_POST['password'] ?? '';
|
$password = $_POST['password'] ?? '';
|
||||||
|
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||||
|
|
||||||
if (empty($username) || empty($password)) {
|
// Проверка Rate Limit
|
||||||
|
if (!checkRateLimit($this->pdo, $ip)) {
|
||||||
|
$error = 'Слишком много попыток входа. Попробуйте позже.';
|
||||||
|
} elseif (empty($username) || empty($password)) {
|
||||||
$error = 'Пожалуйста, введите имя пользователя и пароль';
|
$error = 'Пожалуйста, введите имя пользователя и пароль';
|
||||||
} else {
|
} else {
|
||||||
$userModel = new User($this->pdo);
|
$userModel = new User($this->pdo);
|
||||||
|
|
@ -29,8 +33,12 @@ class AuthController extends BaseController {
|
||||||
if ($user && $userModel->verifyPassword($password, $user['password_hash'])) {
|
if ($user && $userModel->verifyPassword($password, $user['password_hash'])) {
|
||||||
if (!$user['is_active']) {
|
if (!$user['is_active']) {
|
||||||
$error = 'Ваш аккаунт деактивирован или ожидает активации администратором.';
|
$error = 'Ваш аккаунт деактивирован или ожидает активации администратором.';
|
||||||
|
logLoginAttempt($this->pdo, $ip, $username);
|
||||||
} else {
|
} else {
|
||||||
// Успешный вход
|
// Успешный вход - очищаем попытки
|
||||||
|
clearLoginAttempts($this->pdo, $ip);
|
||||||
|
|
||||||
|
// Обновляем сессию
|
||||||
session_regenerate_id(true);
|
session_regenerate_id(true);
|
||||||
$_SESSION['user_id'] = $user['id'];
|
$_SESSION['user_id'] = $user['id'];
|
||||||
$_SESSION['username'] = $user['username'];
|
$_SESSION['username'] = $user['username'];
|
||||||
|
|
@ -45,6 +53,7 @@ class AuthController extends BaseController {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$error = 'Неверное имя пользователя или пароль';
|
$error = 'Неверное имя пользователя или пароль';
|
||||||
|
logLoginAttempt($this->pdo, $ip, $username);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,16 @@ class BookController extends BaseController {
|
||||||
$this->redirect('/books/create');
|
$this->redirect('/books/create');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$is_short_story = isset($_POST['is_short_story']) ? 1 : 0;
|
||||||
|
$content = $is_short_story ? ($_POST['content'] ?? '') : null;
|
||||||
|
|
||||||
|
// Валидация: для рассказа проверяем, что есть хоть какое-то содержание
|
||||||
|
if ($is_short_story && empty(trim(strip_tags($content)))) {
|
||||||
|
$_SESSION['error'] = "Для рассказа необходимо заполнить содержание";
|
||||||
|
$_SESSION['form_data'] = $_POST;
|
||||||
|
$this->redirect('/books/create');
|
||||||
|
}
|
||||||
|
|
||||||
$bookModel = new Book($this->pdo);
|
$bookModel = new Book($this->pdo);
|
||||||
$data = [
|
$data = [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
|
|
@ -44,7 +54,9 @@ class BookController extends BaseController {
|
||||||
'user_id' => $_SESSION['user_id'],
|
'user_id' => $_SESSION['user_id'],
|
||||||
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
|
'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,
|
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
|
||||||
'published' => isset($_POST['published']) ? 1 : 0
|
'published' => isset($_POST['published']) ? 1 : 0,
|
||||||
|
'is_short_story' => $is_short_story,
|
||||||
|
'content' => $content
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($bookModel->create($data)) {
|
if ($bookModel->create($data)) {
|
||||||
|
|
@ -62,8 +74,15 @@ class BookController extends BaseController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : "");
|
// Если это рассказ с содержанием - редиректим на редактирование
|
||||||
|
// Если рассказ без содержания - тоже на редактирование (чтобы добавить текст)
|
||||||
|
if ($is_short_story) {
|
||||||
|
$_SESSION['success'] = "Рассказ успешно создан! Теперь можно добавить содержание.";
|
||||||
$this->redirect("/books/{$new_book_id}/edit");
|
$this->redirect("/books/{$new_book_id}/edit");
|
||||||
|
} else {
|
||||||
|
$_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : "");
|
||||||
|
$this->redirect("/books/{$new_book_id}/chapters/create");
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$_SESSION['error'] = "Ошибка при создании книги";
|
$_SESSION['error'] = "Ошибка при создании книги";
|
||||||
}
|
}
|
||||||
|
|
@ -91,11 +110,63 @@ class BookController extends BaseController {
|
||||||
$seriesModel = new Series($this->pdo);
|
$seriesModel = new Series($this->pdo);
|
||||||
$series = $seriesModel->findByUser($_SESSION['user_id']);
|
$series = $seriesModel->findByUser($_SESSION['user_id']);
|
||||||
|
|
||||||
|
|
||||||
$error = '';
|
$error = '';
|
||||||
$cover_error = '';
|
$cover_error = '';
|
||||||
|
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// Обработка запроса автосохранения
|
||||||
|
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
// Подавляем ошибки для автосейва
|
||||||
|
error_reporting(0);
|
||||||
|
|
||||||
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ошибка безопасности']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$book = $bookModel->findById($id);
|
||||||
|
if (!$book || $book['user_id'] != $_SESSION['user_id']) {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Книга не найдена']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$is_short_story = isset($_POST['is_short_story']) ? 1 : 0;
|
||||||
|
$content = $is_short_story ? ($_POST['content'] ?? '') : null;
|
||||||
|
|
||||||
|
// Для autosave обновляем только title и user_id из сессии, остальное получаем из базы
|
||||||
|
$title = trim($_POST['title'] ?? $book['title']);
|
||||||
|
$description = trim($_POST['description'] ?? ($book['description'] ?? ''));
|
||||||
|
$genre = trim($_POST['genre'] ?? ($book['genre'] ?? ''));
|
||||||
|
$user_id = $_SESSION['user_id'];
|
||||||
|
$series_id = !empty($_POST['series_id']) ? (int)$_POST['series_id'] : $book['series_id'];
|
||||||
|
$sort_order_in_series = !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : $book['sort_order_in_series'];
|
||||||
|
$published = isset($_POST['published']) ? 1 : $book['published'];
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => $description,
|
||||||
|
'genre' => $genre,
|
||||||
|
'user_id' => $user_id,
|
||||||
|
'series_id' => $series_id,
|
||||||
|
'sort_order_in_series' => $sort_order_in_series,
|
||||||
|
'published' => $published,
|
||||||
|
'is_short_story' => $is_short_story,
|
||||||
|
'content' => $content
|
||||||
|
];
|
||||||
|
|
||||||
|
$success = $bookModel->update($id, $data);
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
if ($success) {
|
||||||
|
echo json_encode(['success' => true]);
|
||||||
|
} else {
|
||||||
|
echo json_encode(['success' => false, 'error' => 'Ошибка при сохранении']);
|
||||||
|
}
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
|
||||||
$error = "Ошибка безопасности";
|
$error = "Ошибка безопасности";
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -103,6 +174,9 @@ class BookController extends BaseController {
|
||||||
if (empty($title)) {
|
if (empty($title)) {
|
||||||
$error = "Название книги обязательно";
|
$error = "Название книги обязательно";
|
||||||
} else {
|
} else {
|
||||||
|
$is_short_story = isset($_POST['is_short_story']) ? 1 : 0;
|
||||||
|
$content = $is_short_story ? ($_POST['content'] ?? '') : null;
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'description' => trim($_POST['description'] ?? ''),
|
'description' => trim($_POST['description'] ?? ''),
|
||||||
|
|
@ -110,7 +184,9 @@ class BookController extends BaseController {
|
||||||
'user_id' => $_SESSION['user_id'],
|
'user_id' => $_SESSION['user_id'],
|
||||||
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
|
'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,
|
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
|
||||||
'published' => isset($_POST['published']) ? 1 : 0
|
'published' => isset($_POST['published']) ? 1 : 0,
|
||||||
|
'is_short_story' => $is_short_story,
|
||||||
|
'content' => $content
|
||||||
];
|
];
|
||||||
|
|
||||||
// Обработка обложки
|
// Обработка обложки
|
||||||
|
|
@ -252,7 +328,15 @@ class BookController extends BaseController {
|
||||||
$this->render('errors/404');
|
$this->render('errors/404');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
|
||||||
|
// Если это рассказ - не загружаем главы
|
||||||
|
if ($book['is_short_story']) {
|
||||||
|
$chapters = [];
|
||||||
|
$is_short_story = true;
|
||||||
|
} else {
|
||||||
|
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
||||||
|
$is_short_story = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем информацию об авторе
|
// Получаем информацию об авторе
|
||||||
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
||||||
|
|
@ -263,6 +347,7 @@ class BookController extends BaseController {
|
||||||
'book' => $book,
|
'book' => $book,
|
||||||
'chapters' => $chapters,
|
'chapters' => $chapters,
|
||||||
'author' => $author,
|
'author' => $author,
|
||||||
|
'is_short_story' => $is_short_story,
|
||||||
'page_title' => $book['title']
|
'page_title' => $book['title']
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
@ -279,7 +364,15 @@ class BookController extends BaseController {
|
||||||
$this->render('errors/404');
|
$this->render('errors/404');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$chapters = $chapterModel->findByBook($book['id']);
|
|
||||||
|
// Если это рассказ - не загружаем главы
|
||||||
|
if ($book['is_short_story']) {
|
||||||
|
$chapters = [];
|
||||||
|
$is_short_story = true;
|
||||||
|
} else {
|
||||||
|
$chapters = $chapterModel->findByBook($book['id']);
|
||||||
|
$is_short_story = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем информацию об авторе
|
// Получаем информацию об авторе
|
||||||
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
|
||||||
|
|
@ -290,6 +383,7 @@ class BookController extends BaseController {
|
||||||
'book' => $book,
|
'book' => $book,
|
||||||
'chapters' => $chapters,
|
'chapters' => $chapters,
|
||||||
'author' => $author,
|
'author' => $author,
|
||||||
|
'is_short_story' => $is_short_story,
|
||||||
'page_title' => $book['title']
|
'page_title' => $book['title']
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,13 +26,19 @@ class ExportController extends BaseController {
|
||||||
$this->redirect('/books');
|
$this->redirect('/books');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для автора - все главы
|
// Для автора - все главы или контент рассказа
|
||||||
$chapters = $chapterModel->findByBook($book_id);
|
if ($book['is_short_story']) {
|
||||||
|
$chapters = [];
|
||||||
|
$is_short_story = true;
|
||||||
|
} else {
|
||||||
|
$chapters = $chapterModel->findByBook($book_id);
|
||||||
|
$is_short_story = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем информацию об авторе
|
// Получаем информацию об авторе
|
||||||
$author_name = $this->getAuthorName($book['user_id']);
|
$author_name = $this->getAuthorName($book['user_id']);
|
||||||
|
|
||||||
$this->handleExport($book, $chapters, false, $author_name, $format);
|
$this->handleExport($book, $chapters, false, $author_name, $format, $is_short_story);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function exportShared($share_token, $format = 'pdf') {
|
public function exportShared($share_token, $format = 'pdf') {
|
||||||
|
|
@ -45,13 +51,19 @@ class ExportController extends BaseController {
|
||||||
$this->redirect('/');
|
$this->redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Для публичного доступа - только опубликованные главы
|
// Для публичного доступа - только опубликованные главы или контент рассказа
|
||||||
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
if ($book['is_short_story']) {
|
||||||
|
$chapters = [];
|
||||||
|
$is_short_story = true;
|
||||||
|
} else {
|
||||||
|
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
||||||
|
$is_short_story = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Получаем информацию об авторе
|
// Получаем информацию об авторе
|
||||||
$author_name = $this->getAuthorName($book['user_id']);
|
$author_name = $this->getAuthorName($book['user_id']);
|
||||||
|
|
||||||
$this->handleExport($book, $chapters, true, $author_name, $format);
|
$this->handleExport($book, $chapters, true, $author_name, $format, $is_short_story);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function getAuthorName($user_id) {
|
private function getAuthorName($user_id) {
|
||||||
|
|
@ -68,21 +80,20 @@ class ExportController extends BaseController {
|
||||||
return "Неизвестный автор";
|
return "Неизвестный автор";
|
||||||
}
|
}
|
||||||
|
|
||||||
private function handleExport($book, $chapters, $is_public, $author_name, $format) {
|
private function handleExport($book, $chapters, $is_public, $author_name, $format, $is_short_story = false) {
|
||||||
|
|
||||||
|
|
||||||
switch ($format) {
|
switch ($format) {
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
$this->exportPDF($book, $chapters, $is_public, $author_name);
|
$this->exportPDF($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||||
break;
|
break;
|
||||||
case 'docx':
|
case 'docx':
|
||||||
$this->exportDOCX($book, $chapters, $is_public, $author_name);
|
$this->exportDOCX($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||||
break;
|
break;
|
||||||
case 'html':
|
case 'html':
|
||||||
$this->exportHTML($book, $chapters, $is_public, $author_name);
|
$this->exportHTML($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||||
break;
|
break;
|
||||||
case 'txt':
|
case 'txt':
|
||||||
$this->exportTXT($book, $chapters, $is_public, $author_name);
|
$this->exportTXT($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
$_SESSION['error'] = "Неверный формат экспорта";
|
$_SESSION['error'] = "Неверный формат экспорта";
|
||||||
|
|
@ -93,8 +104,7 @@ class ExportController extends BaseController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportPDF($book, $chapters, $is_public, $author_name) {
|
function exportPDF($book, $chapters, $is_public, $author_name, $is_short_story = false) {
|
||||||
|
|
||||||
|
|
||||||
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
|
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
|
||||||
|
|
||||||
|
|
@ -157,8 +167,17 @@ class ExportController extends BaseController {
|
||||||
$pdf->Ln(10);
|
$pdf->Ln(10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Интерактивное оглавление
|
// Если это рассказ - сразу выводим контент
|
||||||
$chapterLinks = [];
|
if ($is_short_story && !empty($book['content'])) {
|
||||||
|
$pdf->SetFont('dejavusans', '', 11);
|
||||||
|
$pdf->writeHTML($book['content'], true, false, true, false, '');
|
||||||
|
$pdf->Ln(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерактивное оглавление (только для обычных книг)
|
||||||
|
if (!$is_short_story) {
|
||||||
|
$chapterLinks = [];
|
||||||
|
if (!empty($chapters)) {
|
||||||
if (!empty($chapters)) {
|
if (!empty($chapters)) {
|
||||||
$pdf->SetFont('dejavusans', 'B', 14);
|
$pdf->SetFont('dejavusans', 'B', 14);
|
||||||
$pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C');
|
$pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C');
|
||||||
|
|
@ -220,7 +239,7 @@ class ExportController extends BaseController {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportDOCX($book, $chapters, $is_public, $author_name) {
|
function exportDOCX($book, $chapters, $is_public, $author_name, $is_short_story = false) {
|
||||||
|
|
||||||
$phpWord = new PhpWord();
|
$phpWord = new PhpWord();
|
||||||
|
|
||||||
|
|
@ -271,8 +290,19 @@ class ExportController extends BaseController {
|
||||||
$section->addTextBreak(2);
|
$section->addTextBreak(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Интерактивное оглавление
|
// Если это рассказ - сразу выводим контент
|
||||||
if (!empty($chapters)) {
|
if ($is_short_story && !empty($book['content'])) {
|
||||||
|
$paragraphs = $this->htmlToParagraphs($book['content']);
|
||||||
|
foreach ($paragraphs as $paragraph) {
|
||||||
|
if (!empty(trim($paragraph))) {
|
||||||
|
$section->addText($paragraph);
|
||||||
|
$section->addTextBreak(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерактивное оглавление (только для обычных книг)
|
||||||
|
if (!$is_short_story && !empty($chapters)) {
|
||||||
$section->addText('Оглавление', ['bold' => true, 'size' => 14], ['alignment' => 'center']);
|
$section->addText('Оглавление', ['bold' => true, 'size' => 14], ['alignment' => 'center']);
|
||||||
$section->addTextBreak(1);
|
$section->addTextBreak(1);
|
||||||
|
|
||||||
|
|
@ -286,10 +316,13 @@ class ExportController extends BaseController {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Разделитель
|
// Разделитель
|
||||||
$section->addPageBreak();
|
if (!$is_short_story) {
|
||||||
|
$section->addPageBreak();
|
||||||
|
}
|
||||||
|
|
||||||
// Главы с закладками
|
// Главы с закладками (только для обычных книг)
|
||||||
foreach ($chapters as $index => $chapter) {
|
if (!$is_short_story) {
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
// Добавляем закладку для главы
|
// Добавляем закладку для главы
|
||||||
$section->addBookmark("chapter_{$chapter['id']}");
|
$section->addBookmark("chapter_{$chapter['id']}");
|
||||||
|
|
||||||
|
|
@ -332,7 +365,7 @@ class ExportController extends BaseController {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportHTML($book, $chapters, $is_public, $author_name) {
|
function exportHTML($book, $chapters, $is_public, $author_name, $is_short_story = false) {
|
||||||
|
|
||||||
$html = '<!DOCTYPE html>
|
$html = '<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
|
|
@ -514,8 +547,13 @@ class ExportController extends BaseController {
|
||||||
$html .= '</div>';
|
$html .= '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Интерактивное оглавление
|
// Если это рассказ - выводим контент сразу
|
||||||
if (!empty($chapters)) {
|
if ($is_short_story && !empty($book['content'])) {
|
||||||
|
$html .= '<div class="chapter-content">' . $book['content'] . '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерактивное оглавление (только для обычных книг)
|
||||||
|
if (!$is_short_story && !empty($chapters)) {
|
||||||
$html .= '<div class="table-of-contents">';
|
$html .= '<div class="table-of-contents">';
|
||||||
$html .= '<h3>Оглавление</h3>';
|
$html .= '<h3>Оглавление</h3>';
|
||||||
$html .= '<ul>';
|
$html .= '<ul>';
|
||||||
|
|
@ -554,7 +592,7 @@ class ExportController extends BaseController {
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportTXT($book, $chapters, $is_public, $author_name) {
|
function exportTXT($book, $chapters, $is_public, $author_name, $is_short_story = false) {
|
||||||
$content = "=" . str_repeat("=", 80) . "=\n";
|
$content = "=" . str_repeat("=", 80) . "=\n";
|
||||||
$content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n";
|
$content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n";
|
||||||
$content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n";
|
$content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n";
|
||||||
|
|
@ -578,20 +616,39 @@ class ExportController extends BaseController {
|
||||||
$content .= "\n";
|
$content .= "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Оглавление
|
// Если это рассказ - выводим контент сразу
|
||||||
if (!empty($chapters)) {
|
if ($is_short_story && !empty($book['content'])) {
|
||||||
$content .= "ОГЛАВЛЕНИЕ:\n";
|
$content .= "СОДЕРЖАНИЕ РАССКАЗА:\n";
|
||||||
$content .= str_repeat("-", 60) . "\n";
|
$content .= str_repeat("-", 60) . "\n\n";
|
||||||
foreach ($chapters as $index => $chapter) {
|
|
||||||
$chapter_number = $index + 1;
|
|
||||||
$content .= "{$chapter_number}. {$chapter['title']}\n";
|
|
||||||
}
|
|
||||||
$content .= "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
$content .= str_repeat("-", 144) . "\n\n";
|
$plainText = $this->htmlToPlainText($book['content']);
|
||||||
|
$paragraphs = explode("\n", $plainText);
|
||||||
|
|
||||||
foreach ($chapters as $index => $chapter) {
|
foreach ($paragraphs as $paragraph) {
|
||||||
|
$trimmed = trim($paragraph);
|
||||||
|
if (!empty($trimmed)) {
|
||||||
|
$wrapped = wordwrap($trimmed, 144);
|
||||||
|
$content .= $wrapped . "\n\n";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Оглавление (только для обычных книг)
|
||||||
|
if (!$is_short_story && !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";
|
||||||
|
|
||||||
|
// Главы (только для обычных книг)
|
||||||
|
if (!$is_short_story) {
|
||||||
|
foreach ($chapters as $index => $chapter) {
|
||||||
$content .= $chapter['title'] . "\n";
|
$content .= $chapter['title'] . "\n";
|
||||||
$content .= str_repeat("-", 60) . "\n\n";
|
$content .= str_repeat("-", 60) . "\n\n";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -352,4 +352,25 @@ function deleteUserAvatar($user_id) {
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function checkRateLimit($pdo, $ip, $max_attempts = 5, $timeframe = 60) {
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT COUNT(*) FROM login_attempts
|
||||||
|
WHERE ip_address = ? AND attempted_at > NOW() - INTERVAL ? SECOND
|
||||||
|
");
|
||||||
|
$stmt->execute([$ip, $timeframe]);
|
||||||
|
$count = $stmt->fetchColumn();
|
||||||
|
|
||||||
|
return $count < $max_attempts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logLoginAttempt($pdo, $ip, $username) {
|
||||||
|
$stmt = $pdo->prepare("INSERT INTO login_attempts (ip_address, username) VALUES (?, ?)");
|
||||||
|
return $stmt->execute([$ip, $username]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLoginAttempts($pdo, $ip) {
|
||||||
|
$stmt = $pdo->prepare("DELETE FROM login_attempts WHERE ip_address = ?");
|
||||||
|
return $stmt->execute([$ip]);
|
||||||
|
}
|
||||||
?>
|
?>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<?php
|
<?php
|
||||||
// index.php - единая точка входа
|
// index.php - единая точка входа
|
||||||
require_once 'config/config.php';
|
require_once __DIR__ . '/../config/config.php';
|
||||||
|
|
||||||
// Получаем путь к запрашиваемому ресурсу
|
// Получаем путь к запрашиваемому ресурсу
|
||||||
$requestUri = $_SERVER['REQUEST_URI'];
|
$requestUri = $_SERVER['REQUEST_URI'];
|
||||||
|
|
@ -53,6 +53,7 @@ if (pathinfo($physicalPath, PATHINFO_EXTENSION) != 'php') {
|
||||||
// Простой роутер
|
// Простой роутер
|
||||||
class Router {
|
class Router {
|
||||||
private $routes = [];
|
private $routes = [];
|
||||||
|
public $params = [];
|
||||||
|
|
||||||
public function add($pattern, $handler) {
|
public function add($pattern, $handler) {
|
||||||
$this->routes[$pattern] = $handler;
|
$this->routes[$pattern] = $handler;
|
||||||
|
|
|
||||||
48
install.php
48
install.php
|
|
@ -61,6 +61,8 @@ 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,
|
||||||
|
`is_short_story` tinyint(1) NOT NULL DEFAULT 0,
|
||||||
|
`content` text DEFAULT NULL,
|
||||||
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`),
|
||||||
|
|
@ -198,32 +200,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
}
|
}
|
||||||
|
|
||||||
function generate_config($db) {
|
function generate_config($db) {
|
||||||
$site_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]";
|
|
||||||
$base_path = str_replace('/install.php', '', $_SERVER['PHP_SELF']);
|
|
||||||
$site_url .= $base_path;
|
|
||||||
|
|
||||||
return <<<EOT
|
return <<<EOT
|
||||||
<?php
|
<?php
|
||||||
// config/config.php - автоматически сгенерирован установщиком
|
// config/config.php - автоматически сгенерирован установщиком
|
||||||
// Подключаем функции
|
// Подключаем функции
|
||||||
require_once __DIR__ . '/../includes/functions.php';
|
require_once __DIR__ . '/../public/includes/functions.php';
|
||||||
session_start();
|
|
||||||
|
|
||||||
// Настройки базы данных
|
// Настройки базы данных
|
||||||
define('DB_HOST', '{$db['db_host']}');
|
define('DB_HOST', '{$db['db_host']}');
|
||||||
define('DB_USER', '{$db['db_user']}');
|
define('DB_USER', '{$db['db_user']}');
|
||||||
define('DB_PASS', '{$db['db_pass']}');
|
define('DB_PASS', '{$db['db_pass']}');
|
||||||
define('DB_NAME', '{$db['db_name']}');
|
define('DB_NAME', '{$db['db_name']}');
|
||||||
define('SITE_URL', '{$site_url}');
|
}
|
||||||
|
|
||||||
|
// динамическое определение SITE_URL
|
||||||
|
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
define('SITE_URL', $protocol . $host);
|
||||||
|
|
||||||
// Настройки приложения
|
// Настройки приложения
|
||||||
define('APP_NAME', 'Web Writer');
|
define('APP_NAME', 'Web Writer');
|
||||||
|
define('UPLOAD_PATH', __DIR__ . '/../public/uploads/');
|
||||||
define('CONTROLLERS_PATH', __DIR__ . '/../controllers/');
|
|
||||||
define('VIEWS_PATH', __DIR__ . '/../views/');
|
|
||||||
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
|
|
||||||
|
|
||||||
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
|
|
||||||
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
|
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
|
||||||
define('COVERS_URL', SITE_URL . '/uploads/covers/');
|
define('COVERS_URL', SITE_URL . '/uploads/covers/');
|
||||||
define('AVATARS_PATH', UPLOAD_PATH . 'avatars/');
|
define('AVATARS_PATH', UPLOAD_PATH . 'avatars/');
|
||||||
|
|
@ -237,25 +234,28 @@ if (!file_exists(AVATARS_PATH)) {
|
||||||
mkdir(AVATARS_PATH, 0755, true);
|
mkdir(AVATARS_PATH, 0755, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Подключение к базе данных
|
// Подключение к базе данных
|
||||||
try {
|
try {
|
||||||
\$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
|
$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
|
||||||
\$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
} catch(PDOException \$e) {
|
} catch(PDOException $e) {
|
||||||
error_log("DB Error: " . \$e->getMessage());
|
error_log("DB Error: " . $e->getMessage());
|
||||||
die("Ошибка подключения к базе данных");
|
die("Ошибка подключения к базе данных");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем константы для новых путей
|
||||||
|
define('CONTROLLERS_PATH', __DIR__ . '/../public/controllers/');
|
||||||
|
define('VIEWS_PATH', __DIR__ . '/../public/views/');
|
||||||
|
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
|
||||||
|
|
||||||
|
// Автозагрузка контроллеров
|
||||||
// Автозагрузка моделей
|
spl_autoload_register(function ($class_name) {
|
||||||
spl_autoload_register(function (\$class_name) {
|
$controller_file = CONTROLLERS_PATH . $class_name . '.php';
|
||||||
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
|
if (file_exists($controller_file)) {
|
||||||
if (file_exists(\$model_file)) {
|
require_once $controller_file;
|
||||||
require_once \$model_file;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
?>
|
|
||||||
EOT;
|
EOT;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,11 @@ class Book {
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT b.*,
|
SELECT b.*,
|
||||||
COUNT(c.id) as chapter_count,
|
COUNT(c.id) as chapter_count,
|
||||||
COALESCE(SUM(c.word_count), 0) as total_words
|
COALESCE(
|
||||||
|
CASE WHEN b.is_short_story = 1
|
||||||
|
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
|
||||||
|
ELSE COALESCE(SUM(c.word_count), 0) END, 0
|
||||||
|
) as total_words
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN chapters c ON b.id = c.book_id
|
LEFT JOIN chapters c ON b.id = c.book_id
|
||||||
WHERE b.user_id = ?
|
WHERE b.user_id = ?
|
||||||
|
|
@ -45,10 +49,12 @@ 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;
|
||||||
|
$is_short_story = isset($data['is_short_story']) ? (int)$data['is_short_story'] : 0;
|
||||||
|
$content = $is_short_story ? ($data['content'] ?? null) : null;
|
||||||
|
|
||||||
$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)
|
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, is_short_story, content)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
");
|
");
|
||||||
return $stmt->execute([
|
return $stmt->execute([
|
||||||
$data['title'],
|
$data['title'],
|
||||||
|
|
@ -58,12 +64,16 @@ 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,
|
||||||
|
$is_short_story,
|
||||||
|
$content
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
$is_short_story = isset($data['is_short_story']) ? (int)$data['is_short_story'] : 0;
|
||||||
|
$content = $is_short_story ? ($data['content'] ?? null) : null;
|
||||||
|
|
||||||
// Преобразуем пустые строки в NULL для integer полей
|
// Преобразуем пустые строки в NULL для integer полей
|
||||||
$series_id = !empty($data['series_id']) ? (int)$data['series_id'] : null;
|
$series_id = !empty($data['series_id']) ? (int)$data['series_id'] : null;
|
||||||
|
|
@ -71,7 +81,7 @@ class Book {
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare("
|
$stmt = $this->pdo->prepare("
|
||||||
UPDATE books
|
UPDATE books
|
||||||
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?
|
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?, is_short_story = ?, content = ?
|
||||||
WHERE id = ? AND user_id = ?
|
WHERE id = ? AND user_id = ?
|
||||||
");
|
");
|
||||||
return $stmt->execute([
|
return $stmt->execute([
|
||||||
|
|
@ -81,6 +91,8 @@ class Book {
|
||||||
$series_id, // Теперь это либо integer, либо NULL
|
$series_id, // Теперь это либо integer, либо NULL
|
||||||
$sort_order_in_series, // Теперь это либо integer, либо NULL
|
$sort_order_in_series, // Теперь это либо integer, либо NULL
|
||||||
$published,
|
$published,
|
||||||
|
$is_short_story,
|
||||||
|
$content,
|
||||||
$id,
|
$id,
|
||||||
$data['user_id']
|
$data['user_id']
|
||||||
]);
|
]);
|
||||||
|
|
@ -200,17 +212,17 @@ class Book {
|
||||||
public function getBookStats($book_id, $only_published_chapters = false) {
|
public function getBookStats($book_id, $only_published_chapters = false) {
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(c.id) as chapter_count,
|
b.is_short_story,
|
||||||
COALESCE(SUM(c.word_count), 0) as total_words
|
(SELECT COUNT(*) FROM chapters c WHERE c.book_id = b.id " . ($only_published_chapters ? "AND c.status = 'published'" : "") . ") as chapter_count,
|
||||||
|
CASE
|
||||||
|
WHEN b.is_short_story = 1 AND b.content IS NOT NULL THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
|
||||||
|
WHEN b.is_short_story = 0 THEN (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b.id " . ($only_published_chapters ? "AND status = 'published'" : "") . ")
|
||||||
|
ELSE 0
|
||||||
|
END as total_words
|
||||||
FROM books b
|
FROM books b
|
||||||
LEFT JOIN chapters c ON b.id = c.book_id
|
|
||||||
WHERE b.id = ?
|
WHERE b.id = ?
|
||||||
";
|
";
|
||||||
|
|
||||||
if ($only_published_chapters) {
|
|
||||||
$sql .= " AND c.status = 'published'";
|
|
||||||
}
|
|
||||||
|
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$stmt = $this->pdo->prepare($sql);
|
||||||
$stmt->execute([$book_id]);
|
$stmt->execute([$book_id]);
|
||||||
return $stmt->fetch(PDO::FETCH_ASSOC);
|
return $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,21 @@ class Series {
|
||||||
$this->pdo = $pdo;
|
$this->pdo = $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findById($id) {
|
public function findById($id) {
|
||||||
$stmt = $this->pdo->prepare("
|
$stmt = $this->pdo->prepare("
|
||||||
SELECT s.*,
|
SELECT s.*,
|
||||||
COUNT(b.id) as book_count,
|
COUNT(b.id) as book_count,
|
||||||
COALESCE((
|
(
|
||||||
SELECT SUM(c.word_count)
|
SELECT COALESCE(SUM(
|
||||||
FROM chapters c
|
CASE
|
||||||
JOIN books b2 ON c.book_id = b2.id
|
WHEN b2.is_short_story = 1
|
||||||
|
THEN CHAR_LENGTH(b2.content) - CHAR_LENGTH(REPLACE(b2.content, ' ', '')) + 1
|
||||||
|
ELSE (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b2.id)
|
||||||
|
END
|
||||||
|
), 0)
|
||||||
|
FROM books b2
|
||||||
WHERE b2.series_id = s.id AND b2.published = 1
|
WHERE b2.series_id = s.id AND b2.published = 1
|
||||||
), 0) as total_words
|
) as total_words
|
||||||
FROM series s
|
FROM series s
|
||||||
LEFT JOIN books b ON s.id = b.series_id AND b.published = 1
|
LEFT JOIN books b ON s.id = b.series_id AND b.published = 1
|
||||||
WHERE s.id = ?
|
WHERE s.id = ?
|
||||||
|
|
@ -32,11 +37,12 @@ class Series {
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT s.*,
|
SELECT s.*,
|
||||||
COUNT(b.id) as book_count,
|
COUNT(b.id) as book_count,
|
||||||
COALESCE((
|
COALESCE(SUM(
|
||||||
SELECT SUM(c.word_count)
|
CASE
|
||||||
FROM chapters c
|
WHEN b.is_short_story = 1
|
||||||
JOIN books b2 ON c.book_id = b2.id
|
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
|
||||||
WHERE b2.series_id = s.id AND b2.user_id = ?
|
ELSE (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b.id)
|
||||||
|
END
|
||||||
), 0) as total_words
|
), 0) as total_words
|
||||||
FROM series s
|
FROM series s
|
||||||
LEFT JOIN books b ON s.id = b.series_id
|
LEFT JOIN books b ON s.id = b.series_id
|
||||||
|
|
@ -45,7 +51,7 @@ class Series {
|
||||||
ORDER BY s.created_at DESC
|
ORDER BY s.created_at DESC
|
||||||
";
|
";
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$stmt = $this->pdo->prepare($sql);
|
||||||
$stmt->execute([$user_id, $user_id]);
|
$stmt->execute([$user_id]);
|
||||||
} else {
|
} else {
|
||||||
$sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC";
|
$sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC";
|
||||||
$stmt = $this->pdo->prepare($sql);
|
$stmt = $this->pdo->prepare($sql);
|
||||||
|
|
@ -128,8 +134,14 @@ class Series {
|
||||||
$sql = "
|
$sql = "
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(b.id) as book_count,
|
COUNT(b.id) as book_count,
|
||||||
COALESCE(SUM(stats.chapter_count), 0) as chapter_count,
|
COALESCE(SUM(
|
||||||
COALESCE(SUM(stats.total_words), 0) as total_words
|
CASE WHEN b.is_short_story = 1 THEN 0 ELSE stats.chapter_count END
|
||||||
|
), 0) as chapter_count,
|
||||||
|
COALESCE(SUM(
|
||||||
|
CASE WHEN b.is_short_story = 1
|
||||||
|
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
|
||||||
|
ELSE COALESCE(stats.total_words, 0) END
|
||||||
|
), 0) as total_words
|
||||||
FROM series s
|
FROM series s
|
||||||
LEFT JOIN books b ON s.id = b.series_id
|
LEFT JOIN books b ON s.id = b.series_id
|
||||||
LEFT JOIN (
|
LEFT JOIN (
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ include 'views/layouts/header.php';
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data" id="book-form">
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
|
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
|
|
@ -94,6 +94,28 @@ include 'views/layouts/header.php';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="is_short_story" name="is_short_story" value="1"
|
||||||
|
<?= (!empty($_POST['is_short_story']) && $_POST['is_short_story']) ? 'checked' : '' ?> onchange="toggleStoryEditor()">
|
||||||
|
<label class="form-check-label" for="is_short_story">
|
||||||
|
Это рассказ (без разбивки на главы)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4" id="story-editor-card" style="display: none;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0">Содержание рассказа</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="story-editor" style="height: 400px;" data-content="<?= htmlspecialchars($_POST['content'] ?? '', ENT_QUOTES) ?>">
|
||||||
|
<?= $_POST['content'] ?? '' ?>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="content" id="story-content" <?= !empty($_POST['content']) ? 'value="' . htmlspecialchars($_POST['content'], ENT_QUOTES) . '"' : '' ?>>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
<i class="bi bi-journal-plus"></i> Создать книгу
|
<i class="bi bi-journal-plus"></i> Создать книгу
|
||||||
|
|
@ -107,6 +129,44 @@ include 'views/layouts/header.php';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<script src="<?= SITE_URL ?>/assets/js/editor.js"></script>
|
||||||
|
<script src="<?= SITE_URL ?>/assets/js/autosave.js"></script>
|
||||||
|
<script>
|
||||||
|
function toggleStoryEditor() {
|
||||||
|
var isShortStory = document.getElementById('is_short_story').checked;
|
||||||
|
var storyCard = document.getElementById('story-editor-card');
|
||||||
|
|
||||||
|
if (storyCard) {
|
||||||
|
storyCard.style.display = isShortStory ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (isShortStory && !window.writerEditor) {
|
||||||
|
initQuillEditor(true);
|
||||||
|
} else if (!isShortStory && window.writerEditor) {
|
||||||
|
window.writerEditor.quill.destroy();
|
||||||
|
window.writerEditor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isShortStory && window.writerEditor && window.writerEditor.quill) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.writerEditor.quill.root.style.height = '400px';
|
||||||
|
window.writerEditor.quill.container.style.height = '400px';
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
toggleStoryEditor();
|
||||||
|
|
||||||
|
window.addEventListener('resize', function() {
|
||||||
|
var isShortStory = document.getElementById('is_short_story').checked;
|
||||||
|
if (isShortStory && window.writerEditor && window.writerEditor.quill) {
|
||||||
|
window.writerEditor.quill.root.style.height = '400px';
|
||||||
|
window.writerEditor.quill.container.style.height = '400px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
<?php include 'views/layouts/footer.php'; ?>
|
<?php include 'views/layouts/footer.php'; ?>
|
||||||
|
|
@ -18,16 +18,19 @@ include 'views/layouts/header.php';
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="row">
|
<form method="post" enctype="multipart/form-data" id="book-form">
|
||||||
<div class="col-lg-8">
|
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Основная информация</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form method="post" enctype="multipart/form-data">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
|
|
||||||
|
<!-- Аккордеон для основной информации (свернут) -->
|
||||||
|
<div class="accordion mb-4" id="bookInfoAccordion">
|
||||||
|
<div class="accordion-item">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#bookInfoCollapse">
|
||||||
|
<i class="bi bi-info-circle me-2"></i> Основная информация
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="bookInfoCollapse" class="accordion-collapse collapse" data-bs-parent="#bookInfoAccordion">
|
||||||
|
<div class="accordion-body">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="title" class="form-label">Название книги *</label>
|
<label for="title" class="form-label">Название книги *</label>
|
||||||
<input type="text" class="form-control" id="title" name="title"
|
<input type="text" class="form-control" id="title" name="title"
|
||||||
|
|
@ -83,14 +86,38 @@ include 'views/layouts/header.php';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary">
|
<div class="mb-4">
|
||||||
<i class="bi bi-check-circle"></i> Сохранить изменения
|
<div class="form-check">
|
||||||
</button>
|
<input class="form-check-input" type="checkbox" id="is_short_story" name="is_short_story" value="1"
|
||||||
</form>
|
<?= !empty($book['is_short_story']) ? 'checked' : '' ?> onchange="toggleStoryEditor()">
|
||||||
|
<label class="form-check-label" for="is_short_story">
|
||||||
|
Это рассказ (без разбивки на главы)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mb-4">
|
<!-- Редактор рассказа (всегда видимый) -->
|
||||||
|
<div class="mb-4" id="story-editor-card" style="display: <?= !empty($book['is_short_story']) ? 'block' : 'none' ?>">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-success bg-opacity-10">
|
||||||
|
<h5 class="card-title mb-0 text-success">
|
||||||
|
<i class="bi bi-book me-2"></i>Содержание рассказа
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="story-editor" style="height: 500px;" data-content="<?= htmlspecialchars($book['content'] ?? '', ENT_QUOTES) ?>"></div>
|
||||||
|
<textarea name="content" id="content" style="display: none;"><?= htmlspecialchars($book['content'] ?? '', ENT_QUOTES) ?></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Главы книги (всегда видимые) -->
|
||||||
|
<div class="mb-4" id="chapters-card" style="display: <?= !empty($book['is_short_story']) ? 'none' : 'block' ?>">
|
||||||
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="card-title mb-0">Главы книги</h5>
|
<h5 class="card-title mb-0">Главы книги</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -145,136 +172,22 @@ include 'views/layouts/header.php';
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-lg-4">
|
<!-- Кнопка сохранения -->
|
||||||
<div class="card mb-4">
|
<div class="mb-4">
|
||||||
<div class="card-header">
|
<button type="submit" class="btn btn-primary btn-lg">
|
||||||
<h5 class="card-title mb-0">Обложка книги</h5>
|
<i class="bi bi-check-circle"></i> Сохранить изменения
|
||||||
</div>
|
</button>
|
||||||
<div class="card-body">
|
|
||||||
<?php if (!empty($book['cover_image'])): ?>
|
|
||||||
<div class="text-center mb-3">
|
|
||||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
|
||||||
alt="Обложка"
|
|
||||||
class="img-fluid rounded"
|
|
||||||
style="max-height: 200px;">
|
|
||||||
</div>
|
|
||||||
<div class="form-check mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" name="delete_cover" value="1" id="delete_cover">
|
|
||||||
<label class="form-check-label" for="delete_cover">
|
|
||||||
Удалить обложку
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="cover_image" class="form-label">Загрузить новую обложку</label>
|
|
||||||
<input type="file" class="form-control" id="cover_image" name="cover_image"
|
|
||||||
accept="image/jpeg, image/png, image/gif, image/webp">
|
|
||||||
<div class="form-text">
|
|
||||||
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<?php if (!empty($cover_error)): ?>
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<i class="bi bi-exclamation-triangle"></i> <?= e($cover_error) ?>
|
|
||||||
</div>
|
|
||||||
<?php endif; ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Публичная ссылка</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="input-group mb-2">
|
|
||||||
<input type="text"
|
|
||||||
id="share-link"
|
|
||||||
value="<?= e(SITE_URL . '/book/' . $book['share_token']) ?>"
|
|
||||||
readonly
|
|
||||||
class="form-control">
|
|
||||||
<button type="button" onclick="copyShareLink()" class="btn btn-outline-secondary">
|
|
||||||
<i class="bi bi-clipboard"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/regenerate-token" class="d-inline">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
<button type="submit" class="btn btn-outline-warning btn-sm" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')">
|
|
||||||
<i class="bi bi-arrow-repeat"></i> Обновить ссылку
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<div class="form-text">
|
|
||||||
В публичном просмотре отображаются только опубликованные главы
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card mb-4">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5 class="card-title mb-0">Экспорт книги</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="btn btn-outline-danger" target="_blank">
|
|
||||||
<i class="bi bi-file-pdf"></i> PDF
|
|
||||||
</a>
|
|
||||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="btn btn-outline-primary" target="_blank">
|
|
||||||
<i class="bi bi-file-word"></i> DOCX
|
|
||||||
</a>
|
|
||||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="btn btn-outline-success" target="_blank">
|
|
||||||
<i class="bi bi-file-code"></i> HTML
|
|
||||||
</a>
|
|
||||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="btn btn-outline-secondary" target="_blank">
|
|
||||||
<i class="bi bi-file-text"></i> TXT
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="form-text mt-2">
|
|
||||||
Экспортируются все главы книги (включая черновики)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card border-danger">
|
|
||||||
<div class="card-header bg-danger text-white">
|
|
||||||
<h5 class="card-title mb-0">Опасная зона</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p class="card-text small text-muted">
|
|
||||||
Удаление книги приведет к удалению всех глав и обложки. Это действие нельзя отменить.
|
|
||||||
</p>
|
|
||||||
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/delete"
|
|
||||||
onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
|
|
||||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
|
||||||
<button type="submit" class="btn btn-danger w-100">
|
|
||||||
<i class="bi bi-trash"></i> Удалить книгу
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
|
<script src="<?= SITE_URL ?>/assets/js/editor.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const checkbox = document.getElementById('is_short_story');
|
||||||
|
if (checkbox && checkbox.checked) {
|
||||||
|
toggleStoryEditor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
|
||||||
function copyShareLink() {
|
|
||||||
const shareLink = document.getElementById('share-link');
|
|
||||||
shareLink.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
|
|
||||||
// Показать уведомление
|
|
||||||
const button = event.target;
|
|
||||||
const originalHTML = button.innerHTML;
|
|
||||||
button.innerHTML = '<i class="bi bi-check"></i>';
|
|
||||||
button.classList.remove('btn-outline-secondary');
|
|
||||||
button.classList.add('btn-success');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
button.innerHTML = originalHTML;
|
|
||||||
button.classList.remove('btn-success');
|
|
||||||
button.classList.add('btn-outline-secondary');
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<?php include 'views/layouts/footer.php'; ?>
|
|
||||||
|
|
@ -49,6 +49,11 @@ include 'views/layouts/header.php';
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="d-flex justify-content-between align-items-start">
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
<h5 class="card-title mb-1">
|
<h5 class="card-title mb-1">
|
||||||
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
|
<i class="bi bi-file-text text-success me-1"></i>
|
||||||
|
<?php else: ?>
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>
|
||||||
|
<?php endif; ?>
|
||||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="text-decoration-none">
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="text-decoration-none">
|
||||||
<?= e($book['title']) ?>
|
<?= e($book['title']) ?>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -70,26 +75,38 @@ include 'views/layouts/header.php';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="row text-center mb-3">
|
<div class="row text-center mb-3">
|
||||||
<div class="col-4">
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
<div class="border-end">
|
<div class="col-6">
|
||||||
<div class="fw-bold text-primary"><?= $book['chapter_count'] ?? 0 ?></div>
|
<div class="border-end">
|
||||||
<small class="text-muted">глав</small>
|
<div class="fw-bold text-success"><?= number_format($book['total_words'] ?? 0) ?></div>
|
||||||
</div>
|
<small class="text-muted">слов</small>
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<div class="border-end">
|
|
||||||
<div class="fw-bold text-success"><?= number_format($book['total_words'] ?? 0) ?></div>
|
|
||||||
<small class="text-muted">слов</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-4">
|
|
||||||
<div>
|
|
||||||
<div class="fw-bold text-info">
|
|
||||||
<?= $book['chapter_count'] > 0 ? number_format(($book['total_words'] ?? 0) / $book['chapter_count']) : 0 ?>
|
|
||||||
</div>
|
</div>
|
||||||
<small class="text-muted">слов/глава</small>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col-6">
|
||||||
|
<small class="text-success">(рассказ)</small>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="border-end">
|
||||||
|
<div class="fw-bold text-primary"><?= $book['chapter_count'] ?? 0 ?></div>
|
||||||
|
<small class="text-muted">глав</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="border-end">
|
||||||
|
<div class="fw-bold text-success"><?= number_format($book['total_words'] ?? 0) ?></div>
|
||||||
|
<small class="text-muted">слов</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div>
|
||||||
|
<div class="fw-bold text-info">
|
||||||
|
<?= $book['chapter_count'] > 0 ? number_format(($book['total_words'] ?? 0) / $book['chapter_count']) : 0 ?>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">слов/глава</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
|
|
@ -97,9 +114,11 @@ include 'views/layouts/header.php';
|
||||||
<i class="bi bi-pencil"></i> Редактировать
|
<i class="bi bi-pencil"></i> Редактировать
|
||||||
</a>
|
</a>
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary btn-sm">
|
<?php if (empty($book['is_short_story'])): ?>
|
||||||
<i class="bi bi-file-text"></i> Главы
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary btn-sm">
|
||||||
</a>
|
<i class="bi bi-file-text"></i> Главы
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="btn btn-outline-success btn-sm" target="_blank">
|
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="btn btn-outline-success btn-sm" target="_blank">
|
||||||
<i class="bi bi-eye"></i> Просмотр
|
<i class="bi bi-eye"></i> Просмотр
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,22 @@ include 'views/layouts/header.php';
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<div class="d-flex justify-content-center gap-4 flex-wrap mb-4">
|
<div class="d-flex justify-content-center gap-4 flex-wrap mb-4">
|
||||||
|
<?php if (!$is_short_story): ?>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="h4 text-primary mb-0"><?= count($chapters) ?></div>
|
<div class="h4 text-primary mb-0"><?= count($chapters) ?></div>
|
||||||
<small class="text-muted">Глав</small>
|
<small class="text-muted">Глав</small>
|
||||||
</div>
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="h4 text-success mb-0"><?= array_sum(array_column($chapters, 'word_count')) ?></div>
|
<div class="h4 text-success mb-0">
|
||||||
|
<?php if ($is_short_story && !empty($book['content'])): ?>
|
||||||
|
<?= number_format(mb_strlen($book['content']) - mb_strlen(str_replace(' ', '', $book['content'])) + 1) ?>
|
||||||
|
<?php elseif (!$is_short_story): ?>
|
||||||
|
<?= array_sum(array_column($chapters, 'word_count')) ?>
|
||||||
|
<?php else: ?>
|
||||||
|
0
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
<small class="text-muted">Слов</small>
|
<small class="text-muted">Слов</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -81,7 +91,13 @@ include 'views/layouts/header.php';
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<?php if (empty($chapters)): ?>
|
<?php if ($is_short_story && !empty($book['content'])): ?>
|
||||||
|
<div class="chapter-content mb-5">
|
||||||
|
<div class="chapter-text" style="line-height: 1.8; font-size: 1.1em;">
|
||||||
|
<?= $book['content'] ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php elseif (empty($chapters)): ?>
|
||||||
<div class="text-center py-5">
|
<div class="text-center py-5">
|
||||||
<i class="bi bi-file-text fs-1 text-muted"></i>
|
<i class="bi bi-file-text fs-1 text-muted"></i>
|
||||||
<h3 class="h4 text-muted mt-3">В этой книге пока нет глав</h3>
|
<h3 class="h4 text-muted mt-3">В этой книге пока нет глав</h3>
|
||||||
|
|
@ -146,6 +162,7 @@ include 'views/layouts/header.php';
|
||||||
.chapter-text p {
|
.chapter-text p {
|
||||||
margin-bottom: 1.5em;
|
margin-bottom: 1.5em;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
|
text-indent: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chapter-text .dialogue {
|
.chapter-text .dialogue {
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
.chapter-content p {
|
.chapter-content p {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
|
text-indent: 2em;
|
||||||
}
|
}
|
||||||
.chapter-content code {
|
.chapter-content code {
|
||||||
background: var(--bs-light);
|
background: var(--bs-light);
|
||||||
|
|
|
||||||
|
|
@ -117,16 +117,21 @@ include 'views/layouts/header.php';
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Глав: <?= $book['chapter_count'] ?? 0 ?> |
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
Слов: <?= $book['total_words'] ?? 0 ?>
|
(рассказ) | <?= number_format($book['total_words'] ?? 0) ?> слов
|
||||||
|
<?php else: ?>
|
||||||
|
Глав: <?= $book['chapter_count'] ?? 0 ?> | <?= number_format($book['total_words'] ?? 0) ?> слов
|
||||||
|
<?php endif; ?>
|
||||||
</small>
|
</small>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
|
||||||
<i class="bi bi-pencil"></i>
|
<i class="bi bi-pencil"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary btn-sm">
|
<?php if (empty($book['is_short_story'])): ?>
|
||||||
<i class="bi bi-file-text"></i>
|
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary btn-sm">
|
||||||
</a>
|
<i class="bi bi-file-text"></i>
|
||||||
|
</a>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,9 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
<script src="<?= SITE_URL ?>/assets/js/quill.js"></script>
|
<?php if (!isset($GLOBALS['bootstrap_loaded'])): ?>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<?php $GLOBALS['bootstrap_loaded'] = true; endif; ?>
|
||||||
<script>
|
<script>
|
||||||
// Функция для установки темы
|
// Функция для установки темы
|
||||||
function setTheme(themeName) {
|
function setTheme(themeName) {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ $available_themes = [
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||||
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
|
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
|
||||||
<link href="<?= SITE_URL ?>/assets/css/quill.snow.css" rel="stylesheet">
|
<link href="<?= SITE_URL ?>/assets/css/quill.snow.css" rel="stylesheet">
|
||||||
|
<script src="<?= SITE_URL ?>/assets/js/quill.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<?php $GLOBALS['bootstrap_loaded'] = true; ?>
|
||||||
<style>
|
<style>
|
||||||
.navbar-brand { font-weight: 600; }
|
.navbar-brand { font-weight: 600; }
|
||||||
.dropdown-menu { min-width: 200px; }
|
.dropdown-menu { min-width: 200px; }
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,20 @@ include 'views/layouts/header.php';
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||||
<h3 class="h4">
|
<h3 class="h4 mb-0">
|
||||||
<?php if ($book['sort_order_in_series']): ?>
|
<?php if ($book['sort_order_in_series']): ?>
|
||||||
<small class="text-muted">Книга <?= $book['sort_order_in_series'] ?></small><br>
|
<small class="text-muted">Книга <?= $book['sort_order_in_series'] ?></small><br>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
|
<i class="bi bi-file-text text-success me-1"></i>
|
||||||
|
<?php else: ?>
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>
|
||||||
|
<?php endif; ?>
|
||||||
<?= e($book['title']) ?>
|
<?= e($book['title']) ?>
|
||||||
</h3>
|
</h3>
|
||||||
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
|
<span class="text-success">(рассказ)</span>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($book['genre']): ?>
|
<?php if ($book['genre']): ?>
|
||||||
|
|
@ -96,8 +104,11 @@ include 'views/layouts/header.php';
|
||||||
$book_stats = $book_model->getBookStats($book['id'], true);
|
$book_stats = $book_model->getBookStats($book['id'], true);
|
||||||
?>
|
?>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> |
|
<?php if (!empty($book_stats['is_short_story'])): ?>
|
||||||
Слов: <?= $book_stats['total_words'] ?? 0 ?>
|
<?= number_format($book_stats['total_words'] ?? 0) ?> слов
|
||||||
|
<?php else: ?>
|
||||||
|
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | <?= number_format($book_stats['total_words'] ?? 0) ?> слов
|
||||||
|
<?php endif; ?>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,14 @@ include 'views/layouts/header.php';
|
||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<h5 class="card-title"><?= e($book['title']) ?></h5>
|
<h5 class="card-title mb-0">
|
||||||
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
|
<i class="bi bi-file-text text-success me-1"></i>
|
||||||
|
<?php else: ?>
|
||||||
|
<i class="bi bi-book text-primary me-1"></i>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?= e($book['title']) ?>
|
||||||
|
</h5>
|
||||||
<?php if ($book['genre']): ?>
|
<?php if ($book['genre']): ?>
|
||||||
<p class="text-muted small mb-1"><em><?= e($book['genre']) ?></em></p>
|
<p class="text-muted small mb-1"><em><?= e($book['genre']) ?></em></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
@ -102,10 +109,18 @@ include 'views/layouts/header.php';
|
||||||
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="btn btn-primary btn-sm">
|
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="btn btn-primary btn-sm">
|
||||||
<i class="bi bi-book"></i> Читать книгу
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
|
<i class="bi bi-file-text"></i> Читать рассказ
|
||||||
|
<?php else: ?>
|
||||||
|
<i class="bi bi-book"></i> Читать книгу
|
||||||
|
<?php endif; ?>
|
||||||
</a>
|
</a>
|
||||||
<small class="text-muted">
|
<small class="text-muted">
|
||||||
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?>
|
<?php if (!empty($book['is_short_story'])): ?>
|
||||||
|
(рассказ) <?= number_format($word_count) ?> слов
|
||||||
|
<?php else: ?>
|
||||||
|
Глав: <?= $chapter_count ?> | <?= number_format($word_count) ?> слов
|
||||||
|
<?php endif; ?>
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue