web_writer/333.txt

6500 lines
258 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ./controllers/DashboardController.php
<?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' => 'Панель управления'
]);
}
}
?>
// ./controllers/AuthController.php
<?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' => 'Регистрация'
]);
}
}
?>
// ./controllers/ChapterController.php
<?php
// controllers/ChapterController.php
require_once 'controllers/BaseController.php';
require_once 'models/Chapter.php';
require_once 'models/Book.php';
class ChapterController extends BaseController {
public function index($book_id) {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo);
// Проверяем права доступа к книге
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
$this->redirect('/books');
}
// Получаем информацию о книге и главах
$book = $bookModel->findById($book_id);
$chapters = $chapterModel->findByBook($book_id);
$this->render('chapters/index', [
'book' => $book,
'chapters' => $chapters,
'page_title' => "Главы книги: " . e($book['title'])
]);
}
public function create($book_id) {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo);
// Проверяем права доступа к книге
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
$this->redirect('/books');
}
$book = $bookModel->findById($book_id);
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content']) ?? '';
$content = $this->cleanChapterContent($content);
$status = $_POST['status'] ?? 'draft';
if (empty($title)) {
$error = "Название главы обязательно";
} else {
$data = [
'book_id' => $book_id,
'title' => $title,
'content' => $content,
'status' => $status
];
if ($chapterModel->create($data)) {
$_SESSION['success'] = "Глава успешно создана";
$this->redirect("/books/{$book_id}/chapters");
} else {
$error = "Ошибка при создании главы";
}
}
}
}
$this->render('chapters/create', [
'book' => $book,
'error' => $error,
'page_title' => "Новая глава для: " . e($book['title'])
]);
}
public function edit($id) {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$chapterModel = new Chapter($this->pdo);
$bookModel = new Book($this->pdo);
// Получаем главу и книгу
$chapter = $chapterModel->findById($id);
if (!$chapter) {
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Глава не найдена']);
exit;
}
$_SESSION['error'] = "Глава не найдена";
$this->redirect('/books');
}
$book = $bookModel->findById($chapter['book_id']);
// Проверяем права доступа
if (!$chapterModel->userOwnsChapter($id, $user_id)) {
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Доступ запрещен']);
exit;
}
$_SESSION['error'] = "У вас нет доступа к этой главе";
$this->redirect('/books');
}
// Обработка POST запроса
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$content = $this->cleanChapterContent($_POST['content'] ?? '');
$status = $_POST['status'] ?? 'draft';
// Проверяем CSRF
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => 'Ошибка безопасности']);
exit;
}
$error = "Ошибка безопасности";
}
if (empty($title)) {
$error = "Название главы обязательно";
}
$data = ['title' => $title, 'content' => $content, 'status' => $status];
// Если это автосейв — возвращаем JSON сразу
if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
if (empty($error)) {
$success = $chapterModel->update($id, $data);
header('Content-Type: application/json');
echo json_encode(['success' => $success, 'error' => $success ? null : 'Ошибка при сохранении']);
} else {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'error' => $error]);
}
exit;
}
// Обычное сохранение формы
if (empty($error)) {
if ($chapterModel->update($id, $data)) {
$_SESSION['success'] = "Глава успешно обновлена";
$this->redirect("/books/{$chapter['book_id']}/chapters");
} else {
$error = "Ошибка при обновлении главы";
}
}
}
// Рендер страницы
$this->render('chapters/edit', [
'chapter' => $chapter,
'book' => $book,
'error' => $error ?? '',
'page_title' => "Редактирование главы: " . e($chapter['title'])
]);
}
public function delete($id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
$this->redirect('/books');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect('/books');
}
$user_id = $_SESSION['user_id'];
$chapterModel = new Chapter($this->pdo);
// Проверяем права доступа
if (!$chapterModel->userOwnsChapter($id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой главе";
$this->redirect('/books');
}
$chapter = $chapterModel->findById($id);
$book_id = $chapter['book_id'];
// Удаляем главу
if ($chapterModel->delete($id)) {
$_SESSION['success'] = "Глава успешно удалена";
} else {
$_SESSION['error'] = "Ошибка при удалении главы";
}
$this->redirect("/books/{$book_id}/chapters");
}
public function preview() {
$this->requireLogin();
$content = $_POST['content'] ?? '';
$content = $this->cleanChapterContent($content);
$title = $_POST['title'] ?? 'Предпросмотр';
$this->render('chapters/preview', [
'content' => $content,
'title' => $title,
'page_title' => "Предпросмотр: " . e($title)
]);
}
// Добавьте эту функцию в начало файла
function cleanChapterContent($content) {
// Удаляем лишние пробелы в начале и конце
$content = trim($content);
// Удаляем пустые абзацы и параграфы, содержащие только пробелы
$content = preg_replace('/<p[^>]*>\s*(?:<br\s*\/?>|&nbsp;)?\s*<\/p>/i', '', $content);
$content = preg_replace('/<p[^>]*>\s*<\/p>/i', '', $content);
// Удаляем последовательные пустые абзацы
$content = preg_replace('/(<\/p>\s*<p[^>]*>)+/', '</p><p>', $content);
// Удаляем лишние пробелы в начале и конце каждого параграфа
$content = preg_replace('/(<p[^>]*>)\s+/', '$1', $content);
$content = preg_replace('/\s+<\/p>/', '</p>', $content);
// Удаляем лишние переносы строк
$content = preg_replace('/\n{3,}/', "\n\n", $content);
return $content;
}
}
?>
// ./controllers/ExportController.php
<?php
// controllers/ExportController.php
require_once 'controllers/BaseController.php';
require_once 'models/Book.php';
require_once 'models/Chapter.php';
require_once 'vendor/autoload.php';
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
use TCPDF;
class ExportController extends BaseController {
public function export($book_id, $format = 'pdf') {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo);
$book = $bookModel->findById($book_id);
if (!$book || $book['user_id'] != $user_id) {
$_SESSION['error'] = "Доступ запрещен";
$this->redirect('/books');
}
// Для автора - все главы
$chapters = $chapterModel->findByBook($book_id);
// Получаем информацию об авторе
$author_name = $this->getAuthorName($book['user_id']);
$this->handleExport($book, $chapters, false, $author_name, $format);
}
public function exportShared($share_token, $format = 'pdf') {
$bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo);
$book = $bookModel->findByShareToken($share_token);
if (!$book) {
$_SESSION['error'] = "Книга не найдена";
$this->redirect('/');
}
// Для публичного доступа - только опубликованные главы
$chapters = $chapterModel->getPublishedChapters($book['id']);
// Получаем информацию об авторе
$author_name = $this->getAuthorName($book['user_id']);
$this->handleExport($book, $chapters, true, $author_name, $format);
}
private function getAuthorName($user_id) {
$stmt = $this->pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
$stmt->execute([$user_id]);
$author_info = $stmt->fetch(PDO::FETCH_ASSOC);
if ($author_info && $author_info['display_name'] != "") {
return $author_info['display_name'];
} elseif ($author_info) {
return $author_info['username'];
}
return "Неизвестный автор";
}
private function handleExport($book, $chapters, $is_public, $author_name, $format) {
switch ($format) {
case 'pdf':
$this->exportPDF($book, $chapters, $is_public, $author_name);
break;
case 'docx':
$this->exportDOCX($book, $chapters, $is_public, $author_name);
break;
case 'html':
$this->exportHTML($book, $chapters, $is_public, $author_name);
break;
case 'txt':
$this->exportTXT($book, $chapters, $is_public, $author_name);
break;
default:
$_SESSION['error'] = "Неверный формат экспорта";
$redirect_url = $is_public ?
"/book/{$book['share_token']}" :
"/books/{$book['id']}/edit";
$this->redirect($redirect_url);
}
}
function exportPDF($book, $chapters, $is_public, $author_name) {
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
// Устанавливаем метаданные документа
$pdf->SetCreator(APP_NAME);
$pdf->SetAuthor($author_name);
$pdf->SetTitle($book['title']);
$pdf->SetSubject($book['genre'] ?? '');
// Устанавливаем margins
$pdf->SetMargins(15, 25, 15);
$pdf->SetHeaderMargin(10);
$pdf->SetFooterMargin(10);
// Устанавливаем авто разрыв страниц
$pdf->SetAutoPageBreak(TRUE, 15);
// Добавляем страницу
$pdf->AddPage();
// Устанавливаем шрифт с поддержкой кириллицы
$pdf->SetFont('dejavusans', '', 12);
// Заголовок книги
$pdf->SetFont('dejavusans', 'B', 18);
$pdf->Cell(0, 10, $book['title'], 0, 1, 'C');
$pdf->Ln(2);
// Автор
$pdf->SetFont('dejavusans', 'I', 14);
$pdf->Cell(0, 10, $author_name, 0, 1, 'C');
$pdf->Ln(5);
// Обложка книги
if (!empty($book['cover_image'])) {
$cover_path = COVERS_PATH . $book['cover_image'];
if (file_exists($cover_path)) {
list($width, $height) = getimagesize($cover_path);
$max_width = 80;
$ratio = $width / $height;
$new_height = $max_width / $ratio;
$x = (210 - $max_width) / 2;
$pdf->Image($cover_path, $x, $pdf->GetY(), $max_width, $new_height, '', '', 'N', false, 300, '', false, false, 0, false, false, false);
$pdf->Ln($new_height + 5);
}
}
// Жанр
if (!empty($book['genre'])) {
$pdf->SetFont('dejavusans', 'I', 12);
$pdf->Cell(0, 10, 'Жанр: ' . $book['genre'], 0, 1, 'C');
$pdf->Ln(5);
}
// Описание
if (!empty($book['description'])) {
$pdf->SetFont('dejavusans', '', 11);
$pdf->MultiCell(0, 6, $book['description'], 0, 'J');
$pdf->Ln(10);
}
// Интерактивное оглавление
$chapterLinks = [];
if (!empty($chapters)) {
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C');
$pdf->Ln(5);
$toc_page = $pdf->getPage();
$pdf->SetFont('dejavusans', '', 11);
foreach ($chapters as $index => $chapter) {
$chapter_number = $index + 1;
$link = $pdf->AddLink();
$chapterLinks[$chapter['id']] = $link; // Сохраняем ссылку для этой главы
$pdf->Cell(0, 6, "{$chapter_number}. {$chapter['title']}", 0, 1, 'L', false, $link);
}
$pdf->Ln(10);
}
// Разделитель
$pdf->Line(15, $pdf->GetY(), 195, $pdf->GetY());
$pdf->Ln(10);
// Главы с закладками и правильными ссылками
foreach ($chapters as $index => $chapter) {
// Добавляем новую страницу для каждой главы
$pdf->AddPage();
// УСТАНАВЛИВАЕМ ЯКОРЬ ДЛЯ ССЫЛКИ
if (isset($chapterLinks[$chapter['id']])) {
$pdf->SetLink($chapterLinks[$chapter['id']]);
}
// Устанавливаем закладку для этой главы
$pdf->Bookmark($chapter['title'], 0, 0, '', 'B', array(0, 0, 0));
// Название главы
$pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 8, $chapter['title'], 0, 1);
$pdf->Ln(2);
// Контент главы
$pdf->SetFont('dejavusans', '', 11);
$htmlContent = $chapter['content'];
$pdf->writeHTML($htmlContent, true, false, true, false, '');
$pdf->Ln(8);
}
// Футер с информацией
$pdf->SetY(-25);
$pdf->SetFont('dejavusans', 'I', 8);
$pdf->Cell(0, 6, 'Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), 0, 1, 'C');
$pdf->Cell(0, 6, 'Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), 0, 1, 'C');
// Отправляем файл
$filename = cleanFilename($book['title']) . '.pdf';
$pdf->Output($filename, 'D');
exit;
}
function exportDOCX($book, $chapters, $is_public, $author_name) {
$phpWord = new PhpWord();
// Стили документа
$phpWord->setDefaultFontName('Times New Roman');
$phpWord->setDefaultFontSize(12);
// Секция документа
$section = $phpWord->addSection();
// Заголовок книги
$section->addText($book['title'], ['bold' => true, 'size' => 16], ['alignment' => 'center']);
$section->addTextBreak(1);
// Автор
$section->addText($author_name, ['italic' => true, 'size' => 14], ['alignment' => 'center']);
$section->addTextBreak(2);
// Обложка книги
if (!empty($book['cover_image'])) {
$cover_path = COVERS_PATH . $book['cover_image'];
if (file_exists($cover_path)) {
$section->addImage($cover_path, [
'width' => 150,
'height' => 225,
'alignment' => 'center'
]);
$section->addTextBreak(2);
}
}
// Жанр
if (!empty($book['genre'])) {
$section->addText('Жанр: ' . $book['genre'], ['italic' => true], ['alignment' => 'center']);
$section->addTextBreak(1);
}
// Описание
if (!empty($book['description'])) {
$descriptionParagraphs = $this->htmlToParagraphs($book['description']);
foreach ($descriptionParagraphs as $paragraph) {
if (!empty(trim($paragraph))) {
$section->addText($paragraph);
}
}
$section->addTextBreak(2);
}
// Интерактивное оглавление
if (!empty($chapters)) {
$section->addText('Оглавление', ['bold' => true, 'size' => 14], ['alignment' => 'center']);
$section->addTextBreak(1);
foreach ($chapters as $index => $chapter) {
$chapter_number = $index + 1;
// Создаем гиперссылку на заголовок главы
$section->addLink("chapter_{$chapter['id']}", "{$chapter_number}. {$chapter['title']}", null, null, true);
$section->addTextBreak(1);
}
$section->addTextBreak(2);
}
// Разделитель
$section->addPageBreak();
// Главы с закладками
foreach ($chapters as $index => $chapter) {
// Добавляем закладку для главы
$section->addBookmark("chapter_{$chapter['id']}");
// Заголовок главы
$section->addText($chapter['title'], ['bold' => true, 'size' => 14]);
$section->addTextBreak(1);
// Получаем очищенный текст и разбиваем на абзацы
$cleanContent = strip_tags($chapter['content']);
$paragraphs = $this->htmlToParagraphs($chapter['content']);
// Добавляем каждый абзац
foreach ($paragraphs as $paragraph) {
if (!empty(trim($paragraph))) {
$section->addText($paragraph);
$section->addTextBreak(1);
}
}
// Добавляем разрыв страницы между главами (кроме последней)
if ($index < count($chapters) - 1) {
$section->addPageBreak();
}
}
// Футер
$section->addTextBreak(2);
$section->addText('Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), ['italic' => true, 'size' => 9]);
$section->addText('Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), ['italic' => true, 'size' => 9]);
// Сохраняем и отправляем
$filename = cleanFilename($book['title']) . '.docx';
header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document');
header('Content-Disposition: attachment; filename="' . $filename . '"');
$objWriter = IOFactory::createWriter($phpWord, 'Word2007');
$objWriter->save('php://output');
exit;
}
function exportHTML($book, $chapters, $is_public, $author_name) {
$html = '<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>' . htmlspecialchars($book['title']) . '</title>
<style>
body {
font-family: "Times New Roman", serif;
line-height: 1.6;
margin: 40px;
max-width: 900px;
margin-left: auto;
margin-right: auto;
color: #333;
}
.book-title {
text-align: center;
font-size: 24px;
font-weight: bold;
margin-bottom: 5px;
}
.book-author {
text-align: center;
font-size: 18px;
font-style: italic;
color: #666;
margin-bottom: 20px;
}
.book-cover {
text-align: center;
margin: 20px 0;
}
.book-cover img {
max-width: 200px;
height: auto;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.book-genre {
text-align: center;
font-style: italic;
color: #666;
margin-bottom: 20px;
}
.book-description {
background: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
border-left: 4px solid #007bff;
}
.table-of-contents {
background: #f8f9fa;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
columns: 1;
column-gap: 2rem;
}
.table-of-contents h3 {
margin-top: 0;
text-align: center;
column-span: all;
}
.table-of-contents ul {
list-style-type: none;
padding-left: 0;
}
.table-of-contents li {
margin-bottom: 5px;
padding: 5px 0;
break-inside: avoid;
}
.table-of-contents a {
text-decoration: none;
color: #333;
}
.table-of-contents a:hover {
color: #007bff;
}
.chapter-title {
border-bottom: 2px solid #007bff;
padding-bottom: 10px;
margin-top: 30px;
font-size: 20px;
scroll-margin-top: 2rem;
}
.chapter-content {
margin: 20px 0;
text-align: justify;
}
.footer {
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #ddd;
text-align: center;
font-size: 12px;
color: #666;
}
/* Отображение абзацев */
.chapter-content p {
margin-bottom: 1em;
text-align: justify;
}
.dialogue {
margin-left: 2rem;
font-style: italic;
color: #2c5aa0;
margin-bottom: 1em;
}
/* Остальные стили */
.chapter-content h1, .chapter-content h2, .chapter-content h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.chapter-content blockquote {
border-left: 4px solid #007bff;
padding-left: 15px;
margin-left: 0;
color: #555;
font-style: italic;
}
.chapter-content code {
background: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
}
.chapter-content pre {
background: #f5f5f5;
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
}
.chapter-content ul, .chapter-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.chapter-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.chapter-content th, .chapter-content td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
.chapter-content th {
background: #f5f5f5;
}
@media (max-width: 768px) {
.table-of-contents {
columns: 1;
}
}
</style>
</head>
<body>
<div class="book-title">' . htmlspecialchars($book['title']) . '</div>
<div class="book-author">' . htmlspecialchars($author_name) . '</div>';
if (!empty($book['genre'])) {
$html .= '<div class="book-genre">Жанр: ' . htmlspecialchars($book['genre']) . '</div>';
}
// Обложка книги
if (!empty($book['cover_image'])) {
$cover_url = COVERS_URL . $book['cover_image'];
$html .= '<div class="book-cover">';
$html .= '<img src="' . $cover_url . '" alt="' . htmlspecialchars($book['title']) . '">';
$html .= '</div>';
}
if (!empty($book['description'])) {
$html .= '<div class="book-description">';
$html .= $book['description'];
$html .= '</div>';
}
// Интерактивное оглавление
if (!empty($chapters)) {
$html .= '<div class="table-of-contents">';
$html .= '<h3>Оглавление</h3>';
$html .= '<ul>';
foreach ($chapters as $index => $chapter) {
$chapter_number = $index + 1;
$html .= '<li><a href="#chapter-' . $chapter['id'] . '">' . $chapter_number . '. ' . htmlspecialchars($chapter['title']) . '</a></li>';
}
$html .= '</ul>';
$html .= '</div>';
}
$html .= '<hr style="margin: 30px 0;">';
foreach ($chapters as $index => $chapter) {
$html .= '<div class="chapter">';
$html .= '<div class="chapter-title" id="chapter-' . $chapter['id'] . '" name="chapter-' . $chapter['id'] . '">' . htmlspecialchars($chapter['title']) . '</div>';
$html .= '<div class="chapter-content">' . $chapter['content']. '</div>';
$html .= '</div>';
if ($index < count($chapters) - 1) {
$html .= '<hr style="margin: 30px 0;">';
}
}
$html .= '<div class="footer">
Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i') . '<br>
Автор: ' . htmlspecialchars($author_name) . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')) . '
</div>
</body>
</html>';
$filename = cleanFilename($book['title']) . '.html';
header('Content-Type: text/html; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo $html;
exit;
}
function exportTXT($book, $chapters, $is_public, $author_name) {
$content = "=" . str_repeat("=", 80) . "=\n";
$content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n";
$content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n";
$content .= "=" . str_repeat("=", 80) . "=\n\n";
if (!empty($book['genre'])) {
$content .= "Жанр: " . $book['genre'] . "\n\n";
}
if (!empty($book['description'])) {
$content .= "ОПИСАНИЕ:\n";
// Обрабатываем описание
$descriptionText = strip_tags($book['description']);
$content .= wordwrap($descriptionText, 144) . "\n\n";
}
// Оглавление
if (!empty($chapters)) {
$content .= "ОГЛАВЛЕНИЕ:\n";
$content .= str_repeat("-", 60) . "\n";
foreach ($chapters as $index => $chapter) {
$chapter_number = $index + 1;
$content .= "{$chapter_number}. {$chapter['title']}\n";
}
$content .= "\n";
}
$content .= str_repeat("-", 144) . "\n\n";
foreach ($chapters as $index => $chapter) {
$content .= $chapter['title'] . "\n";
$content .= str_repeat("-", 60) . "\n\n";
// Получаем очищенный текст
$cleanContent = strip_tags($chapter['content']);
$paragraphs = $this->htmlToPlainTextParagraphs($cleanContent);
foreach ($paragraphs as $paragraph) {
if (!empty(trim($paragraph))) {
$content .= wordwrap($paragraph, 144) . "\n\n";
}
}
if ($index < count($chapters) - 1) {
$content .= str_repeat("-", 144) . "\n\n";
}
}
$content .= "\n" . str_repeat("=", 144) . "\n";
$content .= "Экспортировано из " . APP_NAME . " - " . date('d.m.Y H:i') . "\n";
$content .= "Автор: " . $author_name . " | Всего глав: " . count($chapters) . " | Всего слов: " . array_sum(array_column($chapters, 'word_count')) . "\n";
$content .= str_repeat("=", 144) . "\n";
$filename = cleanFilename($book['title']) . '.txt';
header('Content-Type: text/plain; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo $content;
exit;
}
// Функция для разбивки HTML на абзацы
function htmlToParagraphs($html) {
// Убираем HTML теги и нормализуем пробелы
$text = strip_tags($html);
$text = preg_replace('/\s+/', ' ', $text);
// Разбиваем на абзацы по точкам и переносам строк
$paragraphs = preg_split('/(?<=[.!?])\s+/', $text);
// Фильтруем пустые абзацы
$paragraphs = array_filter($paragraphs, function($paragraph) {
return !empty(trim($paragraph));
});
return $paragraphs;
}
function htmlToPlainTextParagraphs($html) {
// Убираем HTML теги
$text = strip_tags($html);
// Заменяем HTML entities
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Нормализуем переносы строк
$text = str_replace(["\r\n", "\r"], "\n", $text);
// Разбиваем на строки
$lines = explode("\n", $text);
$paragraphs = [];
$currentParagraph = '';
foreach ($lines as $line) {
$trimmedLine = trim($line);
// Пустая строка - конец абзаца
if (empty($trimmedLine)) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
}
continue;
}
// Добавляем к текущему абзацу
if (!empty($currentParagraph)) {
$currentParagraph .= ' ' . $trimmedLine;
} else {
$currentParagraph = $trimmedLine;
}
}
// Добавляем последний абзац
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
return $paragraphs;
}
}
?>
// ./controllers/BaseController.php
<?php
// controllers/BaseController.php
class BaseController {
protected $pdo;
public function __construct() {
global $pdo;
$this->pdo = $pdo;
}
protected function render($view, $data = []) {
extract($data);
include "views/$view.php";
}
protected function redirect($url) {
header("Location: " . SITE_URL . $url);
exit;
}
protected function requireLogin() {
if (!is_logged_in()) {
$this->redirect('/login');
}
}
protected function requireAdmin() {
if (!is_logged_in()) {
$this->redirect('/login');
return;
}
global $pdo;
$userModel = new User($pdo);
$user = $userModel->findById($_SESSION['user_id']);
if (!$user || $user['id'] != 1) { // Предполагаем, что администратор имеет ID = 1
$_SESSION['error'] = "У вас нет прав администратора";
$this->redirect('/dashboard');
exit;
}
}
protected function jsonResponse($data) {
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
}
?>
// ./controllers/SeriesController.php
<?php
// controllers/SeriesController.php
require_once 'controllers/BaseController.php';
require_once 'models/Series.php';
require_once 'models/Book.php';
class SeriesController extends BaseController {
public function index() {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($this->pdo);
$series = $seriesModel->findByUser($user_id);
// Получаем статистику для каждой серии отдельно
foreach ($series as &$ser) {
$stats = $seriesModel->getSeriesStats($ser['id'], $user_id);
$ser['book_count'] = $stats['book_count'] ?? 0;
$ser['total_words'] = $stats['total_words'] ?? 0;
}
unset($ser);
$this->render('series/index', [
'series' => $series,
'page_title' => "Мои серии книг"
]);
}
public function create() {
$this->requireLogin();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
if (empty($title)) {
$error = "Название серии обязательно";
} else {
$seriesModel = new Series($this->pdo);
$data = [
'title' => $title,
'description' => $description,
'user_id' => $_SESSION['user_id']
];
if ($seriesModel->create($data)) {
$_SESSION['success'] = "Серия успешно создана";
$new_series_id = $this->pdo->lastInsertId();
$this->redirect("/series/{$new_series_id}/edit");
} else {
$error = "Ошибка при создании серии";
}
}
}
}
$this->render('series/create', [
'error' => $error,
'page_title' => "Создание новой серии"
]);
}
public function edit($id) {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($this->pdo);
$series = $seriesModel->findById($id);
if (!$series || !$seriesModel->userOwnsSeries($id, $user_id)) {
$_SESSION['error'] = "Серия не найдена или у вас нет доступа";
$this->redirect('/series');
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
if (empty($title)) {
$error = "Название серии обязательно";
} else {
$data = [
'title' => $title,
'description' => $description,
'user_id' => $user_id
];
if ($seriesModel->update($id, $data)) {
$_SESSION['success'] = "Серия успешно обновлена";
$this->redirect('/series');
} else {
$error = "Ошибка при обновлении серии";
}
}
}
}
// Получаем книги в серии
$bookModel = new Book($this->pdo);
$books_in_series = $bookModel->findBySeries($id);
$available_books = $bookModel->getBooksNotInSeries($user_id, $id);
$this->render('series/edit', [
'series' => $series,
'books_in_series' => $books_in_series,
'available_books' => $available_books,
'error' => $error,
'page_title' => "Редактирование серии: " . e($series['title'])
]);
}
public function delete($id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
$this->redirect('/series');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect('/series');
}
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($this->pdo);
if (!$seriesModel->userOwnsSeries($id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой серии";
$this->redirect('/series');
}
if ($seriesModel->delete($id, $user_id)) {
$_SESSION['success'] = "Серия успешно удалена";
} else {
$_SESSION['error'] = "Ошибка при удалении серии";
}
$this->redirect('/series');
}
public function viewPublic($id) {
$seriesModel = new Series($this->pdo);
$series = $seriesModel->findById($id);
if (!$series) {
http_response_code(404);
$this->render('errors/404');
return;
}
// Получаем только опубликованные книги серии
$books = $seriesModel->getBooksInSeries($id, true);
// Получаем информацию об авторе
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
$stmt->execute([$series['user_id']]);
$author = $stmt->fetch(PDO::FETCH_ASSOC);
// Получаем статистику по опубликованным книгам
$bookModel = new Book($this->pdo);
$total_words = 0;
$total_chapters = 0;
foreach ($books as $book) {
$book_stats = $bookModel->getBookStats($book['id'], true);
$total_words += $book_stats['total_words'] ?? 0;
$total_chapters += $book_stats['chapter_count'] ?? 0;
}
$this->render('series/view_public', [
'series' => $series,
'books' => $books,
'author' => $author,
'total_words' => $total_words,
'total_chapters' => $total_chapters,
'page_title' => $series['title'] . ' — серия книг'
]);
}
public function addBook($series_id) {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($this->pdo);
$bookModel = new Book($this->pdo);
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой серии";
$this->redirect('/series');
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect("/series/{$series_id}/edit");
}
$book_id = (int)($_POST['book_id'] ?? 0);
$sort_order = (int)($_POST['sort_order'] ?? 0);
if (!$book_id) {
$_SESSION['error'] = "Выберите книгу";
$this->redirect("/series/{$series_id}/edit");
}
// Проверяем, что книга принадлежит пользователю
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
$this->redirect("/series/{$series_id}/edit");
}
// Добавляем книгу в серию
if ($bookModel->updateSeriesInfo($book_id, $series_id, $sort_order)) {
$_SESSION['success'] = "Книга добавлена в серию";
} else {
$_SESSION['error'] = "Ошибка при добавлении книги в серию";
}
$this->redirect("/series/{$series_id}/edit");
}
}
public function removeBook($series_id, $book_id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
$this->redirect("/series/{$series_id}/edit");
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect("/series/{$series_id}/edit");
}
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($this->pdo);
$bookModel = new Book($this->pdo);
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой серии";
$this->redirect('/series');
}
// Проверяем, что книга принадлежит пользователю
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
$this->redirect("/series/{$series_id}/edit");
}
// Удаляем книгу из серии
if ($bookModel->removeFromSeries($book_id)) {
$_SESSION['success'] = "Книга удалена из серии";
} else {
$_SESSION['error'] = "Ошибка при удалении книги из серии";
}
$this->redirect("/series/{$series_id}/edit");
}
public function updateBookOrder($series_id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
$this->redirect("/series/{$series_id}/edit");
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect("/series/{$series_id}/edit");
}
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($this->pdo);
$bookModel = new Book($this->pdo);
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой серии";
$this->redirect('/series');
}
$order_data = $_POST['order'] ?? [];
if (empty($order_data)) {
$_SESSION['error'] = "Нет данных для обновления";
$this->redirect("/series/{$series_id}/edit");
}
// Обновляем порядок книг
if ($bookModel->reorderSeriesBooks($series_id, $order_data)) {
$_SESSION['success'] = "Порядок книг обновлен";
} else {
$_SESSION['error'] = "Ошибка при обновлении порядка книг";
}
$this->redirect("/series/{$series_id}/edit");
}
}
?>
// ./controllers/UserController.php
<?php
// controllers/UserController.php
require_once 'controllers/BaseController.php';
require_once 'models/User.php';
require_once 'models/Book.php';
class UserController extends BaseController {
public function profile() {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$userModel = new User($this->pdo);
$user = $userModel->findById($user_id);
$message = '';
$avatar_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$message = "Ошибка безопасности";
} else {
$display_name = trim($_POST['display_name'] ?? '');
$email = trim($_POST['email'] ?? '');
$bio = trim($_POST['bio'] ?? '');
// Обработка загрузки аватарки
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === UPLOAD_ERR_OK) {
$avatar_result = handleAvatarUpload($_FILES['avatar'], $user_id);
if ($avatar_result['success']) {
$userModel->updateAvatar($user_id, $avatar_result['filename']);
// Обновляем данные пользователя
$user = $userModel->findById($user_id);
} else {
$avatar_error = $avatar_result['error'];
}
}
// Обработка удаления аватарки
if (isset($_POST['delete_avatar']) && $_POST['delete_avatar'] == '1') {
deleteUserAvatar($user_id);
$user = $userModel->findById($user_id);
}
// Обновляем основные данные
$data = [
'display_name' => $display_name,
'email' => $email,
'bio' => $bio
];
if ($userModel->updateProfile($user_id, $data)) {
$_SESSION['display_name'] = $display_name ?: $user['username'];
$message = "Профиль обновлен";
// Обновляем данные пользователя
$user = $userModel->findById($user_id);
} else {
$message = "Ошибка при обновлении профиля";
}
}
}
$this->render('user/profile', [
'user' => $user,
'message' => $message,
'avatar_error' => $avatar_error,
'page_title' => "Мой профиль"
]);
}
public function updateProfile() {
$this->requireLogin();
// Эта функция обрабатывает AJAX или прямые POST запросы для обновления профиля
// Можно объединить с методом profile() или оставить отдельно для API-like операций
$this->profile(); // Перенаправляем на основной метод
}
public function viewPublic($id) {
$userModel = new User($this->pdo);
$user = $userModel->findById($id);
if (!$user) {
http_response_code(404);
$this->render('errors/404');
return;
}
$bookModel = new Book($this->pdo);
$books = $bookModel->findByUser($id, true); // только опубликованные
// Получаем статистику автора
$total_books = count($books);
$total_words = 0;
$total_chapters = 0;
foreach ($books as $book) {
$book_stats = $bookModel->getBookStats($book['id'], true);
$total_words += $book_stats['total_words'] ?? 0;
$total_chapters += $book_stats['chapter_count'] ?? 0;
}
$this->render('user/view_public', [
'user' => $user,
'books' => $books,
'total_books' => $total_books,
'total_words' => $total_words,
'total_chapters' => $total_chapters,
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
]);
}
}
?>
// ./controllers/BookController.php
<?php
// controllers/BookController.php
require_once 'controllers/BaseController.php';
require_once 'models/Book.php';
require_once 'models/Chapter.php';
require_once 'models/Series.php';
class BookController extends BaseController {
public function index() {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
$books = $bookModel->findByUser($user_id);
$this->render('books/index', [
'books' => $books,
'page_title' => 'Мои книги'
]);
}
public function create() {
$this->requireLogin();
$seriesModel = new Series($this->pdo);
$series = $seriesModel->findByUser($_SESSION['user_id']);
$error = '';
$cover_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect('/books/create');
}
$title = trim($_POST['title'] ?? '');
if (empty($title)) {
$_SESSION['error'] = "Название книги обязательно";
$this->redirect('/books/create');
}
$bookModel = new Book($this->pdo);
$data = [
'title' => $title,
'description' => trim($_POST['description'] ?? ''),
'genre' => trim($_POST['genre'] ?? ''),
'user_id' => $_SESSION['user_id'],
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
'published' => isset($_POST['published']) ? 1 : 0
];
if ($bookModel->create($data)) {
$new_book_id = $this->pdo->lastInsertId();
// Обработка загрузки обложки
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
$cover_result = handleCoverUpload($_FILES['cover_image'], $new_book_id);
if ($cover_result['success']) {
$bookModel->updateCover($new_book_id, $cover_result['filename']);
} else {
$cover_error = $cover_result['error'];
// Сохраняем ошибку в сессии, чтобы показать после редиректа
$_SESSION['cover_error'] = $cover_error;
}
}
$_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : "");
$this->redirect("/books/{$new_book_id}/edit");
} else {
$_SESSION['error'] = "Ошибка при создании книги";
}
}
$this->render('books/create', [
'series' => $series,
'error' => $error,
'cover_error' => $cover_error,
'page_title' => 'Создание новой книги'
]);
}
public function edit($id) {
$this->requireLogin();
$bookModel = new Book($this->pdo);
$book = $bookModel->findById($id);
if (!$book || $book['user_id'] != $_SESSION['user_id']) {
$_SESSION['error'] = "Книга не найдена или у вас нет доступа";
$this->redirect('/books');
}
$seriesModel = new Series($this->pdo);
$series = $seriesModel->findByUser($_SESSION['user_id']);
$error = '';
$cover_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$title = trim($_POST['title'] ?? '');
if (empty($title)) {
$error = "Название книги обязательно";
} else {
$data = [
'title' => $title,
'description' => trim($_POST['description'] ?? ''),
'genre' => trim($_POST['genre'] ?? ''),
'user_id' => $_SESSION['user_id'],
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
'published' => isset($_POST['published']) ? 1 : 0
];
// Обработка обложки
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
$cover_result = handleCoverUpload($_FILES['cover_image'], $id);
if ($cover_result['success']) {
$bookModel->updateCover($id, $cover_result['filename']);
} else {
$cover_error = $cover_result['error'];
}
}
// Удаление обложки
if (isset($_POST['delete_cover']) && $_POST['delete_cover'] == '1') {
$bookModel->deleteCover($id);
}
// Обновление книги
$success = $bookModel->update($id, $data);
if ($success) {
$success_message = "Книга успешно обновлена";
$_SESSION['success'] = $success_message;
$this->redirect("/books/{$id}/edit");
} else {
$error = "Ошибка при обновлении книги";
}
}
}
}
// Получаем статистику по главам для отображения в шаблоне
$chapterModel = new Chapter($this->pdo);
$chapters = $chapterModel->findByBook($id);
$this->render('books/edit', [
'book' => $book,
'series' => $series,
'chapters' => $chapters,
'error' => $error,
'cover_error' => $cover_error,
'page_title' => 'Редактирование книги'
]);
}
public function delete($id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
$this->redirect('/books');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect('/books');
}
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
if (!$bookModel->userOwnsBook($id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
$this->redirect('/books');
}
if ($bookModel->delete($id, $user_id)) {
$_SESSION['success'] = "Книга успешно удалена";
} else {
$_SESSION['error'] = "Ошибка при удалении книги";
}
$this->redirect('/books');
}
public function deleteAll() {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect('/books');
}
$bookModel = new Book($this->pdo);
// Получаем все книги пользователя
$books = $bookModel->findByUser($user_id);
if (empty($books)) {
$_SESSION['info'] = "У вас нет книг для удаления";
$this->redirect('/books');
}
try {
$this->pdo->beginTransaction();
$deleted_count = 0;
$deleted_covers = 0;
foreach ($books as $book) {
// Удаляем обложку если она есть
if (!empty($book['cover_image'])) {
$cover_path = COVERS_PATH . $book['cover_image'];
if (file_exists($cover_path) && unlink($cover_path)) {
$deleted_covers++;
}
}
// Удаляем главы книги
$stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id = ?");
$stmt->execute([$book['id']]);
// Удаляем саму книгу
$stmt = $this->pdo->prepare("DELETE FROM books WHERE id = ? AND user_id = ?");
$stmt->execute([$book['id'], $user_id]);
$deleted_count++;
}
$this->pdo->commit();
$message = "Все книги успешно удалены ($deleted_count книг";
if ($deleted_covers > 0) {
$message .= ", удалено $deleted_covers обложек";
}
$message .= ")";
$_SESSION['success'] = $message;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Ошибка при массовом удалении: " . $e->getMessage());
$_SESSION['error'] = "Произошла ошибка при удалении книг: " . $e->getMessage();
}
$this->redirect('/books');
}
public function viewPublic($share_token) {
$bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo);
$book = $bookModel->findByShareToken($share_token);
if (!$book) {
http_response_code(404);
$this->render('errors/404');
return;
}
$chapters = $chapterModel->getPublishedChapters($book['id']);
// Получаем информацию об авторе
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
$stmt->execute([$book['user_id']]);
$author = $stmt->fetch(PDO::FETCH_ASSOC);
$this->render('books/view_public', [
'book' => $book,
'chapters' => $chapters,
'author' => $author,
'page_title' => $book['title']
]);
}
public function viewAll($id) {
$bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo);
$book = $bookModel->findById($id);
if (!$book) {
http_response_code(404);
$this->render('errors/404');
return;
}
$chapters = $chapterModel->findByBook($book['id']);
// Получаем информацию об авторе
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
$stmt->execute([$book['user_id']]);
$author = $stmt->fetch(PDO::FETCH_ASSOC);
$this->render('books/view_public', [
'book' => $book,
'chapters' => $chapters,
'author' => $author,
'page_title' => $book['title']
]);
}
public function regenerateToken($id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
$this->redirect("/books/{$id}/edit");
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
$this->redirect("/books/{$id}/edit");
}
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
if (!$bookModel->userOwnsBook($id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
$this->redirect('/books');
}
$new_token = $bookModel->generateNewShareToken($id);
if ($new_token) {
$_SESSION['success'] = "Ссылка успешно обновлена";
} else {
$_SESSION['error'] = "Ошибка при обновлении ссылки";
}
$this->redirect("/books/{$id}/edit");
}
}
?>
// ./controllers/AdminController.php
<?php
require_once 'controllers/BaseController.php';
require_once 'models/User.php';
class AdminController extends BaseController {
public function __construct() {
parent::__construct();
$this->requireAdmin();
}
public function users() {
$userModel = new User($this->pdo);
$users = $userModel->findAll();
$this->render('admin/users', [
'users' => $users,
'page_title' => 'Управление пользователями'
]);
}
public function toggleUserStatus($user_id) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Неверный метод запроса или токен безопасности";
$this->redirect('/admin/users');
return;
}
if ($user_id == $_SESSION['user_id']) {
$_SESSION['error'] = "Нельзя изменить статус собственного аккаунта";
$this->redirect('/admin/users');
return;
}
$userModel = new User($this->pdo);
$user = $userModel->findById($user_id);
if (!$user) {
$_SESSION['error'] = "Пользователь не найден";
$this->redirect('/admin/users');
return;
}
$newStatus = $user['is_active'] ? 0 : 1;
if ($userModel->updateStatus($user_id, $newStatus)) {
$_SESSION['success'] = "Статус пользователя обновлен";
} else {
$_SESSION['error'] = "Ошибка при обновлении статуса";
}
$this->redirect('/admin/users');
}
public function deleteUser($user_id) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Неверный метод запроса или токен безопасности";
$this->redirect('/admin/users');
return;
}
if ($user_id == $_SESSION['user_id']) {
$_SESSION['error'] = "Нельзя удалить собственный аккаунт";
$this->redirect('/admin/users');
return;
}
$userModel = new User($this->pdo);
$user = $userModel->findById($user_id);
if (!$user) {
$_SESSION['error'] = "Пользователь не найден";
$this->redirect('/admin/users');
return;
}
if ($userModel->delete($user_id)) {
$_SESSION['success'] = "Пользователь успешно удален";
} else {
$_SESSION['error'] = "Ошибка при удалении пользователя";
}
$this->redirect('/admin/users');
}
public function addUser() {
$error = '';
$success = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? '';
$password_confirm = $_POST['password_confirm'] ?? '';
$email = trim($_POST['email'] ?? '');
$display_name = trim($_POST['display_name'] ?? '');
$is_active = isset($_POST['is_active']) ? 1 : 0;
if (empty($username) || empty($password)) {
$error = 'Имя пользователя и пароль обязательны';
} elseif ($password !== $password_confirm) {
$error = 'Пароли не совпадают';
} elseif (strlen($password) < 6) {
$error = 'Пароль должен быть не менее 6 символов';
} else {
$userModel = new User($this->pdo);
if ($userModel->findByUsername($username)) {
$error = 'Имя пользователя уже занято';
} elseif (!empty($email) && $userModel->findByEmail($email)) {
$error = 'Email уже используется';
} else {
$data = [
'username' => $username,
'password' => $password,
'email' => $email ?: null,
'display_name' => $display_name ?: $username,
'is_active' => $is_active
];
if ($userModel->create($data)) {
$success = 'Пользователь успешно создан';
// Очищаем поля формы
$_POST = [];
} else {
$error = 'Ошибка при создании пользователя';
}
}
}
}
}
$this->render('admin/add_user', [
'error' => $error,
'success' => $success,
'page_title' => 'Добавление пользователя'
]);
}
}
?>
// ./composer.json
{
"require": {
"phpoffice/phpword": "^1.0",
"tecnickcom/tcpdf": "^6.6"
}
}
// ./models/Chapter.php
<?php
// models/Chapter.php
class Chapter {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
public function findById($id) {
$stmt = $this->pdo->prepare("
SELECT c.*, b.user_id, b.title as book_title
FROM chapters c
JOIN books b ON c.book_id = b.id
WHERE c.id = ?
");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByBook($book_id) {
$stmt = $this->pdo->prepare("
SELECT * FROM chapters
WHERE book_id = ?
ORDER BY sort_order, created_at
");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function create($data) {
$stmt = $this->pdo->prepare("SELECT MAX(sort_order) as max_order FROM chapters WHERE book_id = ?");
$stmt->execute([$data['book_id']]);
$result = $stmt->fetch();
$next_order = ($result['max_order'] ?? 0) + 1;
$word_count = $this->countWords($data['content']);
$stmt = $this->pdo->prepare("
INSERT INTO chapters (book_id, title, content, sort_order, word_count, status)
VALUES (?, ?, ?, ?, ?, ?)
");
return $stmt->execute([
$data['book_id'],
$data['title'],
$data['content'],
$next_order,
$word_count,
$data['status'] ?? 'draft'
]);
}
public function update($id, $data) {
$word_count = $this->countWords($data['content']);
$stmt = $this->pdo->prepare("
UPDATE chapters
SET title = ?, content = ?, word_count = ?, status = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
return $stmt->execute([
$data['title'],
$data['content'],
$word_count,
$data['status'] ?? 'draft',
$id
]);
}
public function delete($id) {
$stmt = $this->pdo->prepare("DELETE FROM chapters WHERE id = ?");
return $stmt->execute([$id]);
}
public function updateSortOrder($chapter_id, $new_order) {
$stmt = $this->pdo->prepare("UPDATE chapters SET sort_order = ? WHERE id = ?");
return $stmt->execute([$new_order, $chapter_id]);
}
private function countWords($text) {
$text = strip_tags($text);
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
$words = preg_split('/\s+/', $text);
$words = array_filter($words);
return count($words);
}
public function userOwnsChapter($chapter_id, $user_id) {
$stmt = $this->pdo->prepare("
SELECT c.id
FROM chapters c
JOIN books b ON c.book_id = b.id
WHERE c.id = ? AND b.user_id = ?
");
$stmt->execute([$chapter_id, $user_id]);
return $stmt->fetch() !== false;
}
public function getPublishedChapters($book_id) {
$stmt = $this->pdo->prepare("
SELECT * FROM chapters
WHERE book_id = ? AND status = 'published'
ORDER BY sort_order, created_at
");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
// private function getAllChapters($book_id) {
// $stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
// $stmt->execute([$book_id]);
// return $stmt->fetchAll(PDO::FETCH_ASSOC);
// }
// private function updateChapterContent($chapter_id, $content) {
// $word_count = $this->countWords($content);
// $stmt = $this->pdo->prepare("
// UPDATE chapters
// SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
// WHERE id = ?
// ");
// return $stmt->execute([$content, $word_count, $chapter_id]);
// }
}
?>
// ./models/Series.php
<?php
// models/Series.php
class Series {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
public function findById($id) {
$stmt = $this->pdo->prepare("
SELECT s.*,
COUNT(b.id) as book_count,
COALESCE((
SELECT SUM(c.word_count)
FROM chapters c
JOIN books b2 ON c.book_id = b2.id
WHERE b2.series_id = s.id AND b2.published = 1
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id AND b.published = 1
WHERE s.id = ?
GROUP BY s.id
");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByUser($user_id, $include_stats = true) {
if ($include_stats) {
$sql = "
SELECT s.*,
COUNT(b.id) as book_count,
COALESCE((
SELECT SUM(c.word_count)
FROM chapters c
JOIN books b2 ON c.book_id = b2.id
WHERE b2.series_id = s.id AND b2.user_id = ?
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
WHERE s.user_id = ?
GROUP BY s.id
ORDER BY s.created_at DESC
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id, $user_id]);
} else {
$sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id]);
}
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function create($data) {
$stmt = $this->pdo->prepare("
INSERT INTO series (title, description, user_id)
VALUES (?, ?, ?)
");
return $stmt->execute([
$data['title'],
$data['description'] ?? null,
$data['user_id']
]);
}
public function update($id, $data) {
$stmt = $this->pdo->prepare("
UPDATE series
SET title = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
");
return $stmt->execute([
$data['title'],
$data['description'] ?? null,
$id,
$data['user_id']
]);
}
public function delete($id, $user_id) {
try {
$this->pdo->beginTransaction();
$stmt = $this->pdo->prepare("UPDATE books SET series_id = NULL, sort_order_in_series = NULL WHERE series_id = ? AND user_id = ?");
$stmt->execute([$id, $user_id]);
$stmt = $this->pdo->prepare("DELETE FROM series WHERE id = ? AND user_id = ?");
$result = $stmt->execute([$id, $user_id]);
$this->pdo->commit();
return $result;
} catch (Exception $e) {
$this->pdo->rollBack();
return false;
}
}
public function userOwnsSeries($series_id, $user_id) {
$stmt = $this->pdo->prepare("SELECT id FROM series WHERE id = ? AND user_id = ?");
$stmt->execute([$series_id, $user_id]);
return $stmt->fetch() !== false;
}
public function getBooksInSeries($series_id, $only_published = false) {
$sql = "SELECT * FROM books WHERE series_id = ?";
if ($only_published) {
$sql .= " AND published = 1";
}
$sql .= " ORDER BY sort_order_in_series, created_at";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$series_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getNextSortOrder($series_id) {
$stmt = $this->pdo->prepare("SELECT MAX(sort_order_in_series) as max_order FROM books WHERE series_id = ?");
$stmt->execute([$series_id]);
$result = $stmt->fetch();
return ($result['max_order'] ?? 0) + 1;
}
public function getSeriesStats($series_id, $user_id = null) {
$sql = "
SELECT
COUNT(b.id) as book_count,
COALESCE(SUM(stats.chapter_count), 0) as chapter_count,
COALESCE(SUM(stats.total_words), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
LEFT JOIN (
SELECT
book_id,
COUNT(id) as chapter_count,
SUM(word_count) as total_words
FROM chapters
GROUP BY book_id
) stats ON b.id = stats.book_id
WHERE s.id = ?
";
$params = [$series_id];
if ($user_id) {
$sql .= " AND s.user_id = ?";
$params[] = $user_id;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}
?>
// ./models/index.php
// ./models/User.php
<?php
// models/User.php
class User {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
public function findById($id) {
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByUsername($username) {
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE username = ?");
$stmt->execute([$username]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByEmail($email) {
$stmt = $this->pdo->prepare("SELECT * FROM users WHERE email = ?");
$stmt->execute([$email]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findAll() {
$stmt = $this->pdo->prepare("SELECT id, username, display_name, email, created_at, last_login, is_active FROM users ORDER BY created_at DESC");
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function create($data) {
$password_hash = password_hash($data['password'], PASSWORD_DEFAULT);
$is_active = $data['is_active'] ?? 0;
$stmt = $this->pdo->prepare("
INSERT INTO users (username, display_name, email, password_hash, is_active)
VALUES (?, ?, ?, ?, ?)
");
return $stmt->execute([
$data['username'],
$data['display_name'] ?? $data['username'],
$data['email'] ?? null,
$password_hash,
$is_active
]);
}
public function update($id, $data) {
$sql = "UPDATE users SET display_name = ?, email = ?";
$params = [$data['display_name'], $data['email']];
if (!empty($data['password'])) {
$sql .= ", password_hash = ?";
$params[] = password_hash($data['password'], PASSWORD_DEFAULT);
}
$sql .= " WHERE id = ?";
$params[] = $id;
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($params);
}
public function delete($id) {
$stmt = $this->pdo->prepare("DELETE FROM users WHERE id = ?");
return $stmt->execute([$id]);
}
public function updateStatus($id, $is_active) {
$stmt = $this->pdo->prepare("UPDATE users SET is_active = ? WHERE id = ?");
return $stmt->execute([$is_active, $id]);
}
public function updateLastLogin($id) {
$stmt = $this->pdo->prepare("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?");
return $stmt->execute([$id]);
}
public function verifyPassword($password, $hash) {
return password_verify($password, $hash);
}
public function updateAvatar($id, $filename) {
$stmt = $this->pdo->prepare("UPDATE users SET avatar = ? WHERE id = ?");
return $stmt->execute([$filename, $id]);
}
public function updateBio($id, $bio) {
$stmt = $this->pdo->prepare("UPDATE users SET bio = ? WHERE id = ?");
return $stmt->execute([$bio, $id]);
}
public function updateProfile($id, $data) {
$sql = "UPDATE users SET display_name = ?, email = ?, bio = ?";
$params = [
$data['display_name'] ?? '',
$data['email'] ?? null,
$data['bio'] ?? null
];
if (!empty($data['avatar'])) {
$sql .= ", avatar = ?";
$params[] = $data['avatar'];
}
$sql .= " WHERE id = ?";
$params[] = $id;
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($params);
}
}
?>
// ./models/Book.php
<?php
// models/Book.php
class Book {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
public function findById($id) {
$stmt = $this->pdo->prepare("SELECT * FROM books WHERE id = ?");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByShareToken($share_token) {
$stmt = $this->pdo->prepare("SELECT * FROM books WHERE share_token = ?");
$stmt->execute([$share_token]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByUser($user_id, $only_published = false) {
$sql = "
SELECT b.*,
COUNT(c.id) as chapter_count,
COALESCE(SUM(c.word_count), 0) as total_words
FROM books b
LEFT JOIN chapters c ON b.id = c.book_id
WHERE b.user_id = ?
";
if ($only_published) {
$sql .= " AND b.published = 1 ";
}
$sql .= " GROUP BY b.id ORDER BY b.created_at DESC ";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function create($data) {
$share_token = bin2hex(random_bytes(16));
$published = isset($data['published']) ? (int)$data['published'] : 0;
$stmt = $this->pdo->prepare("
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
return $stmt->execute([
$data['title'],
$data['description'] ?? null,
$data['genre'] ?? null,
$data['user_id'],
$data['series_id'] ?? null,
$data['sort_order_in_series'] ?? null,
$share_token,
$published
]);
}
public function update($id, $data) {
$published = isset($data['published']) ? (int)$data['published'] : 0;
// Преобразуем пустые строки в 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 = ?
WHERE id = ? AND user_id = ?
");
return $stmt->execute([
$data['title'],
$data['description'] ?? null,
$data['genre'] ?? null,
$series_id, // Теперь это либо integer, либо NULL
$sort_order_in_series, // Теперь это либо integer, либо NULL
$published,
$id,
$data['user_id']
]);
}
public function delete($id, $user_id) {
try {
$this->pdo->beginTransaction();
// Удаляем главы книги
$stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id = ?");
$stmt->execute([$id]);
// Удаляем саму книгу
$stmt = $this->pdo->prepare("DELETE FROM books WHERE id = ? AND user_id = ?");
$result = $stmt->execute([$id, $user_id]);
$this->pdo->commit();
return $result;
} catch (Exception $e) {
$this->pdo->rollBack();
return false;
}
}
public function deleteAllByUser($user_id) {
try {
$this->pdo->beginTransaction();
// Получаем ID всех книг пользователя
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE user_id = ?");
$stmt->execute([$user_id]);
$book_ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($book_ids)) {
$this->pdo->commit();
return 0;
}
// Удаляем главы всех книг пользователя (одним запросом)
$placeholders = implode(',', array_fill(0, count($book_ids), '?'));
$stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id IN ($placeholders)");
$stmt->execute($book_ids);
// Удаляем все книги пользователя (одним запросом)
$stmt = $this->pdo->prepare("DELETE FROM books WHERE user_id = ?");
$stmt->execute([$user_id]);
$deleted_count = $stmt->rowCount();
$this->pdo->commit();
return $deleted_count;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function userOwnsBook($book_id, $user_id) {
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE id = ? AND user_id = ?");
$stmt->execute([$book_id, $user_id]);
return $stmt->fetch() !== false;
}
public function generateNewShareToken($book_id) {
$new_token = bin2hex(random_bytes(16));
$stmt = $this->pdo->prepare("UPDATE books SET share_token = ? WHERE id = ?");
$success = $stmt->execute([$new_token, $book_id]);
return $success ? $new_token : false;
}
public function updateCover($book_id, $filename) {
$stmt = $this->pdo->prepare("UPDATE books SET cover_image = ? WHERE id = ?");
return $stmt->execute([$filename, $book_id]);
}
public function deleteCover($book_id) {
$book = $this->findById($book_id);
$old_filename = $book['cover_image'];
if ($old_filename) {
$file_path = COVERS_PATH . $old_filename;
if (file_exists($file_path)) {
unlink($file_path);
}
}
$stmt = $this->pdo->prepare("UPDATE books SET cover_image = NULL WHERE id = ?");
return $stmt->execute([$book_id]);
}
public function updateSeriesInfo($book_id, $series_id, $sort_order) {
$stmt = $this->pdo->prepare("UPDATE books SET series_id = ?, sort_order_in_series = ? WHERE id = ?");
return $stmt->execute([$series_id, $sort_order, $book_id]);
}
public function removeFromSeries($book_id) {
$stmt = $this->pdo->prepare("UPDATE books SET series_id = NULL, sort_order_in_series = NULL WHERE id = ?");
return $stmt->execute([$book_id]);
}
public function findBySeries($series_id) {
$stmt = $this->pdo->prepare("
SELECT b.*
FROM books b
WHERE b.series_id = ?
ORDER BY b.sort_order_in_series, b.created_at
");
$stmt->execute([$series_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getBookStats($book_id, $only_published_chapters = false) {
$sql = "
SELECT
COUNT(c.id) as chapter_count,
COALESCE(SUM(c.word_count), 0) as total_words
FROM books b
LEFT JOIN chapters c ON b.id = c.book_id
WHERE b.id = ?
";
if ($only_published_chapters) {
$sql .= " AND c.status = 'published'";
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$book_id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function getBooksNotInSeries($user_id, $series_id = null) {
$sql = "SELECT * FROM books
WHERE user_id = ?
AND (series_id IS NULL OR series_id != ? OR series_id = 0)";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id, $series_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function reorderSeriesBooks($series_id, $new_order) {
try {
$this->pdo->beginTransaction();
foreach ($new_order as $order => $book_id) {
$stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
$stmt->execute([$order + 1, $book_id, $series_id]);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Ошибка при обновлении порядка книг: " . $e->getMessage());
return false;
}
}
private function countWords($text) {
$text = strip_tags($text);
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
$words = preg_split('/\s+/', $text);
$words = array_filter($words);
return count($words);
}
}
?>
// ./index.php
<?php
// index.php - единая точка входа
require_once 'config/config.php';
// Получаем путь к запрашиваемому ресурсу
$requestUri = $_SERVER['REQUEST_URI'];
$requestPath = parse_url($requestUri, PHP_URL_PATH);
// Убираем базовый URL (SITE_URL) из пути
$basePath = parse_url(SITE_URL, PHP_URL_PATH) ?? '';
if ($basePath && strpos($requestPath, $basePath) === 0) {
$requestPath = substr($requestPath, strlen($basePath));
}
// Убираем ведущий слеш
$requestPath = ltrim($requestPath, '/');
// Проверяем, существует ли запрашиваемый файл
$physicalPath = __DIR__ . '/' . $requestPath;
if (pathinfo($physicalPath, PATHINFO_EXTENSION) != 'php') {
if (file_exists($physicalPath) && !is_dir($physicalPath) && !str_contains($physicalPath, '..')) {
// Определяем MIME-тип
$mimeTypes = [
'css' => 'text/css',
'js' => 'application/javascript',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'webp' => 'image/webp',
'svg' => 'image/svg+xml',
'ico' => 'image/x-icon',
'json' => 'application/json',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
'eot' => 'application/vnd.ms-fontobject',
];
$extension = strtolower(pathinfo($physicalPath, PATHINFO_EXTENSION));
if (isset($mimeTypes[$extension])) {
header('Content-Type: ' . $mimeTypes[$extension]);
}
// Запрещаем кэширование для разработки, в продакшене можно увеличить время
header('Cache-Control: public, max-age=3600');
// Отправляем файл
readfile($physicalPath);
exit;
}
}
// Простой роутер
class Router {
private $routes = [];
public function add($pattern, $handler) {
$this->routes[$pattern] = $handler;
}
public function handle($uri) {
// Убираем базовый URL если есть
$basePath = parse_url(SITE_URL, PHP_URL_PATH) ?? '';
$uri = str_replace($basePath, '', $uri);
$uri = parse_url($uri, PHP_URL_PATH) ?? '/';
foreach ($this->routes as $pattern => $handler) {
if ($this->match($pattern, $uri)) {
return $this->callHandler($handler, $this->params);
}
}
// 404
http_response_code(404);
include 'views/errors/404.php';
exit;
}
private function match($pattern, $uri) {
$pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern);
$pattern = "#^$pattern$#";
if (preg_match($pattern, $uri, $matches)) {
$this->params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY);
return true;
}
return false;
}
private function callHandler($handler, $params) {
if (is_callable($handler)) {
return call_user_func_array($handler, array_values($params));
}
if (is_string($handler)) {
list($controller, $method) = explode('@', $handler);
$controllerFile = "controllers/{$controller}.php";
if (file_exists($controllerFile)) {
require_once $controllerFile;
$controllerInstance = new $controller();
if (method_exists($controllerInstance, $method)) {
return call_user_func_array([$controllerInstance, $method], array_values($params));
}
}
}
throw new Exception("Handler not found");
}
}
// Инициализация роутера
$router = new Router();
// Маршруты
$router->add('/', 'DashboardController@index');
$router->add('/dashboard', 'DashboardController@index');
$router->add('/index.php', 'DashboardController@index');
$router->add('/login', 'AuthController@login');
$router->add('/logout', 'AuthController@logout');
$router->add('/register', 'AuthController@register');
// Книги
$router->add('/books', 'BookController@index');
$router->add('/book/all/{id}', 'BookController@viewAll');
$router->add('/books/create', 'BookController@create');
$router->add('/books/{id}/edit', 'BookController@edit');
$router->add('/books/{id}/delete', 'BookController@delete');
$router->add('/books/delete-all', 'BookController@deleteAll');
$router->add('/books/{id}/normalize', 'BookController@normalizeContent');
$router->add('/books/{id}/regenerate-token', 'BookController@regenerateToken');
// Главы
$router->add('/books/{book_id}/chapters', 'ChapterController@index');
$router->add('/books/{book_id}/chapters/create', 'ChapterController@create');
$router->add('/chapters/{id}/edit', 'ChapterController@edit');
$router->add('/chapters/{id}/delete', 'ChapterController@delete');
$router->add('/chapters/preview', 'ChapterController@preview');
// Серии
$router->add('/series', 'SeriesController@index');
$router->add('/series/create', 'SeriesController@create');
$router->add('/series/{id}/edit', 'SeriesController@edit');
$router->add('/series/{id}/delete', 'SeriesController@delete');
$router->add('/series/{id}/add-book', 'SeriesController@addBook');
$router->add('/series/{id}/remove-book/{book_id}', 'SeriesController@removeBook');
$router->add('/series/{id}/update-order', 'SeriesController@updateBookOrder');
// Профиль
$router->add('/profile', 'UserController@profile');
$router->add('/profile/update', 'UserController@updateProfile');
// Экспорт с параметром формата
//публичный экспорт
$router->add('/export/shared/{share_token}/{format}', 'ExportController@exportShared');
$router->add('/export/shared/{share_token}', 'ExportController@exportShared'); // по умолчанию pdf
//авторсикй экспорт
$router->add('/export/{book_id}/{format}', 'ExportController@export');
$router->add('/export/{book_id}', 'ExportController@export'); // по умолчанию pdf
// Публичные страницы
$router->add('/book/{share_token}', 'BookController@viewPublic');
$router->add('/author/{id}', 'UserController@viewPublic');
$router->add('/series/{id}/view', 'SeriesController@viewPublic');
// Администрирование
$router->add('/admin/users', 'AdminController@users');
$router->add('/admin/add-user', 'AdminController@addUser');
$router->add('/admin/user/{user_id}/toggle-status', 'AdminController@toggleUserStatus');
$router->add('/admin/user/{user_id}/delete', 'AdminController@deleteUser');
// Обработка запроса
$requestUri = $_SERVER['REQUEST_URI'];
$router->handle($requestUri);
// Редирект с корня на dashboard для авторизованных
$router->add('/', function() {
if (is_logged_in()) {
header("Location: " . SITE_URL . "/dashboard");
} else {
header("Location: " . SITE_URL . "/login");
}
exit;
});
?>
// ./includes/functions.php
<?php
// includes/functions.php
function is_logged_in() {
return isset($_SESSION['user_id']);
}
function require_login() {
if (!is_logged_in()) {
header('Location: login.php');
exit;
}
}
function generate_csrf_token() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token($token) {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
function e($string) {
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}
function redirect($url) {
header("Location: " . $url);
exit;
}
function transliterate($text) {
$cyr = [
'а','б','в','г','д','е','ё','ж','з','и','й','к','л','м','н','о','п',
'р','с','т','у','ф','х','ц','ч','ш','щ','ъ','ы','ь','э','ю','я',
'А','Б','В','Г','Д','Е','Ё','Ж','З','И','Й','К','Л','М','Н','О','П',
'Р','С','Т','У','Ф','Х','Ц','Ч','Ш','Щ','Ъ','Ы','Ь','Э','Ю','Я',
' ',',','!','?',':',';','"','\'','(',')','[',']','{','}'
];
$lat = [
'a','b','v','g','d','e','yo','zh','z','i','y','k','l','m','n','o','p',
'r','s','t','u','f','h','ts','ch','sh','sht','a','i','y','e','yu','ya',
'A','B','V','G','D','E','Yo','Zh','Z','I','Y','K','L','M','N','O','P',
'R','S','T','U','F','H','Ts','Ch','Sh','Sht','A','I','Y','E','Yu','Ya',
'_','_','_','_','_','_','_','_','_','_','_','_','_','_'
];
return str_replace($cyr, $lat, $text);
}
// Функция для очистки имени файла
function cleanFilename($filename) {
// Сначала транслитерируем
$filename = transliterate($filename);
// Затем убираем оставшиеся недопустимые символы
$filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename);
// Убираем множественные подчеркивания
$filename = preg_replace('/_{2,}/', '_', $filename);
// Убираем подчеркивания с начала и конца
$filename = trim($filename, '_');
// Если после очистки имя файла пустое, используем стандартное
if (empty($filename)) {
$filename = 'book';
}
// Ограничиваем длину имени файла
if (strlen($filename) > 100) {
$filename = substr($filename, 0, 100);
}
return $filename;
}
function handleCoverUpload($file, $book_id) {
global $pdo;
// Проверяем папку для загрузок
if (!file_exists(COVERS_PATH)) {
mkdir(COVERS_PATH, 0755, true);
}
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
$max_size = 5 * 1024 * 1024; // 5MB
// Проверка типа файла
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types)) {
return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения'];
}
// Проверка размера
if ($file['size'] > $max_size) {
return ['success' => false, 'error' => 'Размер изображения не должен превышать 5MB'];
}
// Проверка на ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']];
}
// Генерация уникального имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'cover_' . $book_id . '_' . time() . '.' . $extension;
$file_path = COVERS_PATH . $filename;
// Удаляем старую обложку если есть
$bookModel = new Book($pdo);
$bookModel->deleteCover($book_id);
// Сохраняем новую обложку
if (move_uploaded_file($file['tmp_name'], $file_path)) {
// Оптимизируем изображение
optimizeImage($file_path);
return ['success' => true, 'filename' => $filename];
} else {
return ['success' => false, 'error' => 'Не удалось сохранить файл'];
}
}
function optimizeImage($file_path) {
list($width, $height, $type) = getimagesize($file_path);
$max_width = 800;
$max_height = 1200;
if ($width > $max_width || $height > $max_height) {
// Вычисляем новые размеры
$ratio = $width / $height;
if ($max_width / $max_height > $ratio) {
$new_width = $max_height * $ratio;
$new_height = $max_height;
} else {
$new_width = $max_width;
$new_height = $max_width / $ratio;
}
// Создаем новое изображение
$new_image = imagecreatetruecolor($new_width, $new_height);
// Загружаем исходное изображение в зависимости от типа
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($file_path);
// Сохраняем прозрачность для PNG
imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127));
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($file_path);
break;
default:
return; // Не поддерживаемый тип
}
// Ресайз и сохраняем
imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($new_image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($new_image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($new_image, $file_path);
break;
}
// Освобождаем память
imagedestroy($source);
imagedestroy($new_image);
}
}
function handleAvatarUpload($file, $user_id) {
global $pdo;
// Проверяем папку для загрузок
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
$max_size = 2 * 1024 * 1024; // 2MB
// Проверка типа файла
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types)) {
return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения'];
}
// Проверка размера
if ($file['size'] > $max_size) {
return ['success' => false, 'error' => 'Размер изображения не должен превышать 2MB'];
}
// Проверка на ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']];
}
// Проверка реального типа файла по содержимому
$allowed_signatures = [
'image/jpeg' => "\xFF\xD8\xFF",
'image/png' => "\x89\x50\x4E\x47",
'image/gif' => "GIF",
'image/webp' => "RIFF"
];
$file_content = file_get_contents($file['tmp_name']);
$signature = substr($file_content, 0, 4);
$valid_signature = false;
foreach ($allowed_signatures as $type => $sig) {
if (strpos($signature, $sig) === 0) {
$valid_signature = true;
break;
}
}
if (!$valid_signature) {
return ['success' => false, 'error' => 'Неверный формат изображения'];
}
// Генерация уникального имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'avatar_' . $user_id . '_' . time() . '.' . $extension;
$file_path = AVATARS_PATH . $filename;
// Удаляем старый аватар если есть
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$old_file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($old_file_path)) {
unlink($old_file_path);
}
}
// Сохраняем новую аватарку
if (move_uploaded_file($file['tmp_name'], $file_path)) {
// Оптимизируем изображение
optimizeAvatar($file_path);
return ['success' => true, 'filename' => $filename];
} else {
return ['success' => false, 'error' => 'Не удалось сохранить файл'];
}
}
function optimizeAvatar($file_path) {
// Оптимизация аватарки - ресайз до 200x200
list($width, $height, $type) = getimagesize($file_path);
$max_size = 200;
if ($width > $max_size || $height > $max_size) {
// Вычисляем новые размеры
$ratio = $width / $height;
if ($ratio > 1) {
$new_width = $max_size;
$new_height = $max_size / $ratio;
} else {
$new_width = $max_size * $ratio;
$new_height = $max_size;
}
// Создаем новое изображение
$new_image = imagecreatetruecolor($new_width, $new_height);
// Загружаем исходное изображение в зависимости от типа
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($file_path);
// Сохраняем прозрачность для PNG
imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127));
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($file_path);
break;
case IMAGETYPE_WEBP:
$source = imagecreatefromwebp($file_path);
break;
default:
return; // Не поддерживаемый тип
}
// Ресайз и сохраняем
imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($new_image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($new_image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($new_image, $file_path);
break;
case IMAGETYPE_WEBP:
imagewebp($new_image, $file_path, 85);
break;
}
// Освобождаем память
imagedestroy($source);
imagedestroy($new_image);
}
}
function deleteUserAvatar($user_id) {
global $pdo;
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($file_path)) {
unlink($file_path);
}
// Обновляем запись в БД
$stmt = $pdo->prepare("UPDATE users SET avatar = NULL WHERE id = ?");
return $stmt->execute([$user_id]);
}
return true;
}
?>
// ./includes/index.php
// ./composer.lock
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "493a3be12648bbe702ed126df05ead04",
"packages": [
{
"name": "cybermonde/odtphp",
"version": "v1.7",
"source": {
"type": "git",
"url": "https://github.com/cybermonde/odtphp.git",
"reference": "23aba70923ca3c07af15a5600f4072751c1b4a36"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/cybermonde/odtphp/zipball/23aba70923ca3c07af15a5600f4072751c1b4a36",
"reference": "23aba70923ca3c07af15a5600f4072751c1b4a36",
"shasum": ""
},
"require": {
"php": ">=5.2.4"
},
"type": "library",
"autoload": {
"classmap": [
"library"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"GPL"
],
"description": "ODT document generator",
"homepage": "https://github.com/cybermonde/odtphp",
"keywords": [
"odt",
"php"
],
"support": {
"issues": "https://github.com/cybermonde/odtphp/issues",
"source": "https://github.com/cybermonde/odtphp/tree/v1.7"
},
"time": "2015-06-02T07:28:25+00:00"
},
{
"name": "phpoffice/math",
"version": "0.3.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/Math.git",
"reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a",
"reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-xml": "*",
"php": "^7.1|^8.0"
},
"require-dev": {
"phpstan/phpstan": "^0.12.88 || ^1.0.0",
"phpunit/phpunit": "^7.0 || ^9.0"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\Math\\": "src/Math/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Progi1984",
"homepage": "https://lefevre.dev"
}
],
"description": "Math - Manipulate Math Formula",
"homepage": "https://phpoffice.github.io/Math/",
"keywords": [
"MathML",
"officemathml",
"php"
],
"support": {
"issues": "https://github.com/PHPOffice/Math/issues",
"source": "https://github.com/PHPOffice/Math/tree/0.3.0"
},
"time": "2025-05-29T08:31:49+00:00"
},
{
"name": "phpoffice/phpword",
"version": "1.4.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PHPWord.git",
"reference": "6d75328229bc93790b37e93741adf70646cea958"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958",
"reference": "6d75328229bc93790b37e93741adf70646cea958",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-gd": "*",
"ext-json": "*",
"ext-xml": "*",
"ext-zip": "*",
"php": "^7.1|^8.0",
"phpoffice/math": "^0.3"
},
"require-dev": {
"dompdf/dompdf": "^2.0 || ^3.0",
"ext-libxml": "*",
"friendsofphp/php-cs-fixer": "^3.3",
"mpdf/mpdf": "^7.0 || ^8.0",
"phpmd/phpmd": "^2.13",
"phpstan/phpstan": "^0.12.88 || ^1.0.0",
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
"phpunit/phpunit": ">=7.0",
"symfony/process": "^4.4 || ^5.0",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Allows writing PDF",
"ext-xmlwriter": "Allows writing OOXML and ODF",
"ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpWord\\": "src/PhpWord"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-only"
],
"authors": [
{
"name": "Mark Baker"
},
{
"name": "Gabriel Bull",
"email": "me@gabrielbull.com",
"homepage": "http://gabrielbull.com/"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net/blog/"
},
{
"name": "Ivan Lanin",
"homepage": "http://ivan.lanin.org"
},
{
"name": "Roman Syroeshko",
"homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/"
},
{
"name": "Antoine de Troostembergh"
}
],
"description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)",
"homepage": "https://phpoffice.github.io/PHPWord/",
"keywords": [
"ISO IEC 29500",
"OOXML",
"Office Open XML",
"OpenDocument",
"OpenXML",
"PhpOffice",
"PhpWord",
"Rich Text Format",
"WordprocessingML",
"doc",
"docx",
"html",
"odf",
"odt",
"office",
"pdf",
"php",
"reader",
"rtf",
"template",
"template processor",
"word",
"writer"
],
"support": {
"issues": "https://github.com/PHPOffice/PHPWord/issues",
"source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0"
},
"time": "2025-06-05T10:32:36+00:00"
},
{
"name": "tecnickcom/tcpdf",
"version": "6.10.0",
"source": {
"type": "git",
"url": "https://github.com/tecnickcom/TCPDF.git",
"reference": "ca5b6de294512145db96bcbc94e61696599c391d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/ca5b6de294512145db96bcbc94e61696599c391d",
"reference": "ca5b6de294512145db96bcbc94e61696599c391d",
"shasum": ""
},
"require": {
"ext-curl": "*",
"php": ">=7.1.0"
},
"type": "library",
"autoload": {
"classmap": [
"config",
"include",
"tcpdf.php",
"tcpdf_barcodes_1d.php",
"tcpdf_barcodes_2d.php",
"include/tcpdf_colors.php",
"include/tcpdf_filters.php",
"include/tcpdf_font_data.php",
"include/tcpdf_fonts.php",
"include/tcpdf_images.php",
"include/tcpdf_static.php",
"include/barcodes/datamatrix.php",
"include/barcodes/pdf417.php",
"include/barcodes/qrcode.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Nicola Asuni",
"email": "info@tecnick.com",
"role": "lead"
}
],
"description": "TCPDF is a PHP class for generating PDF documents and barcodes.",
"homepage": "http://www.tcpdf.org/",
"keywords": [
"PDFD32000-2008",
"TCPDF",
"barcodes",
"datamatrix",
"pdf",
"pdf417",
"qrcode"
],
"support": {
"issues": "https://github.com/tecnickcom/TCPDF/issues",
"source": "https://github.com/tecnickcom/TCPDF/tree/6.10.0"
},
"funding": [
{
"url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ",
"type": "custom"
}
],
"time": "2025-05-27T18:02:28+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": {},
"prefer-stable": false,
"prefer-lowest": false,
"platform": {},
"platform-dev": {},
"plugin-api-version": "2.9.0"
}
// ./README.md
# Web Writer
**Лицензия:** AGPLv3
**Web Writer** — веб-приложение для написания, хранения и публикации книг и серий книг. Поддерживает Markdown с расширением для диалогов, автосохранение текста, экспорт и управление пользователями.
---
## 🚀 Возможности
- **Книги и серии:** создавайте серии и добавляйте книги с главами.
- **Редактор книг:** Markdown, автосохранение текста, интерактивное содержание.
- **Предпросмотр книг:**
- **Автор:** видит все черновики и опубликованные главы.
- **Публичный доступ:** только опубликованные главы по ссылке с `shared_token`.
- **Обложки и аватары:** добавляйте изображения к книгам и профилям.
- **Экспорт:** PDF, DOCX, HTML, TXT.
- **Администрирование пользователей:**
- Управление аккаунтами, активация/деактивация.
- При удалении пользователя удаляются все его книги.
- **Публичные ссылки:** делитесь `shared_token` для просмотра опубликованных глав.
---
## ⚙️ Требования
- **PHP:** 8.0 и выше
- **MySQL** с InnoDB и внешними ключами
- **PHP расширения:** `mbstring`, `json`, `PDO`
- Веб-сервер с правами на запись в папки `config/` и `uploads/`
> Все библиотеки уже включены в `vendor/`. Composer не нужен.
---
## 🛠 Установка
1. Скопируйте файлы на веб-сервер.
2. Проверьте доступность папок `config/` и `uploads/` для записи.
3. Перейдите в браузере на `install.php` и следуйте шагам:
**Шаг 1: Настройки базы данных**
- Хост БД
- Имя базы данных
- Пользователь и пароль
**Шаг 2: Создание администратора**
- Имя пользователя
- Пароль
- Email (по желанию)
- Отображаемое имя (по желанию)
4. После успешной установки файл `config/config.php` будет сгенерирован автоматически.
5. Перейдите на главную страницу приложения (`index.php`) и войдите под админом.
6. **Не забудьте удалить или переместить файл install.php!!!**
---
## 📝 Конфигурация
Файл `config/config.php` содержит:
- Подключение к базе данных: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`
- Пути к файлам:
- `UPLOAD_PATH` — корневая папка загрузок
- `COVERS_PATH` / `COVERS_URL` — обложки книг
- `AVATARS_PATH` / `AVATARS_URL` — аватары пользователей
- Адрес сайта: `SITE_URL`
- Имя приложения: `APP_NAME` = "Web Writer"
---
## 🛠 Дальнейшее развитие
- Планирую вынести работу с сущностями (книги, главы, серии, пользователи) в контроллеры.
- Создать единую точку входа для приложения.
---
## ❗ Поддержка
Все ошибки и предложения шлите в issue
---
## 📜 Лицензия
Приложение распространяется под лицензией [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html).
// ./config/index.php
// ./config/config.php
<?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;
}
});
?>
// ./install.php
<?php
// install.php - установщик приложения
// Проверяем, не установлено ли приложение уже
if (file_exists('config/config.php')) {
die('Приложение уже установлено. Для переустановки удалите файл config/config.php');
}
// SQL для создания таблиц
$database_sql = <<<SQL
SET FOREIGN_KEY_CHECKS=0;
-- Удаляем существующие таблицы в правильном порядке (сначала дочерние, потом родительские)
DROP TABLE IF EXISTS `user_sessions`;
DROP TABLE IF EXISTS `chapters`;
DROP TABLE IF EXISTS `books`;
DROP TABLE IF EXISTS `series`;
DROP TABLE IF EXISTS `users`;
-- Таблица пользователей
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`display_name` varchar(255) DEFAULT NULL,
`password_hash` varchar(255) NOT NULL,
`email` varchar(255) DEFAULT NULL,
`avatar` varchar(500) DEFAULT NULL,
`bio` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`last_login` timestamp NULL DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица серий
CREATE TABLE `series` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`user_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `idx_series_user_id` (`user_id`),
CONSTRAINT `series_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица книг
CREATE TABLE `books` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`genre` varchar(100) DEFAULT NULL,
`cover_image` varchar(500) DEFAULT NULL,
`user_id` int(11) NOT NULL,
`series_id` int(11) DEFAULT NULL,
`sort_order_in_series` int(11) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`share_token` varchar(32) DEFAULT NULL,
`published` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `share_token` (`share_token`),
KEY `user_id` (`user_id`),
KEY `series_id` (`series_id`),
KEY `idx_sort_order_in_series` (`sort_order_in_series`),
KEY `idx_books_series_id` (`series_id`),
KEY `idx_books_sort_order` (`sort_order_in_series`),
CONSTRAINT `books_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `books_ibfk_2` FOREIGN KEY (`series_id`) REFERENCES `series` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица глав
CREATE TABLE `chapters` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`book_id` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`content` text NOT NULL,
`word_count` int(11) DEFAULT 0,
`sort_order` int(11) NOT NULL DEFAULT 0,
`status` enum('draft','published') DEFAULT 'draft',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `book_id` (`book_id`),
CONSTRAINT `chapters_ibfk_1` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица сессий пользователей
CREATE TABLE `user_sessions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`session_token` varchar(64) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
CONSTRAINT `user_sessions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
SET FOREIGN_KEY_CHECKS=1;
SQL;
$step = $_GET['step'] ?? '1';
$error = '';
$success = '';
// Обработка формы
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($step === '1') {
// Шаг 1: Проверка подключения к БД
$db_host = $_POST['db_host'] ?? 'localhost';
$db_name = $_POST['db_name'] ?? 'writer_app';
$db_user = $_POST['db_user'] ?? '';
$db_pass = $_POST['db_pass'] ?? '';
try {
// Пытаемся подключиться к MySQL
$pdo = new PDO("mysql:host=$db_host", $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Пытаемся создать базу данных если не существует
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$pdo->exec("USE `$db_name`");
// Сохраняем данные в сессии для следующего шага
session_start();
$_SESSION['install_data'] = [
'db_host' => $db_host,
'db_name' => $db_name,
'db_user' => $db_user,
'db_pass' => $db_pass
];
header('Location: install.php?step=2');
exit;
} catch (PDOException $e) {
$error = "Ошибка подключения к базе данных: " . $e->getMessage();
}
} elseif ($step === '2') {
// Шаг 2: Создание администратора
session_start();
if (!isset($_SESSION['install_data'])) {
header('Location: install.php?step=1');
exit;
}
$admin_username = $_POST['admin_username'] ?? '';
$admin_password = $_POST['admin_password'] ?? '';
$admin_email = $_POST['admin_email'] ?? '';
$admin_display_name = $_POST['admin_display_name'] ?? $admin_username;
if (empty($admin_username) || empty($admin_password)) {
$error = 'Имя пользователя и пароль администратора обязательны';
} else {
try {
$db = $_SESSION['install_data'];
$pdo = new PDO("mysql:host={$db['db_host']};dbname={$db['db_name']}", $db['db_user'], $db['db_pass']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Создаем таблицы
$pdo->exec($database_sql);
// Создаем администратора
$password_hash = password_hash($admin_password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("
INSERT INTO users (username, display_name, password_hash, email, is_active, created_at)
VALUES (?, ?, ?, ?, 1, NOW())
");
$stmt->execute([$admin_username, $admin_display_name, $password_hash, $admin_email]);
// Создаем config.php
$config_content = generate_config($db);
if (file_put_contents('config/config.php', $config_content)) {
// Создаем папки для загрузок
if (!file_exists('uploads/covers')) {
mkdir('uploads/covers', 0755, true);
}
if (!file_exists('uploads/avatars')) {
mkdir('uploads/avatars', 0755, true);
}
$success = 'Установка завершена успешно!';
session_destroy();
} else {
$error = 'Не удалось создать файл config.php. Проверьте права доступа к папке config/';
}
} catch (PDOException $e) {
$error = "Ошибка при установке: " . $e->getMessage();
}
}
}
}
function generate_config($db) {
$site_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]";
$base_path = str_replace('/install.php', '', $_SERVER['PHP_SELF']);
$site_url .= $base_path;
return <<<EOT
<?php
// config/config.php - автоматически сгенерирован установщиком
// Подключаем функции
require_once __DIR__ . '/../includes/functions.php';
session_start();
// Настройки базы данных
define('DB_HOST', '{$db['db_host']}');
define('DB_USER', '{$db['db_user']}');
define('DB_PASS', '{$db['db_pass']}');
define('DB_NAME', '{$db['db_name']}');
define('SITE_URL', '{$site_url}');
// Настройки приложения
define('APP_NAME', 'Web Writer');
define('CONTROLLERS_PATH', __DIR__ . '/../controllers/');
define('VIEWS_PATH', __DIR__ . '/../views/');
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
define('COVERS_URL', SITE_URL . '/uploads/covers/');
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("Ошибка подключения к базе данных");
}
// Автозагрузка моделей
spl_autoload_register(function (\$class_name) {
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
if (file_exists(\$model_file)) {
require_once \$model_file;
}
});
?>
EOT;
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Установка Web Writer</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.10/css/pico.min.css">
<style>
.installation-steps {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.step {
padding: 10px 20px;
margin: 0 10px;
border-radius: 20px;
background: #f0f0f0;
color: #666;
}
.step.active {
background: #007bff;
color: white;
}
.install-container {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
}
.alert {
padding: 1rem;
margin: 1rem 0;
border-radius: 5px;
}
.alert-error {
background: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.alert-success {
background: #e8f5e8;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 1rem;
}
.button-group a,
.button-group button {
flex: 1;
text-align: center;
padding: 0.75rem;
text-decoration: none;
border: 1px solid;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
box-sizing: border-box;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.button-group a {
background: var(--secondary);
border-color: var(--secondary);
color: var(--secondary-inverse);
}
.button-group button {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-inverse);
}
.button-group a:hover,
.button-group button:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<main class="container">
<div class="install-container">
<h1 style="text-align: center;">Установка Web Writer</h1>
<!-- Шаги установки -->
<div class="installation-steps">
<div class="step <?= $step === '1' ? 'active' : '' ?>">1. База данных</div>
<div class="step <?= $step === '2' ? 'active' : '' ?>">2. Администратор</div>
<div class="step <?= $step === '3' ? 'active' : '' ?>">3. Завершение</div>
</div>
<?php if ($error): ?>
<div class="alert alert-error">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success">
<?= htmlspecialchars($success) ?>
<div style="margin-top: 1rem;">
<a href="index.php" role="button" class="contrast">Перейти к приложению</a>
</div>
</div>
<?php endif; ?>
<?php if (!$success): ?>
<?php if ($step === '1'): ?>
<!-- Шаг 1: Настройки базы данных -->
<form method="post">
<h3>Настройки базы данных</h3>
<label for="db_host">
Хост БД
<input type="text" id="db_host" name="db_host" value="<?= htmlspecialchars($_POST['db_host'] ?? 'localhost') ?>" required>
</label>
<label for="db_name">
Имя базы данных
<input type="text" id="db_name" name="db_name" value="<?= htmlspecialchars($_POST['db_name'] ?? 'writer_app') ?>" required>
</label>
<label for="db_user">
Пользователь БД
<input type="text" id="db_user" name="db_user" value="<?= htmlspecialchars($_POST['db_user'] ?? '') ?>" required>
</label>
<label for="db_pass">
Пароль БД
<input type="password" id="db_pass" name="db_pass" value="<?= htmlspecialchars($_POST['db_pass'] ?? '') ?>">
</label>
<button type="submit" class="contrast" style="width: 100%;">Продолжить</button>
</form>
<?php elseif ($step === '2'): ?>
<!-- Шаг 2: Создание администратора -->
<form method="post">
<h3>Создание администратора</h3>
<p>Создайте учетную запись администратора для управления приложением.</p>
<label for="admin_username">
Имя пользователя *
<input type="text" id="admin_username" name="admin_username" value="<?= htmlspecialchars($_POST['admin_username'] ?? '') ?>" required>
</label>
<label for="admin_password">
Пароль *
<input type="password" id="admin_password" name="admin_password" required>
</label>
<label for="admin_display_name">
Отображаемое имя
<input type="text" id="admin_display_name" name="admin_display_name" value="<?= htmlspecialchars($_POST['admin_display_name'] ?? '') ?>">
</label>
<label for="admin_email">
Email
<input type="email" id="admin_email" name="admin_email" value="<?= htmlspecialchars($_POST['admin_email'] ?? '') ?>">
</label>
<div class="button-group">
<a href="install.php?step=1">Назад</a>
<button type="submit">Завершить установку</button>
</div>
</form>
<?php endif; ?>
<?php endif; ?>
<?php if ($step === '1' && !$success): ?>
<div style="margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
<h4>Перед установкой убедитесь, что:</h4>
<ul>
<li>Сервер MySQL запущен и доступен</li>
<li>У вас есть данные для подключения к БД (хост, пользователь, пароль)</li>
<li>Папка <code>config/</code> доступна для записи</li>
<li>Папка <code>uploads/</code> доступна для записи</li>
</ul>
</div>
<?php endif; ?>
</div>
</main>
</body>
</html>
// ./views/layouts/header.php
<?php
// views/layouts/header.php
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($page_title ?? 'Web Writer') ?></title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.10/css/pico.min.css">
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
<link href="<?= SITE_URL ?>/assets/css/quill.snow.css" rel="stylesheet">
<script src="<?= SITE_URL ?>/assets/js/quill.js"></script>
</head>
<body>
<nav class="container-fluid">
<ul>
<li><strong><a href="<?= SITE_URL ?>/"><?= e(APP_NAME) ?></a></strong></li>
</ul>
<ul>
<?php if (is_logged_in()): ?>
<li><a href="<?= SITE_URL ?>/dashboard">📊 Панель управления</a></li>
<li><a href="<?= SITE_URL ?>/books">📚 Мои книги</a></li>
<li><a href="<?= SITE_URL ?>/series">📑 Серии</a></li>
<li>
<details role="list" dir="rtl">
<summary aria-haspopup="listbox" role="link">
👤 <?= e($_SESSION['display_name']) ?>
</summary>
<ul role="listbox">
<li><a href="<?= SITE_URL ?>/profile">⚙️ Профиль</a></li>
<li><a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">👤 Моя публичная страница</a></li>
<?php if ($_SESSION['user_id'] == 1): // Проверка на администратора ?>
<li><a href="<?= SITE_URL ?>/admin/users">👥 Управление пользователями</a></li>
<?php endif; ?>
<li><a href="<?= SITE_URL ?>/logout">🚪 Выход</a></li>
</ul>
</details>
</li>
<?php else: ?>
<li><a href="<?= SITE_URL ?>/login">🔑 Вход</a></li>
<li><a href="<?= SITE_URL ?>/register">📝 Регистрация</a></li>
<?php endif; ?>
</ul>
</nav>
<main class="container">
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success">
<?= e($_SESSION['success']) ?>
<?php unset($_SESSION['success']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['warning'])): ?>
<div class="alert alert-warning">
<?= e($_SESSION['warning']) ?>
<?php unset($_SESSION['warning']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['info'])): ?>
<div class="alert alert-info">
<?= e($_SESSION['info']) ?>
<?php unset($_SESSION['info']); ?>
</div>
<?php endif; ?>
// ./views/layouts/footer.php
<?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);
});
}
</script>
</body>
</html>
// ./views/dashboard/index.php
<?php
// views/dashboard/index.php
include 'views/layouts/header.php';
?>
<h1>Панель управления</h1>
<div class="grid" style="margin-bottom: 2rem;">
<article style="text-align: center;">
<h2>📚 Книги</h2>
<div style="font-size: 2rem; font-weight: bold; color: var(--primary);">
<?= $total_books ?>
</div>
<small>Всего книг</small>
</article>
<article style="text-align: center;">
<h2>📑 Главы</h2>
<div style="font-size: 2rem; font-weight: bold; color: var(--success);">
<?= $total_chapters ?>
</div>
<small>Всего глав</small>
</article>
<article style="text-align: center;">
<h2>📝 Слова</h2>
<div style="font-size: 2rem; font-weight: bold; color: var(--warning);">
<?= number_format($total_words) ?>
</div>
<small>Всего слов</small>
</article>
<article style="text-align: center;">
<h2>🌐 Публикации</h2>
<div style="font-size: 2rem; font-weight: bold; color: var(--info);">
<?= $published_books_count ?>
</div>
<small>Опубликовано книг</small>
</article>
</div>
<div class="grid">
<div>
<h2>Недавние книги</h2>
<?php if (!empty($recent_books)): ?>
<?php foreach ($recent_books as $book): ?>
<article style="margin-bottom: 1em; padding-top: 0.5em;">
<h3 style="margin-bottom: 0.5rem; margin-top: 0.5em;">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit">
<?= e($book['title']) ?>
</a>
</h3>
<?php if ($book['genre']): ?>
<p style="margin: 0; color: var(--muted-color); font-size:small;"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?>
<?php if ($book['description']): ?>
<p style="margin: 0; color: var(--muted-color);"><?= e($book['description']) ?></p>
<?php endif; ?>
<footer>
<small>
Глав: <?= $book['chapter_count'] ?? 0 ?> |
Слов: <?= $book['total_words'] ?? 0 ?> |
Статус: <?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
</small>
</footer>
</article>
<?php endforeach; ?>
<?php if (count($recent_books) < count($books)): ?>
<div style="text-align: center; margin-top: 1rem;">
<a href="<?= SITE_URL ?>/books" class="button">Все книги</a>
</div>
<?php endif; ?>
<?php else: ?>
<article>
<p>У вас пока нет книг.</p>
<a href="<?= SITE_URL ?>/books/create" class="button">Создать первую книгу</a>
</article>
<?php endif; ?>
</div>
<div>
<h2>Мои серии</h2>
<?php if (!empty($series)): ?>
<?php foreach ($series as $ser): ?>
<article>
<h3 style="margin-bottom: 0.5rem;">
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/edit">
<?= e($ser['title']) ?>
</a>
</h3>
<?php if ($ser['description']): ?>
<p><?= e(mb_strimwidth($ser['description'], 0, 100, '...')) ?></p>
<?php endif; ?>
<footer>
<small>
Книг: <?= $ser['book_count'] ?> |
Слов: <?= $ser['total_words'] ?>
</small>
</footer>
</article>
<?php endforeach; ?>
<div style="text-align: center; margin-top: 1rem;">
<a href="<?= SITE_URL ?>/series" class="button">Все серии</a>
</div>
<?php else: ?>
<article>
<p>У вас пока нет серий.</p>
<a href="<?= SITE_URL ?>/series/create" class="button">Создать первую серию</a>
</article>
<?php endif; ?>
<h2 style="margin-top: 2rem;">Быстрые действия</h2>
<div class="button-group">
<a href="<?= SITE_URL ?>/books/create" class="button">📖 Новая книга</a>
<a href="<?= SITE_URL ?>/series/create" class="button secondary">📚 Новая серия</a>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?>
// ./views/auth/login.php
<?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'; ?>
// ./views/auth/register.php
<?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'; ?>
// ./views/chapters/preview.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) ?></title>
<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;
max-width: 800px;
margin: 0 auto;
line-height: 1.6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.content {
margin-top: 2rem;
}
h1, h2, h3, h4, h5, h6 {
margin-top: 1.5em;
margin-bottom: 0.5em;
}
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;
text-align: justify;
}
code {
background: var(--card-background-color);
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
pre {
background: var(--card-background-color);
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
border-left: 4px solid var(--primary);
}
pre code {
background: none;
padding: 0;
display: block;
}
blockquote {
border-left: 4px solid var(--border-color);
padding-left: 1rem;
margin-left: 0;
color: var(--muted-color);
font-style: italic;
}
strong { font-weight: bold; }
em { font-style: italic; }
u { text-decoration: underline; }
del { text-decoration: line-through; }
.dialogue {
margin-left: 2rem;
font-style: italic;
color: #2c5aa0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
table, th, td {
border: 1px solid var(--border-color);
}
th, td {
padding: 8px 12px;
text-align: left;
}
th {
background: var(--card-background-color);
}
ul, ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
li {
margin-bottom: 0.3rem;
}
img {
max-width: 100%;
height: auto;
}
</style>
</head>
<body>
<header>
<h1><?= e($title) ?></h1>
<hr>
</header>
<main class="content">
<?= $content ?>
</main>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<small>Сгенерировано <?= date('d.m.Y H:i') ?> | Предпросмотр</small>
<br>
<a href="javascript:window.close()" class="button secondary">Закрыть</a>
<a href="javascript:window.print()" class="button">Печать</a>
</footer>
</body>
</html>
// ./views/chapters/index.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; justify-content:center;">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button" role="button"> Новая глава</a>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="adaptive-button secondary" role="button">✏️ Редактировать книгу</a>
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="adaptive-button green-btn" role="button" target="_blank">👁️ Публичный доступ</a>
<a href="<?= SITE_URL ?>/book/all/<?= $book['id'] ?>" class="adaptive-button" role="button" target="_blank">👁️ Полный обзор</a>
</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['content']): ?>
<br><small style="color: var(--muted-color);"><?= e(mb_strimwidth($chapter['content'], 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="Редактировать" role="button">
✏️
</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'; ?>
// ./views/chapters/create.php
<?php
include 'views/layouts/header.php';
?>
<h1>Новая глава для: <?= e($book['title']) ?></h1>
<?php if (isset($error) && $error): ?>
<div class="alert alert-error">
<?= e($error) ?>
</div>
<?php endif; ?>
<form method="post" id="chapter-form">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Название главы *
</label>
<input type="text" id="title" name="title"
value="<?= e($_POST['title'] ?? '') ?>"
placeholder="Введите название главы"
style="width: 100%; margin-bottom: 1.5rem;"
required>
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Содержание главы *
</label>
<!-- Контейнер Quill -->
<div id="quill-editor"
class="writer-editor-container"
style="height:500px;"
data-content="<?= htmlspecialchars($_POST['content'] ?? '', ENT_QUOTES) ?>">
</div>
<!-- Скрытый textarea для формы -->
<textarea id="content" name="content" style="display:none;"><?= e($_POST['content'] ?? '') ?></textarea>
<div style="margin-top: 1rem;">
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Статус главы
</label>
<select id="status" name="status" style="width: 100%;">
<option value="draft" <?= (($_POST['status'] ?? 'draft') == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
<option value="published" <?= (($_POST['status'] ?? '') == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
</select>
<small style="color: var(--muted-color);">
Опубликованные главы видны в публичном доступе
</small>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
<button type="submit" class="contrast">💾 Сохранить главу</button>
<button type="button" onclick="previewChapter()" class="secondary">👁️ Предпросмотр</button>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary">❌ Отмена</a>
</div>
</form>
<link href="/assets/css/quill_reset.css" rel="stylesheet">
<script>
function previewChapter() {
const form = document.getElementById('chapter-form');
const formData = new FormData(form);
const tempForm = document.createElement('form');
tempForm.method = 'POST';
tempForm.action = '<?= SITE_URL ?>/chapters/preview';
tempForm.target = '_blank';
tempForm.style.display = 'none';
const csrfInput = document.createElement('input');
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= generate_csrf_token() ?>';
tempForm.appendChild(csrfInput);
const contentInput = document.createElement('input');
contentInput.name = 'content';
contentInput.value = document.getElementById('content').value;
tempForm.appendChild(contentInput);
const titleInput = document.createElement('input');
titleInput.name = 'title';
titleInput.value = document.getElementById('title').value || 'Предпросмотр главы';
tempForm.appendChild(titleInput);
const editorTypeInput = document.createElement('input');
editorTypeInput.name = 'editor_type';
editorTypeInput.value = '<?= $book['editor_type'] ?? 'markdown' ?>';
tempForm.appendChild(editorTypeInput);
document.body.appendChild(tempForm);
tempForm.submit();
document.body.removeChild(tempForm);
}
</script>
<script src="/assets/js/editor.js"></script>
<?php include 'views/layouts/footer.php'; ?>
// ./views/chapters/edit.php
<?php
// views/chapters/edit.php
include 'views/layouts/header.php';
?>
<h1>Редактирование главы: <?= e($chapter['title']) ?></h1>
<?php if (isset($error) && $error): ?>
<div class="alert alert-error">
<?= e($error) ?>
</div>
<?php endif; ?>
<form method="post" id="chapter-form">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Название главы *
</label>
<input type="text" id="title" name="title"
value="<?= e($chapter['title']) ?>"
placeholder="Введите название главы"
style="width: 100%; margin-bottom: 1.5rem;"
required>
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Содержание главы *
</label>
<!-- Контейнер Quill -->
<div id="quill-editor"
class="writer-editor-container"
style="height:500px;"
data-content="<?= htmlspecialchars($chapter['content'] ?? '', ENT_QUOTES) ?>">
</div>
<!-- Скрытый textarea для формы -->
<textarea id="content" name="content" style="display:none;"></textarea>
<div style="margin-top: 1rem;">
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Статус главы
</label>
<select id="status" name="status" style="width: 100%;">
<option value="draft" <?= ($chapter['status'] == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
<option value="published" <?= ($chapter['status'] == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
</select>
<small style="color: var(--muted-color);">
Опубликованные главы видны в публичном доступе
</small>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
<button type="submit" class="contrast">
💾 Сохранить изменения
</button>
<button type="button" onclick="previewChapter()" class="secondary">
👁️ Предпросмотр
</button>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary">
❌ Отмена
</a>
</div>
</form>
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
<h3>Информация о главе</h3>
<p><strong>Книга:</strong> <a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit"><?= e($book['title']) ?></a></p>
<p><strong>Количество слов:</strong> <?= $chapter['word_count'] ?></p>
<p><strong>Создана:</strong> <?= date('d.m.Y H:i', strtotime($chapter['created_at'])) ?></p>
<p><strong>Обновлена:</strong> <?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></p>
</div>
<link href="/assets/css/quill_reset.css" rel="stylesheet">
<script>
function previewChapter() {
const form = document.getElementById('chapter-form');
const formData = new FormData(form);
const tempForm = document.createElement('form');
tempForm.method = 'POST';
tempForm.action = '<?= SITE_URL ?>/chapters/preview';
tempForm.target = '_blank';
tempForm.style.display = 'none';
const csrfInput = document.createElement('input');
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= generate_csrf_token() ?>';
tempForm.appendChild(csrfInput);
const contentInput = document.createElement('input');
contentInput.name = 'content';
contentInput.value = document.getElementById('content').value;
tempForm.appendChild(contentInput);
const titleInput = document.createElement('input');
titleInput.name = 'title';
titleInput.value = document.getElementById('title').value || 'Предпросмотр главы';
tempForm.appendChild(titleInput);
const editorTypeInput = document.createElement('input');
editorTypeInput.name = 'editor_type';
editorTypeInput.value = '<?= $book['editor_type'] ?? 'markdown' ?>';
tempForm.appendChild(editorTypeInput);
document.body.appendChild(tempForm);
tempForm.submit();
document.body.removeChild(tempForm);
}
</script>
<script src="/assets/js/editor.js"></script>
<script src="/assets/js/autosave.js"></script>
<?php include 'views/layouts/footer.php'; ?>
// ./views/series/index.php
<?php
include 'views/layouts/header.php';
?>
<div style="display: block; justify-content: between; align-items: center; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem;">
<h1 style="margin: 0;">Мои серии книг</h1>
<a href="/series/create" class="action-button primary" role="button"> Создать серию</a>
</div>
<?php if (empty($series)): ?>
<article class="series-empty-state">
<div class="series-empty-icon">📚</div>
<h2>Пока нет серий</h2>
<p style="color: #666; margin-bottom: 2rem;">
Создайте свою первую серию, чтобы организовать книги в циклы и сериалы.
</p>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<a href="/series/create" class="action-button primary" role="button">Создать серию</a>
<a href="/books" class="action-button secondary">Перейти к книгам</a>
</div>
</article>
<?php else: ?>
<div class="series-grid">
<?php foreach ($series as $ser): ?>
<article class="series-card">
<div class="series-header">
<h3 class="series-title">
<a href="/series/<?= $ser['id'] ?>/edit"><?= e($ser['title']) ?></a>
</h3>
<div class="series-meta">
Создана <?= date('d.m.Y', strtotime($ser['created_at'])) ?>
<?php if ($ser['updated_at'] != $ser['created_at']): ?>
• Обновлена <?= date('d.m.Y', strtotime($ser['updated_at'])) ?>
<?php endif; ?>
</div>
</div>
<?php if (!empty($ser['description'])): ?>
<div class="series-description">
<?= e($ser['description']) ?>
</div>
<?php endif; ?>
<div class="series-stats-grid">
<div class="series-stat">
<span class="series-stat-number"><?= $ser['book_count'] ?? 0 ?></span>
<span class="series-stat-label">книг</span>
</div>
<div class="series-stat">
<span class="series-stat-number"><?= number_format($ser['total_words'] ?? 0) ?></span>
<span class="series-stat-label">слов</span>
</div>
<div class="series-stat">
<span class="series-stat-number">
<?php
$avg_words = $ser['book_count'] > 0 ? round($ser['total_words'] / $ser['book_count']) : 0;
echo number_format($avg_words);
?>
</span>
<span class="series-stat-label">слов/книга</span>
</div>
</div>
<div class="series-actions" style="display:grid;">
<a href="/series/<?= $ser['id'] ?>/edit" class="compact-button primary-btn" role="button">
✏️ Управление
</a>
<a href="/series/<?= $ser['id'] ?>/view" class="compact-button secondary-btn" target="_blank" role="button">
👁️ Публично
</a>
<form method="post" action="/series/<?= $ser['id'] ?>/delete"
onsubmit="return confirm('Удалить серию? Книги останутся, но будут удалены из серии.')">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button red-btn">🗑️ Удалить</button>
</form>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php
include 'views/layouts/footer.php';
?>
// ./views/series/create.php
<?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'; ?>
// ./views/series/edit.php
<?php
include 'views/layouts/header.php';
?>
<h1>Редактирование серии: <?= e($series['title']) ?></h1>
<article>
<h2>Основная информация</h2>
<form method="post" action="/series/<?= $series['id'] ?>/edit">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<label for="title">
Название серии *
<input type="text" id="title" name="title" value="<?= e($series['title']) ?>" required>
</label>
<label for="description">
Описание серии
<textarea id="description" name="description" rows="4"><?= e($series['description'] ?? '') ?></textarea>
</label>
<button type="submit" class="primary-btn">Сохранить изменения</button>
</form>
</article>
<div class="grid">
<div>
<article>
<h2>Добавить книгу в серию</h2>
<?php if (!empty($available_books)): ?>
<form method="post" action="/series/<?= $series['id'] ?>/add-book">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<label for="book_id">
Выберите книгу
<select id="book_id" name="book_id" required>
<option value="">-- Выберите книгу --</option>
<?php foreach ($available_books as $book): ?>
<option value="<?= $book['id'] ?>"><?= e($book['title']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label for="sort_order">
Порядковый номер в серии
<input type="number" id="sort_order" name="sort_order" value="<?= count($books_in_series) + 1 ?>" min="1">
</label>
<button type="submit" class="secondary-btn">Добавить в серию</button>
</form>
<?php else: ?>
<p>Все ваши книги уже добавлены в эту серию или у вас нет доступных книг.</p>
<a href="/books/create" class="primary-btn" role="button">Создать новую книгу</a>
<?php endif; ?>
</article>
</div>
<div>
<article>
<h2>Книги в серии (<?= count($books_in_series) ?>)</h2>
<?php if (!empty($books_in_series)): ?>
<div id="series-books-list">
<form id="reorder-form" method="post" action="/series/<?= $series['id'] ?>/update-order">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div class="books-list">
<?php foreach ($books_in_series as $index => $book): ?>
<div class="book-item" data-book-id="<?= $book['id'] ?>">
<div class="book-drag-handle" style="cursor: move;">☰</div>
<div class="book-info">
<strong><?= e($book['title']) ?></strong>
<small>Порядок: <?= $book['sort_order_in_series'] ?></small>
</div>
<div class="book-actions" style="display: grid; min-width: 2rem; margin-top: 1rem;">
<a href="/books/<?= $book['id'] ?>/edit" class="compact-button" role="button" style="margin-top: 0em;">✏️</a>
<form method="post" action="/series/<?= $series['id'] ?>/remove-book/<?= $book['id'] ?>"
style="display: inline;" onsubmit="return confirm('Удалить книгу из серии?')">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button red-btn" style="margin-top: 0em;">🗑️</button>
</form>
</div>
<input type="hidden" name="order[]" value="<?= $book['id'] ?>">
</div>
<?php endforeach; ?>
</div>
<button type="submit" class="secondary-btn" id="save-order-btn" style="display: none;">
Сохранить порядок
</button>
</form>
</div>
<?php else: ?>
<p>В этой серии пока нет книг. Добавьте книги с помощью формы слева.</p>
<?php endif; ?>
</article>
</div>
</div>
<style>
.books-list {
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.book-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #f0f0f0;
background: white;
transition: background-color 0.2s ease;
}
.book-item:last-child {
border-bottom: none;
}
.book-item:hover {
background: #f8f9fa;
}
.book-item.sortable-ghost {
opacity: 0.4;
}
.book-item.sortable-chosen {
background: #e3f2fd;
}
.book-drag-handle {
padding: 0 10px;
color: #666;
font-size: 1.2rem;
}
.book-info {
flex: 1;
padding: 0 10px;
}
.book-info strong {
display: block;
margin-bottom: 2px;
}
.book-info small {
color: #666;
font-size: 0.8rem;
}
.book-actions {
display: flex;
gap: 5px;
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const booksList = document.querySelector('.books-list');
const saveOrderBtn = document.getElementById('save-order-btn');
if (booksList) {
const sortable = new Sortable(booksList, {
handle: '.book-drag-handle',
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
animation: 150,
onUpdate: function() {
saveOrderBtn.style.display = 'block';
}
});
}
// Автосохранение порядка через 2 секунды после изменения
let saveTimeout;
saveOrderBtn.addEventListener('click', function(e) {
e.preventDefault();
clearTimeout(saveTimeout);
document.getElementById('reorder-form').submit();
});
// Автоматическое сохранение при изменении порядка
booksList.addEventListener('sortupdate', function() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
document.getElementById('reorder-form').submit();
}, 2000);
});
});
</script>
<?php
include 'views/layouts/footer.php';
?>
// ./views/series/view_public.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;">
<?= e($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'; ?>
// ./views/errors/404.php
<?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'; ?>
// ./views/admin/add_user.php
<?php include 'views/layouts/header.php'; ?>
<div class="container">
<h1>Добавление пользователя</h1>
<?php if (isset($error) && $error): ?>
<div class="alert alert-error">
<?= e($error) ?>
</div>
<?php endif; ?>
<?php if (isset($success) && $success): ?>
<div class="alert alert-success">
<?= e($success) ?>
</div>
<?php endif; ?>
<form method="post" style="max-width: 500px; margin: 0 auto;">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;">
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Имя пользователя *
</label>
<input type="text" id="username" name="username"
value="<?= e($_POST['username'] ?? '') ?>"
placeholder="Введите имя пользователя"
style="width: 100%;"
required
pattern="[a-zA-Z0-9_]+"
title="Только латинские буквы, цифры и символ подчеркивания">
</div>
<div style="margin-bottom: 1rem;">
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Отображаемое имя
</label>
<input type="text" id="display_name" name="display_name"
value="<?= e($_POST['display_name'] ?? '') ?>"
placeholder="Введите отображаемое имя"
style="width: 100%;">
</div>
<div style="margin-bottom: 1rem;">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Email
</label>
<input type="email" id="email" name="email"
value="<?= e($_POST['email'] ?? '') ?>"
placeholder="Введите email"
style="width: 100%;">
</div>
<div style="margin-bottom: 1rem;">
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Пароль *
</label>
<input type="password" id="password" name="password"
placeholder="Введите пароль (минимум 6 символов)"
style="width: 100%;"
required
minlength="6">
</div>
<div style="margin-bottom: 1rem;">
<label for="password_confirm" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Подтверждение пароля *
</label>
<input type="password" id="password_confirm" name="password_confirm"
placeholder="Повторите пароль"
style="width: 100%;"
required
minlength="6">
</div>
<div style="margin-bottom: 1.5rem;">
<label for="is_active">
<input type="checkbox" id="is_active" name="is_active" value="1"
<?= isset($_POST['is_active']) ? 'checked' : 'checked' ?>>
Активировать пользователя сразу
</label>
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="contrast" style="flex: 1;">
👥 Добавить пользователя
</button>
<a href="<?= SITE_URL ?>/admin/users" class="secondary" style="display: flex; align-items: center; justify-content: center; padding: 0.75rem; text-decoration: none;">
❌ Отмена
</a>
</div>
</form>
</div>
<?php include 'views/layouts/footer.php'; ?>
// ./views/admin/users.php
<?php include 'views/layouts/header.php'; ?>
<div class="container" style="margin:0; width: auto;">
<h1>Управление пользователями</h1>
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success">
<?= e($_SESSION['success']) ?>
<?php unset($_SESSION['success']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Всего пользователей: <?= count($users) ?></h2>
<a href="<?= SITE_URL ?>/admin/add-user" class="action-button primary"> Добавить пользователя</a>
</div>
<?php if (empty($users)): ?>
<article style="text-align: center; padding: 2rem;">
<h3>Пользователи не найдены</h3>
<p>Зарегистрируйте первого пользователя</p>
<a href="<?= SITE_URL ?>/admin/add-user" role="button">📝 Добавить пользователя</a>
</article>
<?php else: ?>
<div style="overflow-x: auto; width:100%;">
<table class="compact-table">
<thead>
<tr>
<th style="width: 5%;">ID</th>
<th style="width: 15%;">Имя пользователя</th>
<th style="width: 20%;">Отображаемое имя</th>
<th style="width: 20%;">Email</th>
<th style="width: 15%;">Дата регистрации</th>
<th style="width: 10%;">Статус</th>
<th style="width: 15%;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td><?= $user['id'] ?></td>
<td>
<strong><a href="<?= SITE_URL ?>/author/<?= $user['id'] ?>"><?= e($user['username']) ?></a></strong>
<?php if ($user['id'] == $_SESSION['user_id']): ?>
<br><small style="color: #666;">(Вы)</small>
<?php endif; ?>
</td>
<td><?= e($user['display_name']) ?></td>
<td><?= e($user['email']) ?></td>
<td>
<small><?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></small>
<?php if ($user['last_login']): ?>
<br><small style="color: #666;">Вход: <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small>
<?php endif; ?>
</td>
<td>
<span style="color: <?= $user['is_active'] ? 'green' : 'red' ?>">
<?= $user['is_active'] ? '✅ Активен' : '❌ Неактивен' ?>
</span>
</td>
<td>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/toggle-status" style="display: inline;">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button secondary" title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
<?= $user['is_active'] ? '⏸️' : '▶️' ?>
</button>
</form>
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить">
🗑️
</button>
</form>
</div>
<?php else: ?>
<small style="color: #666;">Текущий пользователь</small>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php include 'views/layouts/footer.php'; ?>
// ./views/user/profile.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'; ?>
// ./views/user/view_public.php
<?php
// views/user/view_public.php
include 'views/layouts/header.php';
?>
<div class="container" style="width:100%; margin-left: 0em; margin-right: 0em auto;">
<article>
<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;">
<?= e($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'; ?>
// ./views/books/index.php
<?php
// views/books/index.php
include 'views/layouts/header.php';
?>
<h1>Мои книги <small style="color: #ccc; font-size:1rem;">(Всего книг: <?= count($books) ?>)</small></h1>
<div style="display: flex; justify-content: left; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;">
<a href="<?= SITE_URL ?>/books/create" class="action-button primary" role="button"> Новая книга</a>
<?php if (!empty($books)): ?>
<a href="#" onclick="showDeleteAllConfirmation()" class="action-button delete">🗑️ Удалить все книги</a>
<?php endif; ?>
</div>
<?php if (empty($books)): ?>
<article style="text-align: center; padding: 2rem;">
<h3>У вас пока нет книг</h3>
<p>Создайте свою первую книгу и начните писать!</p>
<a href="<?= SITE_URL ?>/books/create" role="button">📖 Создать первую книгу</a>
</article>
<?php else: ?>
<div class="books-grid">
<?php foreach ($books as $book): ?>
<article class="book-card">
<!-- Обложка книги -->
<div class="book-cover-container">
<?php if (!empty($book['cover_image'])): ?>
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>"
class="book-cover"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="cover-placeholder" style="display: none;">
📚
</div>
<?php else: ?>
<div class="cover-placeholder">
📚
</div>
<?php endif; ?>
<!-- Статус книги -->
<div class="book-status <?= $book['published'] ? 'published' : 'draft' ?>">
<?= $book['published'] ? '✅' : '📝' ?>
</div>
</div>
<!-- Информация о книге -->
<div class="book-info">
<h3 class="book-title">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit">
<?= e($book['title']) ?>
</a>
</h3>
<?php if (!empty($book['genre'])): ?>
<p class="book-genre"><?= e($book['genre']) ?></p>
<?php endif; ?>
<?php if (!empty($book['description'])): ?>
<p class="book-description">
<?= e(mb_strimwidth($book['description'], 0, 120, '...')) ?>
</p>
<?php endif; ?>
<!-- Статистика -->
<div class="book-stats">
<span class="stat-item">
<strong><?= $book['chapter_count'] ?? 0 ?></strong> глав
</span>
<span class="stat-item">
<strong><?= number_format($book['total_words'] ?? 0) ?></strong> слов
</span>
</div>
<!-- Действия -->
<div class="book-actions">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button primary-btn">
✏️ Редактировать
</a>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="compact-button secondary-btn">
📑 Главы
</a>
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="compact-button green-btn" target="_blank">
👁️ Просмотр
</a>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
<!-- Статистика внизу -->
<div class="books-stats-footer">
<strong>Общая статистика:</strong>
Книг: <?= count($books) ?> |
Глав: <?= array_sum(array_column($books, 'chapter_count')) ?> |
Слов: <?= number_format(array_sum(array_column($books, 'total_words'))) ?> |
Опубликовано: <?= count(array_filter($books, function($book) { return $book['published']; })) ?>
</div>
<?php endif; ?>
<?php if (!empty($books)): ?>
<script>
function showDeleteAllConfirmation() {
if (confirm('Вы уверены, что хотите удалить ВСЕ книги? Это действие также удалит все главы и обложки книг. Действие нельзя отменить!')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '<?= SITE_URL ?>/books/delete-all';
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = '<?= generate_csrf_token() ?>';
form.appendChild(csrfInput);
document.body.appendChild(form);
form.submit();
}
}
</script>
<?php endif; ?>
<?php include 'views/layouts/footer.php'; ?>
// ./views/books/create.php
<?php
// views/books/create.php
include 'views/layouts/header.php';
?>
<h1>Создание новой книги</h1>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<?php if (isset($error) && $error): ?>
<div class="alert alert-error">
<?= e($error) ?>
</div>
<?php endif; ?>
<?php if (isset($cover_error) && $cover_error): ?>
<div class="alert alert-error">
Ошибка загрузки обложки: <?= e($cover_error) ?>
</div>
<?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="max-width: 100%; margin-bottom: 0.5rem;">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Название книги *
</label>
<input type="text" id="title" name="title"
value="<?= e($_POST['title'] ?? '') ?>"
placeholder="Введите название книги"
style="width: 100%; margin-bottom: 1.5rem;"
required>
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Жанр
</label>
<input type="text" id="genre" name="genre"
value="<?= e($_POST['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..."
style="width: 100%; margin-bottom: 1.5rem;">
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Серия
</label>
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
<option value="">-- Без серии --</option>
<?php foreach ($series as $ser): ?>
<option value="<?= $ser['id'] ?>" <?= (($_POST['series_id'] ?? '') == $ser['id']) ? 'selected' : '' ?>>
<?= e($ser['title']) ?>
</option>
<?php endforeach; ?>
</select>
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Описание книги
</label>
<textarea id="description" name="description"
placeholder="Краткое описание сюжета или аннотация..."
rows="6"
style="width: 100;"><?= e($_POST['description'] ?? '') ?></textarea>
<div style="margin-bottom: 1rem;">
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Обложка книги
</label>
<input type="file" id="cover_image" name="cover_image"
accept="image/jpeg,image/png,image/gif,image/webp">
<small style="color: var(--muted-color);">
Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
</small>
</div>
<div style="margin-top: 1rem;">
<label for="published">
<input type="checkbox" id="published" name="published" value="1"
<?= (!empty($_POST['published']) && $_POST['published']) ? 'checked' : '' ?>>
Опубликовать книгу (показывать на публичной странице автора)
</label>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
<button type="submit" class="contrast">
📖 Создать книгу
</button>
<a href="<?= SITE_URL ?>/books" role="button" class="secondary">
❌ Отмена
</a>
</div>
</form>
<?php include 'views/layouts/footer.php'; ?>
// ./views/books/edit.php
<?php
// views/books/edit.php
include 'views/layouts/header.php';
?>
<?php if (isset($_SESSION['cover_error'])): ?>
<div class="alert alert-error">
Ошибка загрузки обложки: <?= e($_SESSION['cover_error']) ?>
<?php unset($_SESSION['cover_error']); ?>
</div>
<?php endif; ?>
<h1>Редактирование книги</h1>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="max-width: 100%; margin-bottom: 0.5rem;">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Название книги *
</label>
<input type="text" id="title" name="title"
value="<?= e($book['title'] ?? '') ?>"
placeholder="Введите название книги"
style="width: 100%; margin-bottom: 1.5rem;"
required>
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Жанр
</label>
<input type="text" id="genre" name="genre"
value="<?= e($book['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..."
style="width: 100%; margin-bottom: 1.5rem;">
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Серия
</label>
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
<option value="">-- Без серии --</option>
<?php foreach ($series as $ser): ?>
<option value="<?= $ser['id'] ?>" <?= ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '' ?>>
<?= e($ser['title']) ?>
</option>
<?php endforeach; ?>
</select>
<label for="sort_order_in_series" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Порядок в серии
</label>
<input type="number" id="sort_order_in_series" name="sort_order_in_series"
value="<?= e($book['sort_order_in_series'] ?? '') ?>"
placeholder="Номер по порядку в серии"
min="1"
style="width: 100%; margin-bottom: 1.5rem;">
<!-- Обложка -->
<div style="margin-bottom: 1.5rem;">
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Обложка книги
</label>
<?php if (!empty($book['cover_image'])): ?>
<div style="margin-bottom: 1rem;">
<p><strong>Текущая обложка:</strong></p>
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="Обложка"
style="max-width: 200px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);">
<div style="margin-top: 0.5rem;">
<label style="display: inline-flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" name="delete_cover" value="1">
Удалить обложку
</label>
</div>
</div>
<?php endif; ?>
<input type="file" id="cover_image" name="cover_image"
accept="image/jpeg, image/png, image/gif, image/webp"
style="height: 2.6rem;">
<small style="color: var(--muted-color);">
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
Рекомендуемый размер: 300×450 пикселей.
</small>
<?php if (!empty($cover_error)): ?>
<div style="color: #d32f2f; margin-top: 0.5rem;">
❌ <?= e($cover_error) ?>
</div>
<?php endif; ?>
</div>
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Описание книги
</label>
<textarea id="description" name="description"
placeholder="Краткое описание сюжета или аннотация..."
rows="6"
style="width: 100%;"><?= e($book['description'] ?? '') ?></textarea>
<div style="margin-top: 1rem;">
<label for="published">
<input type="checkbox" id="published" name="published" value="1"
<?= !empty($book['published']) ? 'checked' : '' ?>>
Опубликовать книгу (показывать на публичной странице автора)
</label>
</div>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
<button type="submit" class="contrast">
💾 Сохранить изменения
</button>
<!-- <a href="<?= SITE_URL ?>/books" role="button" class="secondary">
❌ Отмена
</a> -->
</div>
</form>
<?php if ($book): ?>
<div style="margin-top: 0.5rem; padding: 0rem; background: var(--card-background-color); border-radius: 5px;">
<h3>Публичная ссылка для чтения</h3>
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;">
<input type="text"
id="share-link"
value="<?= e(SITE_URL . '/book/' . $book['share_token']) ?>"
readonly
style="flex: 1; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: white;">
<button type="button" onclick="copyShareLink()" class="adaptive-button">
📋 Копировать
</button>
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/regenerate-token" style="display: inline;">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="adaptive-button secondary" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')">
🔄 Обновить
</button>
</form>
</div>
<p style="margin-top: 0.5rem; font-size: 0.8em; color: var(--muted-color);">
<strong>Примечание:</strong> В публичном просмотре отображаются только главы со статусом "Опубликована"
</p>
</div>
<div style="margin-top: 0.5rem; padding: 0rem; background: var(--card-background-color); border-radius: 5px;">
<h3>Экспорт книги</h3>
<p style="margin-bottom: 0.5rem;">Экспортируйте книгу в различные форматы:</p>
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
📄 PDF
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
📝 DOCX
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
🌐 HTML
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
📄 TXT
</a>
</div>
<p style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
<strong>Примечание:</strong> Экспортируются все главы книги (включая черновики)
</p>
</div>
<div style="margin-top: 0.5rem;">
<h2>Главы этой книги</h2>
<div style="display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 1rem;">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="adaptive-button secondary" role="button">
📑 Все главы
</a>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary" role="button">
✏️ Добавить главу
</a>
</div>
<?php if (!empty($chapters)): ?>
<div style="overflow-x: auto;">
<table style="width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 12px 8px;">Название</th>
<th style="text-align: left; padding: 12px 8px;">Статус</th>
<th style="text-align: left; padding: 12px 8px;">Слов</th>
<th style="text-align: left; padding: 12px 8px;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($chapters as $chapter): ?>
<tr style="border-bottom: 1px solid var(--border-color);">
<td style="padding: 12px 8px;"><?= e($chapter['title']) ?></td>
<td style="padding: 12px 8px;">
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>">
<?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
</span>
</td>
<td style="padding: 12px 8px;"><?= $chapter['word_count'] ?></td>
<td style="padding: 12px 8px;">
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary">
Редактировать
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="text-align: center; padding: 0.5rem; background: var(--card-background-color); border-radius: 5px;">
<p style="margin-bottom: 1rem;">В этой книге пока нет глав.</p>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary" role="button">
✏️ Добавить первую главу
</a>
</div>
<?php endif; ?>
</div>
<div style="margin-top: 0.5rem; text-align: center;">
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="adaptive-button red-btn">
🗑️ Удалить книгу
</button>
</form>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Копирование ссылки для чтения
window.copyShareLink = function() {
const shareLink = document.getElementById('share-link');
shareLink.select();
document.execCommand('copy');
const button = event.target;
const originalText = '📋 Копировать';
button.textContent = '✅ Скопировано';
setTimeout(() => {
button.textContent = originalText;
}, 2000);
}
});
</script>
<?php include 'views/layouts/footer.php'; ?>
// ./views/books/view_public.php
<?php
// views/books/view_public.php
include 'views/layouts/header.php';
?>
<div class="container" style="padding: 0em; margin: 0em auto; width: 90%;">
<article style="margin: 0 auto;">
<header style="text-align: center; margin-bottom: 2rem;">
<?php if (!empty($book['cover_image'])): ?>
<div style="margin-bottom: 1rem;">
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>"
style="max-width: 200px; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"
onerror="this.style.display='none'">
</div>
<?php endif; ?>
<h1 style="margin-bottom: 0.5rem;"><?= e($book['title']) ?></h1>
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
Автор: <a href="<?= SITE_URL ?>/author/<?= $book['user_id'] ?>"><?= e($author['display_name']??$author['username']) ?></a>
</p>
<?php if (!empty($book['genre'])): ?>
<p style="color: #666; font-style: italic; margin-bottom: 1rem;">
<?= e($book['genre']) ?>
</p>
<?php endif; ?>
<?php if (!empty($book['description'])): ?>
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
<?= nl2br(e($book['description'])) ?>
</div>
<?php endif; ?>
<div style="display: block; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<span>Глав: <?= count($chapters) ?></span>
<span>Слов: <?= array_sum(array_column($chapters, 'word_count')) ?></span>
<p>
<?php if (!is_logged_in()): ?>
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: center;">
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
📄 PDF
</a>
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
📝 DOCX
</a>
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
🌐 HTML
</a>
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
📄 TXT
</a>
</div>
<?php endif; ?>
<?php if (is_logged_in()): ?>
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: center;">
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
📄 PDF
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
📝 DOCX
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
🌐 HTML
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
📄 TXT
</a>
</div>
<?php endif; ?>
</p>
</div>
</header>
<?php if (empty($chapters)): ?>
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
<h3>В этой книге пока нет глав</h3>
<p>Автор еще не опубликовал содержание книги</p>
</div>
<?php else: ?>
<h3 style="text-align: center; margin-bottom: 2rem; margin-top: 0em;">Оглавление</h3>
<div class="chapters-list">
<?php foreach ($chapters as $index => $chapter): ?>
<h6 style="margin-top: 0; margin-bottom: 0em;">
<a href="#chapter-<?= $chapter['id'] ?>" style="text-decoration: none;">
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?>
</a>
</h6>
<?php endforeach; ?>
</div>
<hr style="margin: 2rem 0;">
<?php foreach ($chapters as $index => $chapter): ?>
<div class="chapter-content" id="chapter-<?= $chapter['id'] ?>" style="margin-bottom: 3rem;">
<h2 style="border-bottom: 2px solid var(--primary); padding-bottom: 0.5rem;">
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?>
</h2>
<div style="margin-top: 1.5rem; line-height: 1.6;">
<?= $chapter['content'] ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;">
<p style="color: var(--muted-color);">
Книга создана в <?= e(APP_NAME) ?> •
<?= date('Y') ?>
</p>
</footer>
</article>
</div>
<style>
.chapter-content h1, .chapter-content h2, .chapter-content h3 {
margin-top: 1.5em;
margin-bottom: 0.5em;
}
.chapter-content p {
margin-bottom: 1em;
text-align: justify;
}
.chapter-content .dialogue {
margin-left: 2rem;
font-style: italic;
color: #2c5aa0;
}
.chapter-content blockquote {
border-left: 4px solid var(--primary);
padding-left: 1rem;
margin-left: 0;
color: #555;
font-style: italic;
}
.chapter-content code {
background: var(--card-background-color);
padding: 2px 4px;
border-radius: 3px;
}
.chapter-content pre {
background: var(--card-background-color);
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
}
.chapter-content ul, .chapter-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
</style>
<?php include 'views/layouts/footer.php'; ?>