Start small MVC changes

This commit is contained in:
mirivlad 2025-11-24 16:26:13 +08:00
parent d7fe90a615
commit 24b3788493
51 changed files with 4012 additions and 4063 deletions

View File

@ -1,2 +0,0 @@
<?php
die();

View File

@ -1,153 +0,0 @@
<?php
require_once '../config/config.php';
require_login();
// Проверяем права администратора (простая проверка - первый пользователь считается администратором)
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>
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success">
<?= e($_SESSION['success']) ?>
<?php unset($_SESSION['success']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Всего пользователей: <?= count($users) ?></h2>
<a href="../register.php" role="button"> Добавить пользователя</a>
</div>
<?php if (empty($users)): ?>
<article style="text-align: center; padding: 2rem;">
<h3>Пользователи не найдены</h3>
<p>Зарегистрируйте первого пользователя</p>
<a href="../register.php" role="button">📝 Добавить пользователя</a>
</article>
<?php else: ?>
<div style="overflow-x: auto;">
<table class="compact-table">
<thead>
<tr>
<th style="width: 5%;">ID</th>
<th style="width: 15%;">Имя пользователя</th>
<th style="width: 20%;">Отображаемое имя</th>
<th style="width: 20%;">Email</th>
<th style="width: 15%;">Дата регистрации</th>
<th style="width: 10%;">Статус</th>
<th style="width: 15%;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td><?= $user['id'] ?></td>
<td>
<strong><a href="/author.php?id=<?= $user['id'] ?>"><?= e($user['username']) ?></a></strong>
<?php if ($user['id'] == $_SESSION['user_id']): ?>
<br><small style="color: #666;">(Вы)</small>
<?php endif; ?>
</td>
<td><?= e($user['display_name']) ?></td>
<td><?= e($user['email']) ?></td>
<td>
<small><?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></small>
<?php if ($user['last_login']): ?>
<br><small style="color: #666;">Вход: <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small>
<?php endif; ?>
</td>
<td>
<span style="color: <?= $user['is_active'] ? 'green' : 'red' ?>">
<?= $user['is_active'] ? '✅ Активен' : '❌ Неактивен' ?>
</span>
</td>
<td>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
<form method="post" style="display: inline;">
<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'] ? 'Деактивировать' : 'Активировать' ?>">
<?= $user['is_active'] ? '⏸️' : '▶️' ?>
</button>
</form>
<form method="post" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');">
<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>
</form>
</div>
<?php else: ?>
<small style="color: #666;">Текущий пользователь</small>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php include '../views/footer.php'; ?>

View File

@ -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');
?>

View File

@ -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');
?>

View File

@ -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>
&nbsp;
<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'; ?>

View File

@ -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
View File

@ -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'; ?>

View File

@ -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");
?>

View File

@ -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'; ?>

51
config/config.php Normal file
View File

@ -0,0 +1,51 @@
<?php
// config/config.php - автоматически сгенерирован установщиком
// Подключаем функции
require_once __DIR__ . '/../includes/functions.php';
session_start();
// Настройки базы данных
define('DB_HOST', 'localhost');
define('DB_USER', 'writer_mirv');
define('DB_PASS', 'writer_moloko22');
define('DB_NAME', 'writer_app');
define('SITE_URL', 'http://writer.local');
// Настройки приложения
define('APP_NAME', 'Web Writer');
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
define('COVERS_URL', SITE_URL . '/uploads/covers/');
define('AVATARS_PATH', UPLOAD_PATH . 'avatars/');
define('AVATARS_URL', SITE_URL . '/uploads/avatars/');
// Создаем папку для загрузок, если ее нет
if (!file_exists(COVERS_PATH)) {
mkdir(COVERS_PATH, 0755, true);
}
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
// Подключение к базе данных
try {
$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch(PDOException $e) {
error_log("DB Error: " . $e->getMessage());
die("Ошибка подключения к базе данных");
}
// Добавляем константы для новых путей
define('CONTROLLERS_PATH', __DIR__ . '/../controllers/');
define('VIEWS_PATH', __DIR__ . '/../views/');
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
// Автозагрузка контроллеров
spl_autoload_register(function ($class_name) {
$controller_file = CONTROLLERS_PATH . $class_name . '.php';
if (file_exists($controller_file)) {
require_once $controller_file;
}
});
?>

View File

@ -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' => 'Регистрация'
]);
}
}
?>

View File

@ -0,0 +1,33 @@
<?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 jsonResponse($data) {
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
}
?>

View File

@ -0,0 +1,268 @@
<?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']);
// Возвращаем типы редакторов для выбора
$editor_types = [
'markdown' => 'Markdown редактор',
'html' => 'HTML редактор (TinyMCE)'
];
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'],
'editor_type' => $_POST['editor_type'] ?? 'markdown',
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
'published' => isset($_POST['published']) ? 1 : 0
];
if ($bookModel->create($data)) {
$_SESSION['success'] = "Книга успешно создана";
$new_book_id = $this->pdo->lastInsertId();
$this->redirect("/books/{$new_book_id}/edit");
} else {
$_SESSION['error'] = "Ошибка при создании книги";
}
}
$this->render('books/create', [
'series' => $series,
'editor_types' => $editor_types,
'selected_editor' => 'markdown', // по умолчанию
'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']);
// Типы редакторов для выбора
$editor_types = [
'markdown' => 'Markdown редактор',
'html' => 'HTML редактор (TinyMCE)'
];
$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 {
$old_editor_type = $book['editor_type'];
$new_editor_type = $_POST['editor_type'] ?? 'markdown';
$editor_changed = ($old_editor_type !== $new_editor_type);
$data = [
'title' => $title,
'description' => trim($_POST['description'] ?? ''),
'genre' => trim($_POST['genre'] ?? ''),
'user_id' => $_SESSION['user_id'],
'editor_type' => $new_editor_type,
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
'published' => isset($_POST['published']) ? 1 : 0
];
// Обработка смены редактора (прежде чем обновлять книгу)
if ($editor_changed) {
$conversion_success = $bookModel->convertChaptersContent($id, $old_editor_type, $new_editor_type);
if (!$conversion_success) {
$_SESSION['warning'] = "Внимание: не удалось автоматически сконвертировать содержание всех глав.";
}
}
// Обработка обложки
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
$cover_result = handleCoverUpload($_FILES['cover_image'], $id);
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 = "Книга успешно обновлена";
if ($editor_changed) {
$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,
'editor_types' => $editor_types,
'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 viewPublic($share_token) {
$bookModel = new Book($this->pdo);
$book = $bookModel->findByShareToken($share_token);
if (!$book) {
http_response_code(404);
$this->render('errors/404');
return;
}
$chapters = $bookModel->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 normalizeContent($id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
$this->redirect("/books/{$id}/edit");
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect("/books/{$id}/edit");
}
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
if (!$bookModel->userOwnsBook($id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
$this->redirect('/books');
}
if ($bookModel->normalizeBookContent($id)) {
$_SESSION['success'] = "Контент глав успешно нормализован";
} else {
$_SESSION['error'] = "Ошибка при нормализации контента";
}
$this->redirect("/books/{$id}/edit");
}
public function regenerateToken($id) {
$this->requireLogin();
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");
}
}
?>

View File

@ -0,0 +1,199 @@
<?php
// controllers/ChapterController.php
require_once 'controllers/BaseController.php';
require_once 'models/Chapter.php';
require_once 'models/Book.php';
require_once 'includes/parsedown/ParsedownExtra.php';
class ChapterController extends BaseController {
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 = $_POST['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);
// Проверяем права доступа к главе
if (!$chapterModel->userOwnsChapter($id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой главе";
$this->redirect('/books');
}
$chapter = $chapterModel->findById($id);
$book = $bookModel->findById($chapter['book_id']);
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$title = trim($_POST['title'] ?? '');
$content = $_POST['content'] ?? '';
$status = $_POST['status'] ?? 'draft';
if (empty($title)) {
$error = "Название главы обязательно";
} else {
$data = [
'title' => $title,
'content' => $content,
'status' => $status
];
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();
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;
}
$this->render('chapters/preview', [
'content' => $html_content,
'title' => $title,
'page_title' => "Предпросмотр: " . e($title)
]);
}
}
?>

View File

@ -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' => 'Панель управления'
]);
}
}
?>

View File

@ -0,0 +1,883 @@
<?php
// controllers/ExportController.php
require_once 'controllers/BaseController.php';
require_once 'models/Book.php';
require_once 'models/Chapter.php';
require_once 'vendor/autoload.php';
require_once 'includes/parsedown/ParsedownExtra.php';
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
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 = $bookModel->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) {
$Parsedown = new ParsedownExtra();
switch ($format) {
case 'pdf':
$this->exportPDF($book, $chapters, $is_public, $author_name, $Parsedown);
break;
case 'docx':
$this->exportDOCX($book, $chapters, $is_public, $author_name, $Parsedown);
break;
case 'html':
$this->exportHTML($book, $chapters, $is_public, $author_name, $Parsedown);
break;
case 'txt':
$this->exportTXT($book, $chapters, $is_public, $author_name, $Parsedown);
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) {
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 = $this->markdownToParagraphs($book['description']);
} else {
$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);
// Получаем очищенный текст и разбиваем на абзацы в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$cleanContent = $this->cleanMarkdown($chapter['content']);
$paragraphs = $this->markdownToParagraphs($cleanContent);
} else {
$cleanContent = strip_tags($chapter['content']);
$paragraphs = $this->htmlToParagraphs($chapter['content']);
}
// Добавляем каждый абзац
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) {
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 = $this->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 = $this->cleanMarkdown($chapter['content']);
$paragraphs = $this->markdownToParagraphs($cleanContent);
} else {
$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;
}
// Функция для преобразования Markdown в чистый текст с форматированием абзацев
function markdownToPlainText($markdown) {
// Обрабатываем диалоги (заменяем - на —)
$markdown = preg_replace('/^- (.+)$/m', "$1", $markdown);
// Убираем Markdown разметку, но сохраняем переносы строк
$text = $markdown;
// Убираем заголовки
$text = preg_replace('/^#+\s+/m', '', $text);
// Убираем жирный и курсив
$text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
$text = preg_replace('/\*(.*?)\*/', '$1', $text);
$text = preg_replace('/__(.*?)__/', '$1', $text);
$text = preg_replace('/_(.*?)_/', '$1', $text);
// Убираем зачеркивание
$text = preg_replace('/~~(.*?)~~/', '$1', $text);
// Убираем код (встроенный)
$text = preg_replace('/`(.*?)`/', '$1', $text);
// Убираем блоки кода (сохраняем содержимое)
$text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
// Убираем ссылки
$text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
// Обрабатываем списки - заменяем маркеры на *
$text = preg_replace('/^[\*\-+]\s+/m', '* ', $text);
$text = preg_replace('/^\d+\.\s+/m', '* ', $text);
// Обрабатываем цитаты
$text = preg_replace('/^>\s+/m', '', $text);
return $text;
}
// Функция для разбивки Markdown на абзацы с сохранением структуры
function markdownToParagraphs($markdown) {
// Нормализуем переносы строк
$text = str_replace(["\r\n", "\r"], "\n", $markdown);
// Обрабатываем диалоги (заменяем - на —)
$text = preg_replace('/^- (.+)$/m', "$1", $text);
// Разбиваем на строки
$lines = explode("\n", $text);
$paragraphs = [];
$currentParagraph = '';
foreach ($lines as $line) {
$trimmedLine = trim($line);
// Пустая строка - конец абзаца
if (empty($trimmedLine)) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
}
continue;
}
// Диалог (начинается с —) всегда начинает новый абзац
if (str_starts_with($trimmedLine, '—')) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
$currentParagraph = $trimmedLine;
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
continue;
}
// Заголовки (начинаются с #) всегда начинают новый абзац
if (str_starts_with($trimmedLine, '#')) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
$currentParagraph = preg_replace('/^#+\s+/', '', $trimmedLine);
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
continue;
}
// Обычный текст - добавляем к текущему абзацу
if (!empty($currentParagraph)) {
$currentParagraph .= ' ' . $trimmedLine;
} else {
$currentParagraph = $trimmedLine;
}
}
// Добавляем последний абзац
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
return $paragraphs;
}
// Функция для очистки Markdown разметки
function cleanMarkdown($markdown) {
$text = $markdown;
// Убираем заголовки
$text = preg_replace('/^#+\s+/m', '', $text);
// Убираем жирный и курсив
$text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
$text = preg_replace('/\*(.*?)\*/', '$1', $text);
$text = preg_replace('/__(.*?)__/', '$1', $text);
$text = preg_replace('/_(.*?)_/', '$1', $text);
// Убираем зачеркивание
$text = preg_replace('/~~(.*?)~~/', '$1', $text);
// Убираем код
$text = preg_replace('/`(.*?)`/', '$1', $text);
$text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
// Убираем ссылки
$text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
// Обрабатываем списки - убираем маркеры
$text = preg_replace('/^[\*\-+]\s+/m', '', $text);
$text = preg_replace('/^\d+\.\s+/m', '', $text);
// Обрабатываем цитаты
$text = preg_replace('/^>\s+/m', '', $text);
return $text;
}
// Функция для форматирования текста с сохранением абзацев и диалогов
function formatPlainText($text) {
$lines = explode("\n", $text);
$formatted = [];
$in_paragraph = false;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
if ($in_paragraph) {
$formatted[] = ''; // Пустая строка для разделения абзацев
$in_paragraph = false;
}
continue;
}
// Диалоги начинаются с
if (str_starts_with($line, '—')) {
if ($in_paragraph) {
$formatted[] = ''; // Разделяем абзацы перед диалогом
}
$formatted[] = $line;
$formatted[] = ''; // Пустая строка после диалога
$in_paragraph = false;
} else {
// Обычный текст
$formatted[] = $line;
$in_paragraph = true;
}
}
return implode("\n", array_filter($formatted, function($line) {
return $line !== '' || !empty($line);
}));
}
// // Новая функция для разбивки HTML на абзацы
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;
}
}
?>

View File

@ -0,0 +1,194 @@
<?php
// controllers/SeriesController.php
require_once 'controllers/BaseController.php';
require_once 'models/Series.php';
require_once 'models/Book.php';
require_once 'includes/parsedown/ParsedownExtra.php';
class SeriesController extends BaseController {
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);
$this->render('series/edit', [
'series' => $series,
'books_in_series' => $books_in_series,
'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;
}
$Parsedown = new ParsedownExtra();
$this->render('series/view_public', [
'series' => $series,
'books' => $books,
'author' => $author,
'total_words' => $total_words,
'total_chapters' => $total_chapters,
'Parsedown' => $Parsedown,
'page_title' => $series['title'] . ' — серия книг'
]);
}
}
?>

View File

@ -0,0 +1,117 @@
<?php
// controllers/UserController.php
require_once 'controllers/BaseController.php';
require_once 'models/User.php';
require_once 'models/Book.php';
require_once 'includes/parsedown/ParsedownExtra.php';
class UserController extends BaseController {
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;
}
$Parsedown = new ParsedownExtra();
$this->render('user/view_public', [
'user' => $user,
'books' => $books,
'total_books' => $total_books,
'total_words' => $total_words,
'total_chapters' => $total_chapters,
'Parsedown' => $Parsedown,
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
]);
}
}
?>

View File

@ -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'; ?>

View File

@ -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;
}
?>

125
index.php
View File

@ -1,9 +1,126 @@
<?php
// index.php - единая точка входа
require_once 'config/config.php';
if (is_logged_in()) {
redirect('dashboard.php');
} else {
redirect('login.php');
// Простой роутер
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, $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], $params);
}
}
}
throw new Exception("Handler not found");
}
}
// Инициализация роутера
$router = new Router();
// Маршруты
$router->add('/', 'DashboardController@index');
$router->add('/login', 'AuthController@login');
$router->add('/logout', 'AuthController@logout');
$router->add('/register', 'AuthController@register');
// Книги
$router->add('/books', 'BookController@index');
$router->add('/books/create', 'BookController@create');
$router->add('/books/{id}/edit', 'BookController@edit');
$router->add('/books/{id}/delete', 'BookController@delete');
$router->add('/books/{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('/profile', 'UserController@profile');
$router->add('/profile/update', 'UserController@updateProfile');
// Экспорт с параметром формата
$router->add('/export/{book_id}/{format}', 'ExportController@export');
$router->add('/export/{book_id}', 'ExportController@export'); // по умолчанию pdf
$router->add('/export/shared/{share_token}/{format}', 'ExportController@exportShared');
$router->add('/export/shared/{share_token}', 'ExportController@exportShared'); // по умолчанию pdf
// Публичные страницы
$router->add('/book/{share_token}', 'BookController@viewPublic');
$router->add('/author/{id}', 'UserController@viewPublic');
$router->add('/series/{id}/view', 'SeriesController@viewPublic');
// Обработка запроса
$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;
});
?>

100
login.php
View File

@ -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'; ?>

View File

@ -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');
?>

View File

@ -64,6 +64,10 @@ class Book {
$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("
UPDATE books
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?, editor_type = ?
@ -73,8 +77,8 @@ class Book {
$data['title'],
$data['description'] ?? null,
$data['genre'] ?? null,
$data['series_id'] ?? null,
$data['sort_order_in_series'] ?? null,
$series_id, // Теперь это либо integer, либо NULL
$sort_order_in_series, // Теперь это либо integer, либо NULL
$published,
$editor_type,
$id,
@ -206,46 +210,73 @@ class Book {
}
public function convertChaptersContent($book_id, $from_editor, $to_editor) {
try {
$this->pdo->beginTransaction();
try {
$this->pdo->beginTransaction();
// Получаем все главы книги
$chapters = $this->getAllChapters($book_id);
foreach ($chapters as $chapter) {
$converted_content = $this->convertContent(
$chapter['content'],
$from_editor,
$to_editor
);
$chapters = $this->getAllChapters($book_id);
foreach ($chapters as $chapter) {
$converted_content = $this->convertContent(
$chapter['content'],
$from_editor,
$to_editor
);
$this->updateChapterContent($chapter['id'], $converted_content);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Error converting chapters: " . $e->getMessage());
return false;
// Обновляем контент главы
$this->updateChapterContent($chapter['id'], $converted_content);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Error converting chapters: " . $e->getMessage());
return false;
}
}
private function getAllChapters($book_id) {
$stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
private function getAllChapters($book_id) {
$stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function updateChapterContent($chapter_id, $content) {
$word_count = $this->countWords($content);
$stmt = $this->pdo->prepare("
UPDATE chapters
SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
return $stmt->execute([$content, $word_count, $chapter_id]);
}
private function convertContent($content, $from_editor, $to_editor) {
if ($from_editor === $to_editor) {
return $content;
}
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]);
require_once __DIR__ . '/../includes/parsedown/ParsedownExtra.php';
try {
if ($from_editor === 'markdown' && $to_editor === 'html') {
// Markdown to HTML
$parsedown = new ParsedownExtra();
return $parsedown->text($content);
} elseif ($from_editor === 'html' && $to_editor === 'markdown') {
// HTML to Markdown (упрощенная версия)
return $this->htmlToMarkdown($content);
}
} catch (Exception $e) {
error_log("Error converting content from {$from_editor} to {$to_editor}: " . $e->getMessage());
return $content;
}
return $content;
}
private function countWords($text) {
$text = strip_tags($text);
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
@ -254,26 +285,7 @@ class Book {
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();

View File

@ -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'; ?>

View File

@ -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'; ?>

View File

@ -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');
?>

View File

@ -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'; ?>

View File

@ -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'; ?>

49
views/auth/login.php Normal file
View File

@ -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'; ?>

85
views/auth/register.php Normal file
View File

@ -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'; ?>

66
views/books/create.php Normal file
View File

@ -0,0 +1,66 @@
<?php
// views/books/create.php
include 'views/layouts/header.php';
?>
<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($_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="editor_type" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Режим редактора
</label>
<select id="editor_type" name="editor_type" style="width: 100%; margin-bottom: 1.5rem;">
<option value="markdown" <?= ($_POST['editor_type'] ?? 'markdown') == 'markdown' ? 'selected' : '' ?>>Markdown редактор</option>
<option value="html" <?= ($_POST['editor_type'] ?? '') == 'html' ? 'selected' : '' ?>>HTML редактор (TinyMCE)</option>
</select>
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Серия
</label>
<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-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'; ?>

259
views/books/edit.php Normal file
View File

@ -0,0 +1,259 @@
<?php
// views/books/edit.php
include 'views/layouts/header.php';
?>
<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="editor_type" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Режим редактора
</label>
<select id="editor_type" name="editor_type" style="width: 100%; margin-bottom: 1.5rem;" onchange="showEditorWarning(this)">
<?php foreach ($editor_types as $type => $label): ?>
<option value="<?= e($type) ?>" <?= ($book['editor_type'] ?? 'markdown') == $type ? 'selected' : '' ?>>
<?= e($label) ?>
</option>
<?php endforeach; ?>
</select>
<div id="editor_warning" style="display: none; background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 4px; margin-bottom: 1rem;">
<strong>Внимание:</strong> При смене редактора содержимое всех глав будет автоматически сконвертировано в новый формат.
</div>
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Серия
</label>
<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: 2rem;">
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/normalize" onsubmit="return confirm('Нормализовать контент всех глав книги? Это действие нельзя отменить.')">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="button secondary">🔄 Нормализовать контент глав</button>
<p style="margin-top: 0.5rem; font-size: 0.8em; color: var(--muted-color);">
Если контент глав отображается неправильно после смены редактора, можно нормализовать его структуру.
</p>
</form>
</div>
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
<h3>Публичная ссылка для чтения</h3>
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;">
<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="compact-button secondary">
📋 Копировать
</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="compact-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: 2rem; padding: 1rem; 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">
📄 PDF
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank">
📝 DOCX
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank">
🌐 HTML
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank">
📄 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: 3rem;">
<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">
📑 Все главы
</a>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary">
✏️ Добавить главу
</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: 2rem; 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">
✏️ Добавить первую главу
</a>
</div>
<?php endif; ?>
</div>
<div style="margin-top: 2rem; 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="button" style="background: #ff4444; border-color: #ff4444; color: white;">
🗑️ Удалить книгу
</button>
</form>
</div>
<?php endif; ?>
<script>
function showEditorWarning(select) {
const warning = document.getElementById('editor_warning');
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
if (select.value !== currentEditor) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
}
document.addEventListener('DOMContentLoaded', function() {
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
const selectedEditor = document.getElementById('editor_type').value;
if (currentEditor !== selectedEditor) {
document.getElementById('editor_warning').style.display = 'block';
}
// Копирование ссылки для чтения
window.copyShareLink = function() {
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'; ?>

66
views/books/index.php Normal file
View File

@ -0,0 +1,66 @@
<?php
// views/books/index.php
include 'views/layouts/header.php';
?>
<h1>Мои книги</h1>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;">
<h2 style="margin: 0;">Всего книг: <?= count($books) ?></h2>
<a href="<?= SITE_URL ?>/books/create" class="action-button primary"> Новая книга</a>
</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="grid">
<?php foreach ($books as $book): ?>
<article>
<header>
<h3 style="margin-bottom: 0.5rem;">
<?= e($book['title']) ?>
<div style="float: right; display: flex; gap: 3px;">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button secondary" title="Редактировать книгу">
✏️
</a>
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="compact-button secondary" title="Просмотреть книгу" target="_blank">
👁️
</a>
</div>
</h3>
<?php if ($book['genre']): ?>
<p style="margin: 0; color: var(--muted-color);"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?>
</header>
<?php if ($book['description']): ?>
<p><?= e(mb_strimwidth($book['description'], 0, 200, '...')) ?></p>
<?php endif; ?>
<footer>
<div>
<small>
Глав: <?= $book['chapter_count'] ?> |
Слов: <?= $book['total_words'] ?> |
Статус: <?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
</small>
</div>
<div style="margin-top: 0.5rem; display: flex; gap: 5px; flex-wrap: wrap;">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="adaptive-button secondary">
📑 Главы
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>" class="adaptive-button secondary" target="_blank">
📄 Экспорт
</a>
</div>
</footer>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php include 'views/layouts/footer.php'; ?>

151
views/books/view_public.php Normal file
View File

@ -0,0 +1,151 @@
<?php
// views/books/view_public.php
include 'views/layouts/header.php';
?>
<div class="container">
<article style="max-width: 800px; 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_name) ?></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: flex; 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>
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>" class="adaptive-button secondary" style="font-size: 0.8em;">
📄 Скачать книгу
</a>
</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: ?>
<h2 style="text-align: center; margin-bottom: 2rem;">Оглавление</h2>
<div class="chapters-list">
<?php foreach ($chapters as $index => $chapter): ?>
<article style="margin-bottom: 1rem; padding: 1rem; background: var(--card-background-color); border-radius: 8px;">
<h3 style="margin-top: 0;">
<a href="#chapter-<?= $chapter['id'] ?>" style="text-decoration: none;">
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?>
</a>
</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<small style="color: var(--muted-color);">
Слов: <?= $chapter['word_count'] ?>
| Обновлено: <?= date('d.m.Y', strtotime($chapter['updated_at'])) ?>
</small>
</div>
</article>
<?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;">
<?php if ($book['editor_type'] == 'markdown'): ?>
<?= $Parsedown->text($chapter['content']) ?>
<?php else: ?>
<?= $chapter['content'] ?>
<?php endif; ?>
</div>
<?php if ($index < count($chapters) - 1): ?>
<div style="text-align: center; margin-top: 2rem;">
<a href="#chapter-<?= $chapters[$index + 1]['id'] ?>" class="button">
📖 Следующая глава
</a>
</div>
<?php endif; ?>
</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'; ?>

134
views/chapters/create.php Normal file
View File

@ -0,0 +1,134 @@
<?php
// views/chapters/create.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>
<?php if (($book['editor_type'] ?? 'markdown') == 'html'): ?>
<textarea id="content" name="content" class="html-editor"
placeholder="Начните писать вашу главу..."
rows="20"
style="width: 100%;"><?= e($_POST['content'] ?? '') ?></textarea>
<?php else: ?>
<div style="margin-bottom: 1rem; padding: 0.5rem; background: var(--card-background-color); border-radius: 5px;">
<div style="display: flex; gap: 3px; flex-wrap: nowrap; overflow-x: auto; padding: 5px 0;">
<button type="button" onclick="insertMarkdown('**')" class="compact-button secondary" title="Жирный текст" style="white-space: nowrap; flex-shrink: 0;">**B**</button>
<button type="button" onclick="insertMarkdown('*')" class="compact-button secondary" title="Курсив" style="white-space: nowrap; flex-shrink: 0;">*I*</button>
<button type="button" onclick="insertMarkdown('~~')" class="compact-button secondary" title="Зачеркнутый" style="white-space: nowrap; flex-shrink: 0;">~~S~~</button>
<button type="button" onclick="insertMarkdown('`')" class="compact-button secondary" title="Код" style="white-space: nowrap; flex-shrink: 0;">`code`</button>
<button type="button" onclick="insertMarkdown('\n\n- ')" class="compact-button secondary" title="Список" style="white-space: nowrap; flex-shrink: 0;">- список</button>
<button type="button" onclick="insertMarkdown('\n\n> ')" class="compact-button secondary" title="Цитата" style="white-space: nowrap; flex-shrink: 0;">> цитата</button>
<button type="button" onclick="insertMarkdown('\n\n# ')" class="compact-button secondary" title="Заголовок" style="white-space: nowrap; flex-shrink: 0;"># Заголовок</button>
<button type="button" onclick="insertMarkdown('\n— ')" class="compact-button secondary" title="Диалог" style="white-space: nowrap; flex-shrink: 0;"> диалог</button>
</div>
</div>
<textarea id="content" name="content"
placeholder="Начните писать вашу главу... Поддерживается Markdown разметка."
rows="20"
style="width: 100%; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px; line-height: 1.5;"><?= e($_POST['content'] ?? '') ?></textarea>
<div style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
<strong>Подсказка:</strong> Используйте Markdown для форматирования.
<a href="https://commonmark.org/help/" target="_blank">Справка по Markdown</a>
</div>
<?php endif; ?>
<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>
<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';
// Добавляем CSRF токен
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/markdown-editor.js"></script>
<script src="/assets/js/autosave.js"></script>
<?php include 'views/layouts/footer.php'; ?>

123
views/chapters/edit.php Normal file
View File

@ -0,0 +1,123 @@
<?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>
<?php if (($book['editor_type'] ?? 'markdown') == 'html'): ?>
<textarea id="content" name="content" class="html-editor"
placeholder="Начните писать вашу главу..."
rows="20"
style="width: 100%;"><?= e($chapter['content']) ?></textarea>
<?php else: ?>
<textarea id="content" name="content"
placeholder="Начните писать вашу главу... Поддерживается Markdown разметка."
rows="20"
style="width: 100%; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 14px; line-height: 1.5;"><?= e($chapter['content']) ?></textarea>
<div style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
<strong>Подсказка:</strong> Используйте Markdown для форматирования.
<a href="https://commonmark.org/help/" target="_blank">Справка по Markdown</a>
</div>
<?php endif; ?>
<div style="margin-top: 1rem;">
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Статус главы
</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>
<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/markdown-editor.js"></script>
<script src="/assets/js/autosave.js"></script>
<?php include 'views/layouts/footer.php'; ?>

201
chapters.php → views/chapters/index.php Executable file → Normal file
View File

@ -1,120 +1,81 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$book_id = $_GET['book_id'] ?? null;
if (!$book_id) {
$_SESSION['error'] = "Не указана книга";
redirect('books.php');
}
$bookModel = new Book($pdo);
$chapterModel = new Chapter($pdo);
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
redirect('books.php');
}
// Получаем информацию о книге и главах
$book = $bookModel->findById($book_id);
$chapters = $chapterModel->findByBook($book_id);
$page_title = "Главы книги: " . e($book['title']);
include 'views/header.php';
?>
<div style="margin-bottom: 1rem;">
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem;">Главы книги: <?= e($book['title']) ?></h1>
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
<a href="chapter_edit.php?book_id=<?= $book_id ?>" class="adaptive-button"> Новая глава</a>
<a href="book_edit.php?id=<?= $book_id ?>" class="adaptive-button secondary">✏️ Редактировать книгу</a>
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" class="adaptive-button secondary" target="_blank">👁️ Просмотреть книгу</a>
<a href="books.php" class="adaptive-button secondary">📚 Все книги</a>
</div>
</div>
<?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 (empty($chapters)): ?>
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px; margin-top: 1rem;">
<h3>В этой книге пока нет глав</h3>
<p>Создайте первую главу для вашей книги</p>
<a href="chapter_edit.php?book_id=<?= $book_id ?>" class="adaptive-button">📝 Создать первую главу</a>
</div>
<?php else: ?>
<div style="overflow-x: auto; margin-top: 1rem;">
<table class="compact-table">
<thead>
<tr>
<th style="width: 5%;"></th>
<th style="width: 40%;">Название главы</th>
<th style="width: 15%;">Статус</th>
<th style="width: 10%;">Слов</th>
<th style="width: 20%;">Обновлено</th>
<th style="width: 10%;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($chapters as $index => $chapter): ?>
<tr>
<td><?= $index + 1 ?></td>
<td>
<strong><?= e($chapter['title']) ?></strong>
<?php if ($chapter['description']): ?>
<br><small style="color: #666;"><?= e(mb_strimwidth($chapter['description'], 0, 100, '...')) ?></small>
<?php endif; ?>
</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'; ?>
<?php
// views/chapters/index.php
include 'views/layouts/header.php';
?>
<div style="margin-bottom: 1rem;">
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem;">Главы книги: <?= e($book['title']) ?></h1>
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button"> Новая глава</a>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="adaptive-button secondary">✏️ Редактировать книгу</a>
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="adaptive-button secondary" target="_blank">👁️ Просмотреть книгу</a>
<a href="<?= SITE_URL ?>/books" class="adaptive-button secondary">📚 Все книги</a>
</div>
</div>
<?php if (empty($chapters)): ?>
<div style="text-align: center; padding: 2rem; background: var(--card-background-color); border-radius: 5px; margin-top: 1rem;">
<h3>В этой книге пока нет глав</h3>
<p>Создайте первую главу для вашей книги</p>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button">📝 Создать первую главу</a>
</div>
<?php else: ?>
<div style="overflow-x: auto; margin-top: 1rem;">
<table class="compact-table">
<thead>
<tr>
<th style="width: 5%;"></th>
<th style="width: 40%;">Название главы</th>
<th style="width: 15%;">Статус</th>
<th style="width: 10%;">Слов</th>
<th style="width: 20%;">Обновлено</th>
<th style="width: 10%;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($chapters as $index => $chapter): ?>
<tr>
<td><?= $index + 1 ?></td>
<td>
<strong><?= e($chapter['title']) ?></strong>
<?php if ($chapter['description']): ?>
<br><small style="color: var(--muted-color);"><?= e(mb_strimwidth($chapter['description'], 0, 100, '...')) ?></small>
<?php endif; ?>
</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="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary" title="Редактировать">
✏️
</a>
<form method="post" action="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');">
<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: var(--card-background-color); 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/layouts/footer.php'; ?>

49
preview.php → views/chapters/preview.php Executable file → Normal file
View File

@ -1,23 +1,6 @@
<?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);
// views/chapters/preview.php
include 'views/layouts/header.php';
?>
<!DOCTYPE html>
<html lang="ru">
@ -25,7 +8,8 @@ $page_title = "Предпросмотр: " . e($title);
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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>
body {
padding: 20px;
@ -41,33 +25,33 @@ $page_title = "Предпросмотр: " . e($title);
margin-top: 1.5em;
margin-bottom: 0.5em;
}
h1 { border-bottom: 2px solid #007bff; padding-bottom: 0.3em; }
h2 { border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
h1 { border-bottom: 2px solid var(--primary); padding-bottom: 0.3em; }
h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; }
p {
margin-bottom: 1em;
}
code {
background: #f5f5f5;
background: var(--card-background-color);
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
pre {
background: #f5f5f5;
background: var(--card-background-color);
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
border-left: 4px solid #007bff;
border-left: 4px solid var(--primary);
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 4px solid #ddd;
border-left: 4px solid var(--border-color);
padding-left: 1rem;
margin-left: 0;
color: #666;
color: var(--muted-color);
font-style: italic;
}
strong { font-weight: bold; }
@ -84,14 +68,14 @@ $page_title = "Предпросмотр: " . e($title);
margin: 1rem 0;
}
table, th, td {
border: 1px solid #ddd;
border: 1px solid var(--border-color);
}
th, td {
padding: 8px 12px;
text-align: left;
}
th {
background: #f5f5f5;
background: var(--card-background-color);
}
</style>
</head>
@ -102,11 +86,14 @@ $page_title = "Предпросмотр: " . e($title);
</header>
<main class="content">
<?= $html_content ?>
<?= $content ?>
</main>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #ddd;">
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<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>
</body>
</html>

119
views/dashboard/index.php Normal file
View File

@ -0,0 +1,119 @@
<?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>
<h3 style="margin-bottom: 0.5rem;">
<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);"><em><?= e($book['genre']) ?></em></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'; ?>

20
views/errors/404.php Normal file
View File

@ -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'; ?>

View File

@ -1,7 +0,0 @@
<?php
// views/footer.php
?>
</main>
</body>
</html>

View File

@ -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; ?>

View File

48
views/layouts/footer.php Executable file
View File

@ -0,0 +1,48 @@
<?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>
&copy; <?= 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);
});
}
// Инициализация TinyMCE если есть текстовые редакторы
document.addEventListener('DOMContentLoaded', function() {
const htmlEditors = document.querySelectorAll('.html-editor');
htmlEditors.forEach(function(editor) {
if (typeof tinymce !== 'undefined') {
tinymce.init({
selector: '#' + editor.id,
plugins: 'advlist autolink lists link image charmap preview anchor',
toolbar: 'undo redo | formatselect | bold italic | alignleft aligncenter alignright | bullist numlist outdent indent | link image',
language: 'ru',
height: 400,
menubar: false,
statusbar: false
});
}
});
});
</script>
</body>
</html>

69
views/layouts/header.php Executable file
View File

@ -0,0 +1,69 @@
<?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">
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.6/tinymce.min.js" referrerpolicy="origin"></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 ?>/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; ?>

58
views/series/create.php Normal file
View File

@ -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'; ?>

126
views/series/edit.php Normal file
View File

@ -0,0 +1,126 @@
<?php
// views/series/edit.php
include 'views/layouts/header.php';
?>
<h1>Редактирование серии: <?= e($series['title']) ?></h1>
<?php if (isset($error) && $error): ?>
<div class="alert alert-error">
<?= e($error) ?>
</div>
<?php endif; ?>
<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']) ?>"
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']) ?></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>
<?php if ($series): ?>
<div style="margin-top: 3rem;">
<h3>Книги в этой серии</h3>
<?php if (empty($books_in_series)): ?>
<div style="text-align: center; padding: 2rem; background: var(--card-background-color); border-radius: 5px;">
<p>В этой серии пока нет книг.</p>
<a href="<?= SITE_URL ?>/books" class="adaptive-button">📚 Добавить книги</a>
</div>
<?php else: ?>
<div style="overflow-x: auto;">
<table class="compact-table">
<thead>
<tr>
<th style="width: 10%;">Порядок</th>
<th style="width: 40%;">Название книги</th>
<th style="width: 20%;">Жанр</th>
<th style="width: 15%;">Статус</th>
<th style="width: 15%;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($books_in_series as $book): ?>
<tr>
<td><?= $book['sort_order_in_series'] ?></td>
<td>
<strong><?= e($book['title']) ?></strong>
<?php if ($book['description']): ?>
<br><small style="color: var(--muted-color);"><?= e(mb_strimwidth($book['description'], 0, 100, '...')) ?></small>
<?php endif; ?>
</td>
<td><?= e($book['genre']) ?></td>
<td>
<span style="color: <?= $book['published'] ? 'green' : 'orange' ?>">
<?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
</span>
</td>
<td>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button secondary">
Редактировать
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
// Вычисляем общую статистику
$total_chapters = 0;
$total_words = 0;
foreach ($books_in_series as $book) {
$bookModel = new Book($pdo);
$stats = $bookModel->getBookStats($book['id']);
$total_chapters += $stats['chapter_count'] ?? 0;
$total_words += $stats['total_words'] ?? 0;
}
?>
<div style="margin-top: 1rem; padding: 0.5rem; background: var(--card-background-color); border-radius: 3px;">
<strong>Статистика серии:</strong>
Книг: <?= count($books_in_series) ?> |
Глав: <?= $total_chapters ?> |
Слов: <?= $total_words ?>
</div>
<?php endif; ?>
</div>
<div style="margin-top: 2rem; text-align: center;">
<form method="post" action="<?= SITE_URL ?>/series/<?= $series['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить серию «<?= e($series['title']) ?>»? Книги останутся, но будут убраны из серии.');">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="button" style="background: #ff4444; border-color: #ff4444; color: white;">
🗑️ Удалить серию
</button>
</form>
</div>
<?php endif; ?>
<?php include 'views/layouts/footer.php'; ?>

View File

@ -1,161 +1,122 @@
<?php
require_once 'config/config.php';
require_once 'includes/parsedown/ParsedownExtra.php';
$Parsedown = new ParsedownExtra();
$series_id = (int)($_GET['id'] ?? 0);
if (!$series_id) {
http_response_code(400);
echo "<h2>Неверный запрос</h2>";
include 'views/footer.php';
exit;
}
$seriesModel = new Series($pdo);
$series = $seriesModel->findById($series_id);
if (!$series) {
http_response_code(404);
echo "<h2>Серия не найдена</h2>";
include 'views/footer.php';
exit;
}
// Получаем только опубликованные книги серии
$books = $seriesModel->getBooksInSeries($series_id, true);
// Получаем информацию об авторе
$stmt = $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($pdo);
$total_words = 0;
$total_chapters = 0;
foreach ($books as $book) {
$book_stats = $bookModel->getBookStats($book['id'], true); // true - только опубликованные главы
$total_words += $book_stats['total_words'] ?? 0;
$total_chapters += $book_stats['chapter_count'] ?? 0;
}
$page_title = $series['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;">
<h1 style="margin-bottom: 0.5rem;"><?= e($series['title']) ?></h1>
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
Серия книг от
<a href="author.php?id=<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
</p>
<?php if ($series['description']): ?>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;">
<?= $Parsedown->text($series['description']) ?>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<span>Книг: <?= count($books) ?></span>
<span>Глав: <?= $total_chapters ?></span>
<span>Слов: <?= $total_words ?></span>
</div>
</header>
<?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
<h3>В этой серии пока нет опубликованных книг</h3>
<p>Автор еще не опубликовал книги из этой серии</p>
</div>
<?php else: ?>
<div class="series-books">
<h2 style="text-align: center; margin-bottom: 2rem;">Книги серии</h2>
<?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;">
<?php if ($book['cover_image']): ?>
<div style="flex-shrink: 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 #ddd;"
onerror="this.style.display='none'">
</div>
<?php else: ?>
<div style="flex-shrink: 0;">
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;">
<?php if ($book['sort_order_in_series']): ?>
<small style="color: #666;">Книга <?= $book['sort_order_in_series'] ?></small><br>
<?php endif; ?>
<?= e($book['title']) ?>
</h3>
<?php if ($book['genre']): ?>
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?>
<?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
<?php endif; ?>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<a href="view_book.php?share_token=<?= e($book['share_token']) ?>" class="adaptive-button">
Читать
</a>
<?php
$bookModel = new Book($pdo);
$book_stats = $bookModel->getBookStats($book['id'], true); // true - только опубликованные главы
?>
<small style="color: #666;">
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | Слов: <?= $book_stats['total_words'] ?? 0 ?>
</small>
</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'; ?>
<?php
// views/series/view_public.php
include 'views/layouts/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 var(--muted-border-color); padding-bottom: 1rem;">
<h1 style="margin-bottom: 0.5rem;"><?= e($series['title']) ?></h1>
<p style="color: var(--muted-color); font-style: italic; margin-bottom: 0.5rem;">
Серия книг от
<a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
</p>
<?php if ($series['description']): ?>
<div style="background: var(--card-background-color); padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;">
<?= $Parsedown->text($series['description']) ?>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);">
<span>Книг: <?= count($books) ?></span>
<span>Глав: <?= $total_chapters ?></span>
<span>Слов: <?= $total_words ?></span>
</div>
</header>
<?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
<h3>В этой серии пока нет опубликованных книг</h3>
<p>Автор еще не опубликовал книги из этой серии</p>
</div>
<?php else: ?>
<div class="series-books">
<h2 style="text-align: center; margin-bottom: 2rem;">Книги серии</h2>
<?php 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;">
<?php if ($book['cover_image']): ?>
<div style="flex-shrink: 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);"
onerror="this.style.display='none'">
</div>
<?php else: ?>
<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>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;">
<?php if ($book['sort_order_in_series']): ?>
<small style="color: var(--muted-color);">Книга <?= $book['sort_order_in_series'] ?></small><br>
<?php endif; ?>
<?= e($book['title']) ?>
</h3>
<?php if ($book['genre']): ?>
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?>
<?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
<?php endif; ?>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button">
Читать
</a>
<?php
$bookModel = new Book($pdo);
$book_stats = $bookModel->getBookStats($book['id'], true);
?>
<small style="color: var(--muted-color);">
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | Слов: <?= $book_stats['total_words'] ?? 0 ?>
</small>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
<?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) ?>
Автор: <a href="<?= SITE_URL ?>/author/<?= $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;
border: 1px solid var(--border-color);
}
.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/layouts/footer.php'; ?>

336
profile.php → views/user/profile.php Executable file → Normal file
View File

@ -1,195 +1,141 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$userModel = new User($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 = "Ошибка при обновлении профиля";
}
}
}
$page_title = "Мой профиль";
include 'views/header.php';
?>
<h1>Мой профиль</h1>
<?php if ($message): ?>
<div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-error' : 'alert-success' ?>">
<?= e($message) ?>
</div>
<?php endif; ?>
<div class="grid">
<article>
<h2>Основная информация</h2>
<form method="post" enctype="multipart/form-data">
<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" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
</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($user['display_name'] ?? $user['username']) ?>"
style="width: 100%;" required>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Email
</label>
<input type="email" id="email" name="email"
value="<?= e($user['email'] ?? '') ?>"
style="width: 100%;">
</div>
<div style="margin-bottom: 1.5rem;">
<label for="bio" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
О себе (отображается на вашей публичной странице)
</label>
<textarea id="bio" name="bio"
placeholder="Расскажите о себе, своих интересах, стиле письма..."
rows="6"
style="width: 100%;"><?= e($user['bio'] ?? '') ?></textarea>
<small style="color: #666;">
Поддерживается Markdown форматирование
</small>
</div>
<div class="profile-buttons">
<button type="submit" class="profile-button primary">
💾 Сохранить изменения
</button>
<a href="dashboard.php" class="profile-button secondary">
↩️ Назад
</a>
</div>
</form>
</article>
<article>
<h2>Аватарка</h2>
<div style="text-align: center; margin-bottom: 1.5rem;">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
alt="Аватарка"
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid #007bff;"
onerror="this.style.display='none'">
<?php else: ?>
<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) ?>
</div>
<?php endif; ?>
</div>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<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'; ?>
<?php
// views/user/profile.php
include 'views/layouts/header.php';
?>
<h1>Мой профиль</h1>
<?php if ($message): ?>
<div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-error' : 'alert-success' ?>">
<?= e($message) ?>
</div>
<?php endif; ?>
<div class="grid">
<article>
<h2>Основная информация</h2>
<form method="post" enctype="multipart/form-data">
<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" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
</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($user['display_name'] ?? $user['username']) ?>"
style="width: 100%;" required>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Email
</label>
<input type="email" id="email" name="email"
value="<?= e($user['email'] ?? '') ?>"
style="width: 100%;">
</div>
<div style="margin-bottom: 1.5rem;">
<label for="bio" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
О себе (отображается на вашей публичной странице)
</label>
<textarea id="bio" name="bio"
placeholder="Расскажите о себе, своих интересах, стиле письма..."
rows="6"
style="width: 100%;"><?= e($user['bio'] ?? '') ?></textarea>
<small style="color: var(--muted-color);">
Поддерживается Markdown форматирование
</small>
</div>
<div class="profile-buttons">
<button type="submit" class="profile-button primary">
💾 Сохранить изменения
</button>
<a href="<?= SITE_URL ?>/dashboard" class="profile-button secondary">
↩️ Назад
</a>
</div>
</form>
</article>
<article>
<h2>Аватарка</h2>
<div style="text-align: center; margin-bottom: 1.5rem;">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
alt="Аватарка"
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid var(--primary);"
onerror="this.style.display='none'">
<?php else: ?>
<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>
<?php endif; ?>
</div>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<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: var(--muted-color);">
Разрешены: 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: var(--card-background-color); border-radius: 5px;">
<p style="margin: 0; font-size: 0.9em; color: var(--muted-color);">
<strong>Примечание:</strong> Аватарка отображается на вашей публичной странице автора
</p>
</div>
<?php endif; ?>
</article>
</div>
<article>
<h3>Информация об аккаунте</h3>
<p><a href="<?= SITE_URL ?>/author/<?= $_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/layouts/footer.php'; ?>

348
author.php → views/user/view_public.php Executable file → Normal file
View File

@ -1,192 +1,156 @@
<?php
require_once 'config/config.php';
require_once 'includes/parsedown/ParsedownExtra.php';
$Parsedown = new ParsedownExtra();
$author_id = (int)($_GET['id'] ?? 0);
if (!$author_id) {
http_response_code(400);
echo "<h2>Неверный запрос</h2>";
include 'views/footer.php';
exit;
}
$stmt = $pdo->prepare("SELECT id, username, display_name, avatar, bio FROM users WHERE id = ?");
$stmt->execute([$author_id]);
$author = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$author) {
http_response_code(404);
echo "<h2>Автор не найден</h2>";
include 'views/footer.php';
exit;
}
$bookModel = new Book($pdo);
$books = $bookModel->findByUser($author_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;
}
$page_title = ($author['display_name'] ?: $author['username']) . ' — публичная страница';
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;">
<!-- Аватарка автора -->
<div style="margin-bottom: 1rem;">
<?php if (!empty($author['avatar'])): ?>
<img src="<?= AVATARS_URL . e($author['avatar']) ?>"
alt="<?= e($author['display_name'] ?: $author['username']) ?>"
style="width: 150px; height: 150px; border-radius: 50%; border: 3px solid #007bff; object-fit: cover;"
onerror="this.style.display='none'">
<?php else: ?>
<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;">
<?= mb_substr(e($author['display_name'] ?: $author['username']), 0, 1) ?>
</div>
<?php endif; ?>
</div>
<h1 style="margin-bottom: 0.5rem;"><?= e($author['display_name'] ?: $author['username']) ?></h1>
<!-- Биография автора -->
<?php if (!empty($author['bio'])): ?>
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
<?= $Parsedown->text($author['bio']) ?>
</div>
<?php endif; ?>
<!-- Статистика автора -->
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #007bff;"><?= $total_books ?></div>
<div>Книг</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #28a745;"><?= $total_chapters ?></div>
<div>Глав</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #6f42c1;"><?= $total_words ?></div>
<div>Слов</div>
</div>
</div>
</header>
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2>
<?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
<h3>У этого автора пока нет опубликованных книг</h3>
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p>
</div>
<?php else: ?>
<div class="author-books">
<?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;">
<?php if ($book['cover_image']): ?>
<div style="flex-shrink: 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 #ddd;"
onerror="this.style.display='none'">
</div>
<?php else: ?>
<div style="flex-shrink: 0;">
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
<?php if ($book['genre']): ?>
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?>
<?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
<?php endif; ?>
<?php
$book_stats = $bookModel->getBookStats($book['id'], true);
$chapter_count = $book_stats['chapter_count'] ?? 0;
$word_count = $book_stats['total_words'] ?? 0;
?>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<a href="view_book.php?share_token=<?= e($book['share_token']) ?>" class="adaptive-button">
Читать книгу
</a>
<small style="color: #666;">
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?>
</small>
</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) ?>
<?= date('Y') ?>
</p>
</footer>
</article>
</div>
<style>
.author-books article {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
}
.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'; ?>
<?php
// views/user/view_public.php
include 'views/layouts/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 var(--muted-border-color); padding-bottom: 1rem;">
<!-- Аватарка автора -->
<div style="margin-bottom: 1rem;">
<?php if (!empty($user['avatar'])): ?>
<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;"
onerror="this.style.display='none'">
<?php else: ?>
<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) ?>
</div>
<?php endif; ?>
</div>
<h1 style="margin-bottom: 0.5rem;"><?= e($user['display_name'] ?: $user['username']) ?></h1>
<!-- Биография автора -->
<?php if (!empty($user['bio'])): ?>
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
<?= $Parsedown->text($user['bio']) ?>
</div>
<?php endif; ?>
<!-- Статистика автора -->
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);">
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: var(--primary);"><?= $total_books ?></div>
<div>Книг</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: var(--success);"><?= $total_chapters ?></div>
<div>Глав</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: var(--warning);"><?= $total_words ?></div>
<div>Слов</div>
</div>
</div>
</header>
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2>
<?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
<h3>У этого автора пока нет опубликованных книг</h3>
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p>
</div>
<?php else: ?>
<div class="author-books">
<?php foreach ($books as $book): ?>
<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']): ?>
<div style="flex-shrink: 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);"
onerror="this.style.display='none'">
</div>
<?php else: ?>
<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>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
<?php if ($book['genre']): ?>
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?>
<?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
<?php endif; ?>
<?php
$book_stats = $bookModel->getBookStats($book['id'], true);
$chapter_count = $book_stats['chapter_count'] ?? 0;
$word_count = $book_stats['total_words'] ?? 0;
?>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button">
Читать книгу
</a>
<small style="color: var(--muted-color);">
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?>
</small>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
<?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>
.author-books article {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid var(--border-color);
}
.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, var(--primary) 0%, var(--secondary) 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/layouts/footer.php'; ?>