From 833d125f6451efda6bfe91650a659f0ce0eecfda Mon Sep 17 00:00:00 2001
From: mirivlad
Date: Tue, 25 Nov 2025 17:25:40 +0800
Subject: [PATCH] continue
---
333.txt | 7398 +++++++++++++++++++++++++
README.md | 0
assets/css/index.php | 0
assets/css/quill_reset.css | 41 +
assets/css/style.css | 1217 ++--
assets/index.php | 0
assets/js/autosave.js | 206 +-
assets/js/editor.js | 102 +
assets/js/index.php | 0
assets/js/markdown-editor.js | 575 --
config/config.php | 0
config/index.php | 0
controllers/AdminController.php | 140 +
controllers/AuthController.php | 0
controllers/BaseController.php | 17 +
controllers/BookController.php | 143 +-
controllers/ChapterController.php | 157 +-
controllers/DashboardController.php | 0
controllers/ExportController.php | 257 +-
controllers/SeriesController.php | 124 +-
controllers/UserController.php | 4 +-
includes/index.php | 0
includes/parsedown/Parsedown.php | 1994 -------
includes/parsedown/ParsedownExtra.php | 18 -
includes/parsedown/index.php | 0
index.php | 13 +-
install.php | 8 +-
models/Book.php | 443 +-
models/Series.php | 0
models/User.php | 10 +-
models/index.php | 0
uploads/avatars/index.php | 0
uploads/covers/cover_2_1764062021.jpg | Bin 0 -> 83534 bytes
uploads/covers/cover_3_1764051570.jpg | Bin 0 -> 27798 bytes
views/admin/add_user.php | 88 +
views/admin/users.php | 96 +
views/auth/login.php | 0
views/auth/register.php | 0
views/books/create.php | 35 +-
views/books/edit.php | 43 +-
views/books/index.php | 138 +-
views/books/view_public.php | 0
views/chapters/create.php | 38 +-
views/chapters/edit.php | 23 +-
views/chapters/index.php | 0
views/chapters/preview.php | 0
views/dashboard/index.php | 0
views/errors/404.php | 0
views/layouts/footer.php | 20 +-
views/layouts/header.php | 7 +-
views/series/create.php | 0
views/series/edit.php | 318 +-
views/series/index.php | 85 +
views/series/view_public.php | 2 +-
views/user/profile.php | 0
views/user/view_public.php | 2 +-
56 files changed, 9428 insertions(+), 4334 deletions(-)
create mode 100644 333.txt
mode change 100644 => 100755 README.md
mode change 100644 => 100755 assets/css/index.php
create mode 100644 assets/css/quill_reset.css
mode change 100644 => 100755 assets/index.php
create mode 100644 assets/js/editor.js
mode change 100644 => 100755 assets/js/index.php
delete mode 100755 assets/js/markdown-editor.js
mode change 100644 => 100755 config/config.php
mode change 100644 => 100755 config/index.php
create mode 100755 controllers/AdminController.php
mode change 100644 => 100755 controllers/AuthController.php
mode change 100644 => 100755 controllers/BaseController.php
mode change 100644 => 100755 controllers/BookController.php
mode change 100644 => 100755 controllers/ChapterController.php
mode change 100644 => 100755 controllers/DashboardController.php
mode change 100644 => 100755 controllers/ExportController.php
mode change 100644 => 100755 controllers/SeriesController.php
mode change 100644 => 100755 controllers/UserController.php
mode change 100644 => 100755 includes/index.php
delete mode 100755 includes/parsedown/Parsedown.php
delete mode 100755 includes/parsedown/ParsedownExtra.php
delete mode 100644 includes/parsedown/index.php
mode change 100644 => 100755 models/Series.php
mode change 100644 => 100755 models/index.php
mode change 100644 => 100755 uploads/avatars/index.php
create mode 100644 uploads/covers/cover_2_1764062021.jpg
create mode 100644 uploads/covers/cover_3_1764051570.jpg
create mode 100755 views/admin/add_user.php
create mode 100755 views/admin/users.php
mode change 100644 => 100755 views/auth/login.php
mode change 100644 => 100755 views/auth/register.php
mode change 100644 => 100755 views/books/create.php
mode change 100644 => 100755 views/books/edit.php
mode change 100644 => 100755 views/books/index.php
mode change 100644 => 100755 views/books/view_public.php
mode change 100644 => 100755 views/chapters/create.php
mode change 100644 => 100755 views/chapters/edit.php
mode change 100644 => 100755 views/chapters/index.php
mode change 100644 => 100755 views/chapters/preview.php
mode change 100644 => 100755 views/dashboard/index.php
mode change 100644 => 100755 views/errors/404.php
mode change 100644 => 100755 views/series/create.php
mode change 100644 => 100755 views/series/edit.php
create mode 100644 views/series/index.php
mode change 100644 => 100755 views/series/view_public.php
mode change 100644 => 100755 views/user/profile.php
mode change 100644 => 100755 views/user/view_public.php
diff --git a/333.txt b/333.txt
new file mode 100644
index 0000000..5df903d
--- /dev/null
+++ b/333.txt
@@ -0,0 +1,7398 @@
+// ./controllers/DashboardController.php
+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
+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
+requireLogin();
+
+ $user_id = $_SESSION['user_id'];
+
+ $bookModel = new Book($this->pdo);
+ $chapterModel = new Chapter($this->pdo);
+
+ // Проверяем права доступа к книге
+ if (!$bookModel->userOwnsBook($book_id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой книге";
+ $this->redirect('/books');
+ }
+
+ // Получаем информацию о книге и главах
+ $book = $bookModel->findById($book_id);
+ $chapters = $chapterModel->findByBook($book_id);
+
+ $this->render('chapters/index', [
+ 'book' => $book,
+ 'chapters' => $chapters,
+ 'page_title' => "Главы книги: " . e($book['title'])
+ ]);
+ }
+
+ public function create($book_id) {
+ $this->requireLogin();
+
+ $user_id = $_SESSION['user_id'];
+
+ $bookModel = new Book($this->pdo);
+ $chapterModel = new Chapter($this->pdo);
+
+ // Проверяем права доступа к книге
+ if (!$bookModel->userOwnsBook($book_id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой книге";
+ $this->redirect('/books');
+ }
+
+ $book = $bookModel->findById($book_id);
+ $error = '';
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $error = "Ошибка безопасности";
+ } else {
+ $title = trim($_POST['title'] ?? '');
+ $content = $_POST['content'] ?? '';
+ $status = $_POST['status'] ?? 'draft';
+
+ if (empty($title)) {
+ $error = "Название главы обязательно";
+ } else {
+ $data = [
+ 'book_id' => $book_id,
+ 'title' => $title,
+ 'content' => $content,
+ 'status' => $status
+ ];
+
+ if ($chapterModel->create($data)) {
+ $_SESSION['success'] = "Глава успешно создана";
+ $this->redirect("/books/{$book_id}/chapters");
+ } else {
+ $error = "Ошибка при создании главы";
+ }
+ }
+ }
+ }
+
+ $this->render('chapters/create', [
+ 'book' => $book,
+ 'error' => $error,
+ 'page_title' => "Новая глава для: " . e($book['title'])
+ ]);
+ }
+
+ public function edit($id) {
+ $this->requireLogin();
+
+ $user_id = $_SESSION['user_id'];
+
+ $chapterModel = new Chapter($this->pdo);
+ $bookModel = new Book($this->pdo);
+
+ // Проверяем права доступа к главе
+ if (!$chapterModel->userOwnsChapter($id, $user_id)) {
+ if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
+ // Для AJAX запросов возвращаем JSON
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'error' => 'Доступ запрещен']);
+ exit;
+ }
+ $_SESSION['error'] = "У вас нет доступа к этой главе";
+ $this->redirect('/books');
+ }
+
+ $chapter = $chapterModel->findById($id);
+
+ // Дополнительная проверка - глава должна существовать
+ if (!$chapter) {
+ if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'error' => 'Глава не найдена']);
+ exit;
+ }
+ $_SESSION['error'] = "Глава не найдена";
+ $this->redirect('/books');
+ }
+
+ $book = $bookModel->findById($chapter['book_id']);
+ $error = '';
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $error = "Ошибка безопасности";
+ } else {
+ $title = trim($_POST['title'] ?? '');
+ $content = $_POST['content'] ?? '';
+ $status = $_POST['status'] ?? 'draft';
+
+ if (empty($title)) {
+ $error = "Название главы обязательно";
+ } else {
+ $data = [
+ 'title' => $title,
+ 'content' => $content,
+ 'status' => $status
+ ];
+
+ // Если это запрос автосейва, возвращаем JSON ответ
+ if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
+ if ($chapterModel->update($id, $data)) {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => true]);
+ exit;
+ } else {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'error' => 'Ошибка при сохранении']);
+ exit;
+ }
+ }
+
+ // Обычный POST запрос (сохранение формы)
+ if ($chapterModel->update($id, $data)) {
+ $_SESSION['success'] = "Глава успешно обновлена";
+ $this->redirect("/books/{$chapter['book_id']}/chapters");
+ } 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'] ?? '';
+ $title = $_POST['title'] ?? 'Предпросмотр';
+
+ // Просто используем HTML как есть
+ $html_content = $content;
+
+ $this->render('chapters/preview', [
+ 'content' => $html_content,
+ 'title' => $title,
+ 'page_title' => "Предпросмотр: " . e($title)
+ ]);
+ }
+
+}
+?>
+// ./controllers/ExportController.php
+requireLogin();
+
+ $user_id = $_SESSION['user_id'];
+
+ $bookModel = new Book($this->pdo);
+ $chapterModel = new Chapter($this->pdo);
+
+ $book = $bookModel->findById($book_id);
+ if (!$book || $book['user_id'] != $user_id) {
+ $_SESSION['error'] = "Доступ запрещен";
+ $this->redirect('/books');
+ }
+
+ // Для автора - все главы
+ $chapters = $chapterModel->findByBook($book_id);
+
+ // Получаем информацию об авторе
+ $author_name = $this->getAuthorName($book['user_id']);
+
+ $this->handleExport($book, $chapters, false, $author_name, $format);
+ }
+
+ public function exportShared($share_token, $format = 'pdf') {
+ $bookModel = new Book($this->pdo);
+ $chapterModel = new Chapter($this->pdo);
+
+ $book = $bookModel->findByShareToken($share_token);
+ if (!$book) {
+ $_SESSION['error'] = "Книга не найдена";
+ $this->redirect('/');
+ }
+
+ // Для публичного доступа - только опубликованные главы
+ $chapters = $bookModel->getPublishedChapters($book['id']);
+
+ // Получаем информацию об авторе
+ $author_name = $this->getAuthorName($book['user_id']);
+
+ $this->handleExport($book, $chapters, true, $author_name, $format);
+ }
+
+ private function getAuthorName($user_id) {
+ $stmt = $this->pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
+ $stmt->execute([$user_id]);
+ $author_info = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ if ($author_info && $author_info['display_name'] != "") {
+ return $author_info['display_name'];
+ } elseif ($author_info) {
+ return $author_info['username'];
+ }
+
+ return "Неизвестный автор";
+ }
+
+ private function handleExport($book, $chapters, $is_public, $author_name, $format) {
+
+
+ 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 = '
+
+
+
+
+ ' . htmlspecialchars($book['title']) . '
+
+
+
+ ' . htmlspecialchars($book['title']) . '
+ ' . htmlspecialchars($author_name) . '
';
+
+ if (!empty($book['genre'])) {
+ $html .= 'Жанр: ' . htmlspecialchars($book['genre']) . '
';
+ }
+
+ // Обложка книги
+ if (!empty($book['cover_image'])) {
+ $cover_url = COVERS_URL . $book['cover_image'];
+ $html .= '';
+ $html .= '
![' . htmlspecialchars($book['title']) . '](' . $cover_url . ')
';
+ $html .= '
';
+ }
+
+ if (!empty($book['description'])) {
+ $html .= '';
+ $html .= $book['description'];
+ $html .= '
';
+ }
+
+ // Интерактивное оглавление
+ if (!empty($chapters)) {
+ $html .= '';
+ $html .= '
Оглавление
';
+ $html .= '
';
+ $html .= '
';
+ }
+
+ $html .= '
';
+
+ foreach ($chapters as $index => $chapter) {
+ $html .= '';
+ $html .= '
' . htmlspecialchars($chapter['title']) . '
';
+ $html .= '
' . $chapter['content']. '
';
+ $html .= '
';
+
+ if ($index < count($chapters) - 1) {
+ $html .= '
';
+ }
+ }
+
+ $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
+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
+requireLogin();
+
+ $user_id = $_SESSION['user_id'];
+ $seriesModel = new Series($this->pdo);
+ $series = $seriesModel->findByUser($user_id);
+
+ // Получаем статистику для каждой серии отдельно
+ foreach ($series as &$ser) {
+ $stats = $seriesModel->getSeriesStats($ser['id'], $user_id);
+ $ser['book_count'] = $stats['book_count'] ?? 0;
+ $ser['total_words'] = $stats['total_words'] ?? 0;
+ }
+ unset($ser);
+
+ $this->render('series/index', [
+ 'series' => $series,
+ 'page_title' => "Мои серии книг"
+ ]);
+ }
+
+ public function create() {
+ $this->requireLogin();
+
+ $error = '';
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $error = "Ошибка безопасности";
+ } else {
+ $title = trim($_POST['title'] ?? '');
+ $description = trim($_POST['description'] ?? '');
+
+ if (empty($title)) {
+ $error = "Название серии обязательно";
+ } else {
+ $seriesModel = new Series($this->pdo);
+ $data = [
+ 'title' => $title,
+ 'description' => $description,
+ 'user_id' => $_SESSION['user_id']
+ ];
+
+ if ($seriesModel->create($data)) {
+ $_SESSION['success'] = "Серия успешно создана";
+ $new_series_id = $this->pdo->lastInsertId();
+ $this->redirect("/series/{$new_series_id}/edit");
+ } else {
+ $error = "Ошибка при создании серии";
+ }
+ }
+ }
+ }
+
+ $this->render('series/create', [
+ 'error' => $error,
+ 'page_title' => "Создание новой серии"
+ ]);
+ }
+
+ public function edit($id) {
+ $this->requireLogin();
+
+ $user_id = $_SESSION['user_id'];
+ $seriesModel = new Series($this->pdo);
+ $series = $seriesModel->findById($id);
+
+ if (!$series || !$seriesModel->userOwnsSeries($id, $user_id)) {
+ $_SESSION['error'] = "Серия не найдена или у вас нет доступа";
+ $this->redirect('/series');
+ }
+
+ $error = '';
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $error = "Ошибка безопасности";
+ } else {
+ $title = trim($_POST['title'] ?? '');
+ $description = trim($_POST['description'] ?? '');
+
+ if (empty($title)) {
+ $error = "Название серии обязательно";
+ } else {
+ $data = [
+ 'title' => $title,
+ 'description' => $description,
+ 'user_id' => $user_id
+ ];
+
+ if ($seriesModel->update($id, $data)) {
+ $_SESSION['success'] = "Серия успешно обновлена";
+ $this->redirect('/series');
+ } else {
+ $error = "Ошибка при обновлении серии";
+ }
+ }
+ }
+ }
+
+ // Получаем книги в серии
+ $bookModel = new Book($this->pdo);
+ $books_in_series = $bookModel->findBySeries($id);
+
+ $this->render('series/edit', [
+ 'series' => $series,
+ 'books_in_series' => $books_in_series,
+ 'error' => $error,
+ 'page_title' => "Редактирование серии: " . e($series['title'])
+ ]);
+ }
+
+ public function delete($id) {
+ $this->requireLogin();
+
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ $_SESSION['error'] = "Неверный метод запроса";
+ $this->redirect('/series');
+ }
+
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $_SESSION['error'] = "Ошибка безопасности";
+ $this->redirect('/series');
+ }
+
+ $user_id = $_SESSION['user_id'];
+ $seriesModel = new Series($this->pdo);
+
+ if (!$seriesModel->userOwnsSeries($id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой серии";
+ $this->redirect('/series');
+ }
+
+ if ($seriesModel->delete($id, $user_id)) {
+ $_SESSION['success'] = "Серия успешно удалена";
+ } else {
+ $_SESSION['error'] = "Ошибка при удалении серии";
+ }
+
+ $this->redirect('/series');
+ }
+
+ public function viewPublic($id) {
+ $seriesModel = new Series($this->pdo);
+ $series = $seriesModel->findById($id);
+
+ if (!$series) {
+ http_response_code(404);
+ $this->render('errors/404');
+ return;
+ }
+
+ // Получаем только опубликованные книги серии
+ $books = $seriesModel->getBooksInSeries($id, true);
+
+ // Получаем информацию об авторе
+ $stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
+ $stmt->execute([$series['user_id']]);
+ $author = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ // Получаем статистику по опубликованным книгам
+ $bookModel = new Book($this->pdo);
+ $total_words = 0;
+ $total_chapters = 0;
+
+ foreach ($books as $book) {
+ $book_stats = $bookModel->getBookStats($book['id'], true);
+ $total_words += $book_stats['total_words'] ?? 0;
+ $total_chapters += $book_stats['chapter_count'] ?? 0;
+ }
+
+ $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
+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
+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']);
+
+ 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)) {
+ $_SESSION['success'] = "Книга успешно создана";
+ $new_book_id = $this->pdo->lastInsertId();
+ $this->redirect("/books/{$new_book_id}/edit");
+ } else {
+ $_SESSION['error'] = "Ошибка при создании книги";
+ }
+ }
+
+ $this->render('books/create', [
+ 'series' => $series,
+ '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);
+ $book = $bookModel->findByShareToken($share_token);
+ if (!$book) {
+ http_response_code(404);
+ $this->render('errors/404');
+ return;
+ }
+ $chapters = $bookModel->getPublishedChapters($book['id']);
+
+ // Получаем информацию об авторе
+ $stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
+ $stmt->execute([$book['user_id']]);
+ $author = $stmt->fetch(PDO::FETCH_ASSOC);
+
+ $this->render('books/view_public', [
+ 'book' => $book,
+ 'chapters' => $chapters,
+ 'author' => $author,
+ 'page_title' => $book['title']
+ ]);
+ }
+
+
+ public function 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
+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
+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;
+ }
+
+}
+?>
+// ./models/Series.php
+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
+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
+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 getPublishedChapters($book_id) {
+ $stmt = $this->pdo->prepare("
+ SELECT * FROM chapters
+ WHERE book_id = ? AND status = 'published'
+ ORDER BY sort_order, created_at
+ ");
+ $stmt->execute([$book_id]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ public function updateCover($book_id, $filename) {
+ $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);
+ }
+
+
+
+ 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]);
+ }
+
+ public function getBooksNotInSeries($user_id, $series_id = null) {
+ $sql = "SELECT * FROM books WHERE user_id = ? AND (series_id IS NULL OR series_id = ?)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([$user_id, $series_id]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ public function reorderSeriesBooks($series_id, $new_order) {
+ try {
+ $this->pdo->beginTransaction();
+
+ foreach ($new_order as $order => $book_id) {
+ $stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
+ $stmt->execute([$order + 1, $book_id, $series_id]);
+ }
+
+ $this->pdo->commit();
+ return true;
+ } catch (Exception $e) {
+ $this->pdo->rollBack();
+ error_log("Ошибка при обновлении порядка книг: " . $e->getMessage());
+ return false;
+ }
+ }
+
+ 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
+ '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, $params);
+ }
+
+ if (is_string($handler)) {
+ list($controller, $method) = explode('@', $handler);
+ $controllerFile = "controllers/{$controller}.php";
+
+ if (file_exists($controllerFile)) {
+ require_once $controllerFile;
+ $controllerInstance = new $controller();
+
+ if (method_exists($controllerInstance, $method)) {
+ return call_user_func_array([$controllerInstance, $method], $params);
+ }
+ }
+ }
+
+ throw new Exception("Handler not found");
+ }
+}
+
+// Инициализация роутера
+$router = new Router();
+
+// Маршруты
+$router->add('/', 'DashboardController@index');
+$router->add('/login', 'AuthController@login');
+$router->add('/logout', 'AuthController@logout');
+$router->add('/register', 'AuthController@register');
+
+// Книги
+$router->add('/books', 'BookController@index');
+$router->add('/books/create', 'BookController@create');
+$router->add('/books/{id}/edit', 'BookController@edit');
+$router->add('/books/{id}/delete', 'BookController@delete');
+$router->add('/books/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/{book_id}/{format}', 'ExportController@export');
+$router->add('/export/{book_id}', 'ExportController@export'); // по умолчанию pdf
+$router->add('/export/shared/{share_token}/{format}', 'ExportController@exportShared');
+$router->add('/export/shared/{share_token}', 'ExportController@exportShared'); // по умолчанию pdf
+
+// Публичные страницы
+$router->add('/book/{share_token}', 'BookController@viewPublic');
+$router->add('/author/{id}', 'UserController@viewPublic');
+$router->add('/series/{id}/view', 'SeriesController@viewPublic');
+
+
+// Администрирование
+$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
+ 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).
+// ./assets/index.php
+
+// ./assets/css/index.php
+
+// ./assets/css/style.css
+/* ===== БАЗОВЫЕ СТИЛИ ===== */
+/* Восстанавливаем центрирование контейнера */
+main.container {
+ margin: 1rem auto;
+ padding: 1rem 0;
+ max-width: 100%;
+}
+
+/* Центрируем основной контент */
+.container {
+ width: 60%;
+ margin-right: 10rem;
+ margin-left: 10rem;
+}
+
+/* Для больших экранов - ограничиваем ширину */
+@media (min-width: 768px) {
+ .container {
+ max-width: 1200px;
+ padding: 0 1rem;
+ }
+}
+
+/* ===== КОМПОНЕНТЫ ===== */
+/* Уведомления */
+.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;
+}
+
+.alert-info {
+ background: #d1ecf1;
+ color: #0c5460;
+ border: 1px solid #bee5eb;
+}
+
+.alert-warning {
+ background: #fff3cd;
+ color: #856404;
+ border: 1px solid #ffeaa7;
+}
+
+/* Кнопки */
+.button-group {
+ display: flex;
+ gap: 5px;
+ margin-bottom: 1rem;
+}
+
+.button-group button,
+.button-group a[role="button"] {
+ flex: 1;
+ padding: 0.5rem;
+ height: 38px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-decoration: none;
+ box-sizing: border-box;
+}
+
+.compact-button {
+ padding: 3px 8px;
+ font-size: 0.85rem;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ cursor: pointer;
+ height: 28px;
+ box-sizing: border-box;
+ line-height: 1;
+}
+
+.action-button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.75rem 1.5rem;
+ font-size: 0.9rem;
+ text-decoration: none;
+ border-radius: 4px;
+ cursor: pointer;
+ height: 44px;
+ min-width: 140px;
+ white-space: nowrap;
+ transition: opacity 0.3s ease;
+ text-align: center;
+}
+
+/* Цвета кнопок */
+.button-group .delete-btn,
+.action-button.delete {
+ background: #ff4444;
+ border-color: #ff4444;
+ color: white;
+}
+
+.button-group .delete-btn:hover,
+.action-button.delete:hover {
+ background: #dd3333;
+ border-color: #dd3333;
+}
+
+.green-btn {
+ background: #449944;
+ border-color: #449944;
+ color: white;
+}
+
+.green-btn:hover {
+ background: #44bb44;
+ border-color: #44bb44;
+}
+
+.primary-btn {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: var(--primary-inverse);
+}
+
+.secondary-btn {
+ background: var(--secondary);
+ border-color: var(--secondary);
+ color: var(--secondary-inverse);
+}
+
+/* ===== КНИГИ И КОНТЕНТ ===== */
+.book-content {
+ line-height: 1.7;
+ font-family: Georgia, serif;
+ max-width: 100%;
+}
+
+.book-content h1 {
+ font-size: 2em;
+ margin: 2rem 0 1rem;
+ border-bottom: 2px solid #eee;
+ padding-bottom: 0.5rem;
+}
+
+.book-content h2 {
+ font-size: 1.6em;
+ margin: 1.5rem 0 1rem;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 0.3rem;
+}
+
+.book-content h3 {
+ font-size: 1.3em;
+ margin: 1.2rem 0 0.8rem;
+}
+
+.book-content p {
+ margin-bottom: 1rem;
+ text-align: justify;
+}
+
+.book-content blockquote {
+ border-left: 4px solid #007bff;
+ padding-left: 1.5rem;
+ margin: 1rem 0;
+ color: #555;
+ font-style: italic;
+ background: #f8f9fa;
+ padding: 1rem;
+ border-radius: 0 5px 5px 0;
+}
+
+.book-content code {
+ background: #f5f5f5;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 0.9em;
+}
+
+.book-content pre {
+ background: #2d2d2d;
+ color: #f8f8f2;
+ padding: 1rem;
+ border-radius: 5px;
+ overflow-x: auto;
+}
+
+.book-content pre code {
+ background: none;
+ padding: 0;
+}
+
+.book-content ul, .book-content ol {
+ margin-bottom: 1rem;
+ padding-left: 2rem;
+}
+
+.book-content table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 1rem;
+}
+
+.book-content th, .book-content td {
+ border: 1px solid #ddd;
+ padding: 10px 12px;
+}
+
+/* Центрируем таблицы в книжном контенте */
+.book-content table {
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/* ===== МЕДИА ===== */
+.book-cover {
+ transition: transform 0.3s ease;
+ display: block;
+ margin: 0 auto;
+}
+
+.book-cover:hover {
+ transform: scale(1.05);
+}
+
+.cover-placeholder {
+ width: 120px;
+ height: 160px;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 2rem;
+ margin: 0 auto 1rem;
+}
+
+.avatar-container {
+ text-align: center;
+ margin-bottom: 1.5rem;
+}
+
+.avatar {
+ width: 150px;
+ height: 150px;
+ border-radius: 50%;
+ border: 3px solid #007bff;
+ object-fit: cover;
+ display: block;
+ margin: 0 auto;
+}
+
+.avatar-placeholder {
+ width: 150px;
+ height: 150px;
+ border-radius: 50%;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 3rem;
+ margin: 0 auto;
+}
+
+/* Центрируем статистику */
+.author-stats {
+ display: flex;
+ justify-content: center;
+ gap: 2rem;
+ flex-wrap: wrap;
+ margin: 1rem 0;
+}
+
+.stat-item {
+ text-align: center;
+}
+
+/* ===== QUILL РЕДАКТОР ===== */
+.writer-editor-container {
+ margin: 10px 0;
+ width: 100%;
+}
+
+.writer-editor-container .ql-editor {
+ min-height: 400px;
+ font-family: 'Georgia', serif;
+ line-height: 1.6;
+}
+
+/* Переопределение Pico CSS для Quill */
+.writer-editor-container [role="button"] {
+ background: none !important;
+ background-color: transparent !important;
+ border: 1px solid transparent !important;
+ border-radius: 3px !important;
+ color: #444 !important;
+ font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif !important;
+ font-size: 14px !important;
+ font-weight: normal !important;
+ text-align: center !important;
+ text-decoration: none !important;
+ text-transform: none !important;
+ box-shadow: none !important;
+ text-shadow: none !important;
+ transition: none !important;
+ padding: 3px 5px !important;
+ margin: 2px !important;
+ width: 28px !important;
+ height: 24px !important;
+ display: inline-block !important;
+ cursor: pointer !important;
+}
+
+.writer-editor-container [role="button"]:hover {
+ background-color: #f3f3f3 !important;
+ border-color: #ccc !important;
+ color: #444 !important;
+}
+/* ===== DASHBOARD ===== */
+.dashboard-buttons {
+ display: flex;
+ gap: 10px;
+ margin-top: 1rem;
+ justify-content: center;
+}
+
+.dashboard-button {
+ text-align: center;
+ padding: 0.75rem 0.5rem;
+ text-decoration: none;
+ border-radius: 4px;
+ font-size: 0.9rem;
+ transition: all 0.3s ease;
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.dashboard-item {
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+ border: 1px solid #e0e0e0;
+ padding: 1rem;
+ text-align: center;
+}
+
+.dashboard-item:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
+}
+
+/* Центрируем welcome сообщение */
+.welcome-message {
+ text-align: center;
+ padding: 3rem;
+ background: #f9f9f9;
+ border-radius: 8px;
+ margin: 2rem auto;
+ max-width: 800px;
+}
+
+.welcome-buttons {
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ flex-wrap: wrap;
+ margin-top: 1.5rem;
+}
+
+/* ===== АДАПТИВНОСТЬ ===== */
+@media (max-width: 768px) {
+ .container {
+ padding: 0 0.5rem;
+ }
+
+ .button-group {
+ flex-direction: column;
+ }
+
+ .dashboard-buttons {
+ flex-direction: column;
+ align-items: center;
+ }
+
+ .book-content {
+ font-size: 16px;
+ padding: 0 0.5rem;
+ }
+
+ .book-content h1 {
+ font-size: 1.6em;
+ }
+
+ .book-content h2 {
+ font-size: 1.4em;
+ }
+
+ .avatar, .avatar-placeholder {
+ width: 120px;
+ height: 120px;
+ }
+
+ .action-button {
+ min-width: 120px;
+ padding: 0.6rem 1rem;
+ }
+
+ .welcome-message {
+ padding: 2rem 1rem;
+ margin: 1rem 0.5rem;
+ }
+}
+
+@media (max-width: 480px) {
+ .book-content h1 {
+ font-size: 1.4em;
+ }
+
+ .avatar, .avatar-placeholder {
+ width: 100px;
+ height: 100px;
+ }
+
+ .action-button {
+ width: 100%;
+ min-width: auto;
+ }
+
+ .author-stats {
+ flex-direction: column;
+ gap: 1rem;
+ }
+}
+
+/* Стили для управления сериями */
+.books-list {
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ background: #fafafa;
+}
+
+.book-item {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ border-bottom: 1px solid #e0e0e0;
+ background: white;
+ transition: all 0.2s ease;
+}
+
+.book-item:last-child {
+ border-bottom: none;
+}
+
+.book-item:hover {
+ background: #f8f9fa;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.book-item.sortable-ghost {
+ opacity: 0.6;
+ background: #e3f2fd;
+}
+
+.book-item.sortable-chosen {
+ background: #e3f2fd;
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
+}
+
+.book-drag-handle {
+ padding: 0 12px;
+ color: #666;
+ font-size: 1.2rem;
+ cursor: move;
+ user-select: none;
+}
+
+.book-drag-handle:hover {
+ color: #007bff;
+}
+
+.book-info {
+ flex: 1;
+ padding: 0 12px;
+}
+
+.book-info strong {
+ display: block;
+ margin-bottom: 4px;
+ color: #333;
+}
+
+.book-info small {
+ color: #666;
+ font-size: 0.8rem;
+}
+
+.book-actions {
+ display: flex;
+ gap: 8px;
+}
+
+/* Адаптивность для мобильных */
+@media (max-width: 768px) {
+ .book-item {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .book-drag-handle {
+ align-self: flex-start;
+ }
+
+ .book-actions {
+ align-self: stretch;
+ justify-content: space-between;
+ }
+
+ .book-actions .compact-button {
+ flex: 1;
+ text-align: center;
+ }
+}
+
+.series-grid {
+ display: grid;
+ gap: 1.5rem;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+}
+
+.series-card {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1.5rem;
+ transition: all 0.3s ease;
+ position: relative;
+ overflow: hidden;
+}
+
+.series-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 25px rgba(0,0,0,0.1);
+ border-color: #007bff;
+}
+
+.series-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.series-header {
+ margin-bottom: 1rem;
+}
+
+.series-title {
+ font-size: 1.3rem;
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+ color: #333;
+}
+
+.series-title a {
+ text-decoration: none;
+ color: inherit;
+}
+
+.series-title a:hover {
+ color: #007bff;
+}
+
+.series-meta {
+ color: #666;
+ font-size: 0.9rem;
+}
+
+.series-description {
+ color: #555;
+ line-height: 1.5;
+ margin-bottom: 1.5rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.series-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ text-align: center;
+}
+
+.series-stat {
+ padding: 0.5rem;
+}
+
+.series-stat-number {
+ font-size: 1.4rem;
+ font-weight: bold;
+ color: #6f42c1;
+ display: block;
+}
+
+.series-stat-label {
+ font-size: 0.8rem;
+ color: #666;
+ display: block;
+}
+
+.series-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+/* Пустое состояние */
+.series-empty-state {
+ text-align: center;
+ padding: 3rem 2rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+ border: 2px dashed #dee2e6;
+}
+
+.series-empty-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+ opacity: 0.5;
+}
+
+/* Адаптивность для серий */
+@media (max-width: 768px) {
+ .series-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .series-stats-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .series-actions {
+ flex-direction: column;
+ }
+
+ .series-actions .compact-button {
+ width: 100%;
+ text-align: center;
+ }
+}
+
+@media (max-width: 480px) {
+ .series-card {
+ padding: 1rem;
+ }
+
+ .series-stats-grid {
+ grid-template-columns: 1fr;
+ gap: 0.25rem;
+ }
+
+ .series-stat {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.25rem 0;
+ }
+
+ .series-stat-number {
+ font-size: 1.1rem;
+ }
+}
+// ./assets/js/autosave.js
+// assets/js/autosave.js
+document.addEventListener('DOMContentLoaded', function() {
+ // Ждем инициализации редактора
+ setTimeout(() => {
+ initializeAutoSave();
+ }, 1000);
+});
+
+function initializeAutoSave() {
+ console.log('AutoSave: Initializing...');
+
+ // Ищем активные редакторы Quill
+ const quillEditors = document.querySelectorAll('.ql-editor');
+ const textareas = document.querySelectorAll('textarea.writer-editor');
+
+ if (quillEditors.length === 0 || textareas.length === 0) {
+ console.log('AutoSave: No Quill editors found, retrying in 1s...');
+ setTimeout(initializeAutoSave, 1000);
+ return;
+ }
+
+ console.log(`AutoSave: Found ${quillEditors.length} Quill editor(s)`);
+
+ // Для каждого редактора настраиваем автосейв
+ quillEditors.forEach((quillEditor, index) => {
+ const textarea = textareas[index];
+ if (!textarea) return;
+
+ setupAutoSaveForEditor(quillEditor, textarea, index);
+ });
+}
+
+function setupAutoSaveForEditor(quillEditor, textarea, editorIndex) {
+ let saveTimeout;
+ let isSaving = false;
+ let lastSavedContent = textarea.value;
+ let changeCount = 0;
+
+ // Получаем экземпляр Quill из контейнера
+ const quillContainer = quillEditor.closest('.ql-container');
+ const quillInstance = quillContainer ? Quill.find(quillContainer) : null;
+
+ if (!quillInstance) {
+ console.error(`AutoSave: Could not find Quill instance for editor ${editorIndex}`);
+ return;
+ }
+
+ console.log(`AutoSave: Setting up for editor ${editorIndex}`);
+
+ function showSaveMessage(message) {
+ let messageEl = document.getElementById('autosave-message');
+ if (!messageEl) {
+ messageEl = document.createElement('div');
+ messageEl.id = 'autosave-message';
+ messageEl.style.cssText = `
+ position: fixed;
+ top: 70px;
+ right: 10px;
+ padding: 8px 12px;
+ background: #28a745;
+ color: white;
+ border-radius: 3px;
+ z-index: 10000;
+ font-size: 0.8rem;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ `;
+ document.body.appendChild(messageEl);
+ }
+
+ messageEl.textContent = message;
+ messageEl.style.display = 'block';
+
+ setTimeout(() => {
+ messageEl.style.display = 'none';
+ }, 2000);
+ }
+
+ function showError(message) {
+ let messageEl = document.getElementById('autosave-message');
+ if (!messageEl) {
+ messageEl = document.createElement('div');
+ messageEl.id = 'autosave-message';
+ messageEl.style.cssText = `
+ position: fixed;
+ top: 70px;
+ right: 10px;
+ padding: 8px 12px;
+ background: #dc3545;
+ color: white;
+ border-radius: 3px;
+ z-index: 10000;
+ font-size: 0.8rem;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ `;
+ document.body.appendChild(messageEl);
+ }
+
+ messageEl.textContent = message;
+ messageEl.style.background = '#dc3545';
+ messageEl.style.display = 'block';
+
+ setTimeout(() => {
+ messageEl.style.display = 'none';
+ messageEl.style.background = '#28a745';
+ }, 3000);
+ }
+
+ function autoSave() {
+ if (isSaving) {
+ console.log('AutoSave: Already saving, skipping...');
+ return;
+ }
+
+ const currentContent = textarea.value;
+
+ // Проверяем, изменилось ли содержимое
+ if (currentContent === lastSavedContent) {
+ console.log('AutoSave: No changes detected');
+ return;
+ }
+
+ changeCount++;
+ console.log(`AutoSave: Changes detected (${changeCount}), saving...`);
+
+ isSaving = true;
+
+ // Показываем индикатор сохранения
+ showSaveMessage('Сохранение...');
+
+ const formData = new FormData();
+ formData.append('content', currentContent);
+
+ // Добавляем title если есть
+ const titleInput = document.querySelector('input[name="title"]');
+ if (titleInput) {
+ formData.append('title', titleInput.value);
+ }
+
+ // Добавляем status если есть
+ const statusSelect = document.querySelector('select[name="status"]');
+ if (statusSelect) {
+ formData.append('status', statusSelect.value);
+ }
+
+ formData.append('autosave', 'true');
+ formData.append('csrf_token', document.querySelector('input[name="csrf_token"]')?.value || '');
+
+ const currentUrl = window.location.href;
+
+ fetch(currentUrl, {
+ method: 'POST',
+ body: formData
+ })
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return response.json();
+ })
+ .then(data => {
+ if (data.success) {
+ lastSavedContent = currentContent;
+ showSaveMessage('Автосохранено: ' + new Date().toLocaleTimeString());
+ console.log('AutoSave: Successfully saved');
+ } else {
+ throw new Error(data.error || 'Unknown error');
+ }
+ })
+ .catch(error => {
+ console.error('AutoSave Error:', error);
+ showError('Ошибка автосохранения: ' + error.message);
+ })
+ .finally(() => {
+ isSaving = false;
+ });
+ }
+
+ // Слушаем изменения в Quill редакторе
+ quillInstance.on('text-change', function(delta, oldDelta, source) {
+ if (source === 'user') {
+ console.log('AutoSave: Text changed by user');
+ clearTimeout(saveTimeout);
+ saveTimeout = setTimeout(autoSave, 2000); // Сохраняем через 2 секунды после изменения
+ }
+ });
+
+ // Также слушаем изменения в title и status
+ const titleInput = document.querySelector('input[name="title"]');
+ if (titleInput) {
+ titleInput.addEventListener('input', function() {
+ clearTimeout(saveTimeout);
+ saveTimeout = setTimeout(autoSave, 2000);
+ });
+ }
+
+ const statusSelect = document.querySelector('select[name="status"]');
+ if (statusSelect) {
+ statusSelect.addEventListener('change', function() {
+ clearTimeout(saveTimeout);
+ saveTimeout = setTimeout(autoSave, 1000);
+ });
+ }
+
+ // Предупреждение при закрытии страницы с несохраненными изменениями
+ window.addEventListener('beforeunload', function(e) {
+ if (textarea.value !== lastSavedContent && !isSaving) {
+ e.preventDefault();
+ e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?';
+ return e.returnValue;
+ }
+ });
+
+ // Периодическое сохранение каждые 30 секунд (на всякий случай)
+ setInterval(() => {
+ if (textarea.value !== lastSavedContent && !isSaving) {
+ console.log('AutoSave: Periodic save triggered');
+ autoSave();
+ }
+ }, 30000);
+
+ console.log(`AutoSave: Successfully set up for editor ${editorIndex}`);
+}
+// ./assets/js/index.php
+
+// ./assets/js/editor.js
+// assets/js/editor.js
+class WriterEditor {
+ constructor() {
+ this.editors = [];
+ this.init();
+ }
+
+ init() {
+ // Инициализируем редакторы для текстовых областей с классом .writer-editor
+ document.querySelectorAll('textarea.writer-editor').forEach(textarea => {
+ this.initEditor(textarea);
+ });
+ }
+
+ initEditor(textarea) {
+ // Создаем контейнер для Quill
+ const editorContainer = document.createElement('div');
+ editorContainer.className = 'writer-editor-container';
+ editorContainer.style.height = '500px';
+ editorContainer.style.marginBottom = '20px';
+
+ // Вставляем контейнер перед textarea
+ textarea.parentNode.insertBefore(editorContainer, textarea);
+
+ // Скрываем оригинальный textarea
+ textarea.style.display = 'none';
+
+ // Настройки Quill
+ const quill = new Quill(editorContainer, {
+ theme: 'snow',
+ modules: {
+ toolbar: [
+ [{ 'header': [1, 2, 3, false] }],
+ ['bold', 'italic', 'underline', 'strike'],
+ ['blockquote', 'code-block'],
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }],
+ [{ 'script': 'sub'}, { 'script': 'super' }],
+ [{ 'indent': '-1'}, { 'indent': '+1' }],
+ [{ 'direction': 'rtl' }],
+ [{ 'size': ['small', false, 'large', 'huge'] }],
+ [{ 'color': [] }, { 'background': [] }],
+ [{ 'font': [] }],
+ [{ 'align': [] }],
+ ['link', 'image', 'video'],
+ ['clean']
+ ],
+ history: {
+ delay: 1000,
+ maxStack: 100,
+ userOnly: true
+ }
+ },
+ placeholder: 'Начните писать вашу главу...',
+ formats: [
+ 'header', 'bold', 'italic', 'underline', 'strike',
+ 'blockquote', 'code-block', 'list', 'bullet',
+ 'script', 'indent', 'direction', 'size',
+ 'color', 'background', 'font', 'align',
+ 'link', 'image', 'video'
+ ]
+ });
+
+ // Устанавливаем начальное содержимое
+ if (textarea.value) {
+ quill.root.innerHTML = textarea.value;
+ }
+
+ // Обновляем textarea при изменении содержимого
+ quill.on('text-change', () => {
+ textarea.value = quill.root.innerHTML;
+ });
+
+ // Сохраняем ссылку на редактор
+ this.editors.push({
+ quill: quill,
+ textarea: textarea
+ });
+
+ return quill;
+ }
+
+ // Метод для получения HTML содержимого
+ getContent(editorIndex = 0) {
+ if (this.editors[editorIndex]) {
+ return this.editors[editorIndex].quill.root.innerHTML;
+ }
+ return '';
+ }
+
+ // Метод для установки содержимого
+ setContent(content, editorIndex = 0) {
+ if (this.editors[editorIndex]) {
+ this.editors[editorIndex].quill.root.innerHTML = content;
+ }
+ }
+}
+
+
+// Инициализация редактора при загрузке страницы
+document.addEventListener('DOMContentLoaded', function() {
+ window.writerEditor = new WriterEditor();
+});
+// ./config/index.php
+
+// ./config/config.php
+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
+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 <<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;
+}
+?>
+
+
+
+
+
+ Установка Web Writer
+
+
+
+
+
+
+
Установка Web Writer
+
+
+
+
1. База данных
+
2. Администратор
+
3. Завершение
+
+
+
+
+ = htmlspecialchars($error) ?>
+
+
+
+
+
+ = htmlspecialchars($success) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Перед установкой убедитесь, что:
+
+ - Сервер MySQL запущен и доступен
+ - У вас есть данные для подключения к БД (хост, пользователь, пароль)
+ - Папка
config/ доступна для записи
+ - Папка
uploads/ доступна для записи
+
+
+
+
+
+
+
+// ./views/layouts/header.php
+
+
+
+
+
+
+ = e($page_title ?? 'Web Writer') ?>
+
+
+
+
+
+
+
+
+
+
+
+ = e($_SESSION['success']) ?>
+
+
+
+
+
+
+ = e($_SESSION['error']) ?>
+
+
+
+
+
+
+ = e($_SESSION['warning']) ?>
+
+
+
+
+
+
+ = e($_SESSION['info']) ?>
+
+
+
+// ./views/layouts/footer.php
+
+
+
+
+
+
+
+
+// ./views/dashboard/index.php
+
+
+Панель управления
+
+
+
+ 📚 Книги
+
+ = $total_books ?>
+
+ Всего книг
+
+
+
+ 📑 Главы
+
+ = $total_chapters ?>
+
+ Всего глав
+
+
+
+ 📝 Слова
+
+ = number_format($total_words) ?>
+
+ Всего слов
+
+
+
+ 🌐 Публикации
+
+ = $published_books_count ?>
+
+ Опубликовано книг
+
+
+
+
+
+
Недавние книги
+
+
+
+
+
+ = e($book['genre']) ?>
+
+
+
+
+
+
+
+
+
+
+ У вас пока нет книг.
+ Создать первую книгу
+
+
+
+
+
+
Мои серии
+
+
+
+
+
+ = e(mb_strimwidth($ser['description'], 0, 100, '...')) ?>
+
+
+
+
+
+
+
+
+ У вас пока нет серий.
+ Создать первую серию
+
+
+
+
Быстрые действия
+
+
+
+
+
+// ./views/auth/login.php
+
+
+
+
Вход в систему
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
+
+
+
+// ./views/auth/register.php
+
+
+
+
Регистрация
+
+
+
+ = e($error) ?>
+
+
+
+
+
+ = e($success) ?>
+
+
+
+
+
+
+
+
+
+// ./views/chapters/preview.php
+
+
+
+
+
+ = e($page_title) ?>
+
+
+
+
+
+
+
+ = $content ?>
+
+
+
+
+// ./views/chapters/index.php
+
+
+
+
Главы книги: = e($book['title']) ?>
+
+
+
+
+
+
+
+
+
+
+ | № |
+ Название главы |
+ Статус |
+ Слов |
+ Обновлено |
+ Действия |
+
+
+
+ $chapter): ?>
+
+ | = $index + 1 ?> |
+
+ = e($chapter['title']) ?>
+
+ = e(mb_strimwidth($chapter['description'], 0, 100, '...')) ?>
+
+ |
+
+
+ = $chapter['status'] == 'published' ? '✅ Опубликована' : '📝 Черновик' ?>
+
+ |
+ = $chapter['word_count'] ?> |
+
+ = date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?>
+ |
+
+
+ |
+
+
+
+
+
+
+
+ Статистика:
+ Всего глав: = count($chapters) ?> |
+ Всего слов: = array_sum(array_column($chapters, 'word_count')) ?> |
+ Опубликовано: = count(array_filter($chapters, function($ch) { return $ch['status'] == 'published'; })) ?>
+
+
+
+
+// ./views/chapters/create.php
+
+
+Новая глава для: = e($book['title']) ?>
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
+
+
+
+// ./views/chapters/edit.php
+
+
+Редактирование главы: = e($chapter['title']) ?>
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
+
Информация о главе
+
Книга: = e($book['title']) ?>
+
Количество слов: = $chapter['word_count'] ?>
+
Создана: = date('d.m.Y H:i', strtotime($chapter['created_at'])) ?>
+
Обновлена: = date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?>
+
+
+
+
+
+
+// ./views/series/index.php
+
+
+
+
+
+
+ 📚
+ Пока нет серий
+
+ Создайте свою первую серию, чтобы организовать книги в циклы и сериалы.
+
+
+
+
+
+
+
+
+
+
+
+ = e($ser['description']) ?>
+
+
+
+
+
+ = $ser['book_count'] ?? 0 ?>
+ книг
+
+
+ = number_format($ser['total_words'] ?? 0) ?>
+ слов
+
+
+
+ 0 ? round($ser['total_words'] / $ser['book_count']) : 0;
+ echo number_format($avg_words);
+ ?>
+
+ слов/книга
+
+
+
+
+
+
+
+
+
+
+// ./views/series/create.php
+
+
+Создание новой серии
+
+
+
+ = e($error) ?>
+
+
+
+
+
+
+
Что такое серия?
+
Серия позволяет объединить несколько книг в одну тематическую коллекцию. Это полезно для:
+
+ - Циклов книг с общим сюжетом
+ - Книг в одном мире или вселенной
+ - Организации книг по темам или жанрам
+
+
Вы сможете добавить книги в серию после её создания.
+
+
+
+// ./views/series/edit.php
+
+
+Редактирование серии: = e($series['title']) ?>
+
+
+
+
+
+
+ Книги в серии (= count($books_in_series) ?>)
+
+
+
+
+
+
+ В этой серии пока нет книг. Добавьте книги с помощью формы слева.
+
+
+
+
+ Статистика серии
+
+
Количество книг: = count($books_in_series) ?>
+ getBookStats($book['id']);
+ $total_words += $stats['total_words'] ?? 0;
+ $total_chapters += $stats['chapter_count'] ?? 0;
+ }
+ ?>
+
Всего глав: = $total_chapters ?>
+
Всего слов: = $total_words ?>
+
+
+
+
+
+
+
+
+
+
+
+// ./views/series/view_public.php
+
+
+
+
+
+
+
+
+
В этой серии пока нет опубликованных книг
+
Автор еще не опубликовал книги из этой серии
+
+
+
+
Книги серии
+
+
+
+
+
+
![<?= e($book['title']) ?>](<?= COVERS_URL . e($book['cover_image']) ?>)
+
+
+
+
+
+
+
+
+ Книга = $book['sort_order_in_series'] ?>
+
+ = e($book['title']) ?>
+
+
+
+
= e($book['genre']) ?>
+
+
+
+
= nl2br(e($book['description'])) ?>
+
+
+
+
+ Читать
+
+
+ getBookStats($book['id'], true);
+ ?>
+
+
+ Глав: = $book_stats['chapter_count'] ?? 0 ?> | Слов: = $book_stats['total_words'] ?? 0 ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// ./views/errors/404.php
+
+
+
+
404 - Страница не найдена
+
+ Запрашиваемая страница не существует или была перемещена.
+
+
+
+
+
+// ./views/admin/add_user.php
+
+
+
+
+
+// ./views/admin/users.php
+
+
+
+
Управление пользователями
+
+
+
+ = e($_SESSION['success']) ?>
+
+
+
+
+
+
+ = e($_SESSION['error']) ?>
+
+
+
+
+
+
+
+
+ Пользователи не найдены
+ Зарегистрируйте первого пользователя
+ 📝 Добавить пользователя
+
+
+
+
+
+
+ | ID |
+ Имя пользователя |
+ Отображаемое имя |
+ Email |
+ Дата регистрации |
+ Статус |
+ Действия |
+
+
+
+
+
+ | = $user['id'] ?> |
+
+ = e($user['username']) ?>
+
+ (Вы)
+
+ |
+ = e($user['display_name']) ?> |
+ = e($user['email']) ?> |
+
+ = date('d.m.Y H:i', strtotime($user['created_at'])) ?>
+
+ Вход: = date('d.m.Y H:i', strtotime($user['last_login'])) ?>
+
+ |
+
+
+ = $user['is_active'] ? '✅ Активен' : '❌ Неактивен' ?>
+
+ |
+
+
+
+
+
+
+
+ Текущий пользователь
+
+ |
+
+
+
+
+
+
+
+
+
+// ./views/user/profile.php
+
+
+Мой профиль
+
+
+
+ = e($message) ?>
+
+
+
+
+
+ Основная информация
+
+
+
+
+ Аватарка
+
+
+
+
 ?>)
+
+
+ = mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
+
+
+
+
+
+
+
+
+
+ Примечание: Аватарка отображается на вашей публичной странице автора
+
+
+
+
+
+
+
+ Информация об аккаунте
+
+ 👁️ Посмотреть мою публичную страницу
+
+ Дата регистрации: = date('d.m.Y H:i', strtotime($user['created_at'])) ?>
+
+ Последний вход: = date('d.m.Y H:i', strtotime($user['last_login'])) ?>
+
+
+
+
+// ./views/user/view_public.php
+
+
+
+
+
+
+ Публикации автора
+
+
+
+
У этого автора пока нет опубликованных книг
+
Следите за обновлениями, скоро здесь появятся новые произведения!
+
+
+
+
+
+
+
+
![<?= e($book['title']) ?>](<?= COVERS_URL . e($book['cover_image']) ?>)
+
+
+
+
+
+
+
= e($book['title']) ?>
+
+
+
= e($book['genre']) ?>
+
+
+
+
= nl2br(e($book['description'])) ?>
+
+
+ getBookStats($book['id'], true);
+ $chapter_count = $book_stats['chapter_count'] ?? 0;
+ $word_count = $book_stats['total_words'] ?? 0;
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// ./views/books/index.php
+
+
+Мои книги
+
+Всего книг: = count($books) ?>
+
+
+
+
+ У вас пока нет книг
+ Создайте свою первую книгу и начните писать!
+ 📖 Создать первую книгу
+
+
+
+
+
+
+
+ = e($book['title']) ?>
+
+
+
+ = e($book['genre']) ?>
+
+
+
+
+ = e(mb_strimwidth($book['description'], 0, 200, '...')) ?>
+
+
+
+
+
+
+
+
+
+
+
+// ./views/books/create.php
+
+Создание новой книги
+
+
+// ./views/books/edit.php
+
+Редактирование книги
+
+
+
+
+
Публичная ссылка для чтения
+
+
+
+
+
+
+ Примечание: В публичном просмотре отображаются только главы со статусом "Опубликована"
+
+
+
+
Экспорт книги
+
Экспортируйте книгу в различные форматы:
+
+
+ Примечание: Экспортируются все главы книги (включая черновики)
+
+
+
+
Главы этой книги
+
+
+
+
+
+
+ | Название |
+ Статус |
+ Слов |
+ Действия |
+
+
+
+
+
+ | = e($chapter['title']) ?> |
+
+
+ = $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
+
+ |
+ = $chapter['word_count'] ?> |
+
+
+ Редактировать
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+// ./views/books/view_public.php
+
+
+
+
+
+
+
+
![<?= e($book['title']) ?>](<?= COVERS_URL . e($book['cover_image']) ?>)
+
+
+
+ = e($book['title']) ?>
+
+
+ Автор: = e($author_name) ?>
+
+
+
+
+ = e($book['genre']) ?>
+
+
+
+
+
+ = nl2br(e($book['description'])) ?>
+
+
+
+
+
Глав: = count($chapters) ?>
+
Слов: = array_sum(array_column($chapters, 'word_count')) ?>
+
+ 📄 Скачать книгу
+
+
+
+
+
+
+
В этой книге пока нет глав
+
Автор еще не опубликовал содержание книги
+
+
+ Оглавление
+
+
+ $chapter): ?>
+
+
+
+
+ Слов: = $chapter['word_count'] ?>
+ | Обновлено: = date('d.m.Y', strtotime($chapter['updated_at'])) ?>
+
+
+
+
+
+
+
+
+ $chapter): ?>
+
+
+ Глава = $index + 1 ?>: = e($chapter['title']) ?>
+
+
+
+
+ = $Parsedown->text($chapter['content']) ?>
+
+ = $chapter['content'] ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/README.md b/README.md
old mode 100644
new mode 100755
diff --git a/assets/css/index.php b/assets/css/index.php
old mode 100644
new mode 100755
diff --git a/assets/css/quill_reset.css b/assets/css/quill_reset.css
new file mode 100644
index 0000000..8a72cb5
--- /dev/null
+++ b/assets/css/quill_reset.css
@@ -0,0 +1,41 @@
+/* Увеличиваем специфичность для кнопок Quill */
+.ql-toolbar .ql-picker-label,
+.ql-toolbar button,
+.ql-toolbar [role="button"] {
+ all: unset; /* Сбрасываем все стили Pico (background, border, padding и т.д.) */
+ display: inline-block; /* Восстанавливаем базовые стили Quill */
+ cursor: pointer;
+ padding: 0; /* Quill кнопки не имеют padding */
+ margin: 0;
+ border: none;
+ background: none;
+ color: inherit; /* Наследуем цвет от Quill */
+ font-size: inherit;
+ line-height: inherit;
+ text-decoration: none; /* Убираем подчёркивание, если это */
+}
+
+/* Восстанавливаем hover/active стили Quill (если они сломались) */
+.ql-toolbar button:hover,
+.ql-toolbar [role="button"]:hover {
+ color: #06c; /* Пример из Quill snow theme; адаптируйте */
+ background: none; /* Без фона */
+}
+
+.ql-toolbar button.ql-active,
+.ql-toolbar [role="button"].ql-active {
+ color: #06c;
+ background: none;
+}
+
+/* Для иконок (SVG в Quill) */
+.ql-toolbar .ql-icon {
+ fill: currentColor; /* Убедимся, что иконки наследуют цвет */
+}
+
+/* Если Quill использует для кнопок */
+.ql-toolbar .ql-picker-item,
+.ql-toolbar .ql-picker-options [role="button"] {
+ all: unset;
+ cursor: pointer;
+}
\ No newline at end of file
diff --git a/assets/css/style.css b/assets/css/style.css
index ff8b770..1aa819d 100755
--- a/assets/css/style.css
+++ b/assets/css/style.css
@@ -1,38 +1,54 @@
-/* Базовые стили */
+/* ===== БАЗОВЫЕ СТИЛИ ===== */
h1, h2, h3, h4, h5, h6 {
- margin-bottom: 1rem;
+ margin-top: 0;
+ margin-bottom: 1rem;
+ color: var(--color);
+ font-weight: var(--font-weight);
+ font-size: var(--font-size);
+ font-family: var(--font-family);
+}
+article{
+ margin-top: 0em;
+}
+article > header {
+ margin-top: calc(var(--block-spacing-vertical) * -1);
+ margin-bottom: calc(var(--block-spacing-vertical)-1);
+ border-bottom: var(--border-width) solid var(--card-border-color);
+ border-top-right-radius: var(--border-radius);
+ border-top-left-radius: var(--border-radius);
+ padding-bottom: 0.5em;
+}
+article > footer {
+ margin-top: calc(var(--block-spacing-vertical)-1);
+ margin-bottom: calc(var(--block-spacing-vertical) * -1);
+ border-top: var(--border-width) solid var(--card-border-color);
+ border-bottom-right-radius: var(--border-radius);
+ border-bottom-left-radius: var(--border-radius);
+ padding-top: 0.5em;
+}
+/* Центрирование контейнера */
+main.container {
+ margin: 1rem auto;
+ padding: 1rem 0;
+ max-width: 100%;
}
-article, textarea, main.container {
- margin-top: 1rem;
- margin-bottom: 1rem;
- padding-top: 1rem;
- padding-bottom: 1rem;
+/* Центрируем основной контент */
+.container {
+ width: 60%;
+ margin-right: 10rem;
+ margin-left: 10rem;
}
-article > header, article > footer {
- margin-top: 0.1rem;
- margin-bottom: 0.1rem;
- padding-top: 0.2rem;
- padding-bottom: 0.2rem;
-}
-
-input:not([type="checkbox"], [type="radio"]), select {
- padding: 4px;
- height: 2em;
-}
-
-/* Убираем конфликтующие стили Pico CSS */
-article header, article footer {
- margin: 0;
- padding: 0;
-}
-
-article > header, article > footer {
- margin: 0;
- padding: 0;
+/* Для больших экранов - ограничиваем ширину */
+@media (min-width: 768px) {
+ .container {
+ max-width: 1200px;
+ padding: 0 1rem;
+ }
}
+/* ===== КОМПОНЕНТЫ ===== */
/* Уведомления */
.alert {
padding: 1rem;
@@ -52,29 +68,19 @@ article > header, article > footer {
border: 1px solid #c8e6c9;
}
+.alert-info {
+ background: #d1ecf1;
+ color: #0c5460;
+ border: 1px solid #bee5eb;
+}
+
+.alert-warning {
+ background: #fff3cd;
+ color: #856404;
+ border: 1px solid #ffeaa7;
+}
+
/* Кнопки */
-.compact-button {
- padding: 3px 8px !important;
- font-size: 0.85rem;
- text-decoration: none;
- display: inline-flex !important;
- align-items: center;
- justify-content: center;
- border: 1px solid var(--secondary);
- border-radius: 4px;
- background: var(--secondary);
- color: var(--secondary-inverse);
- cursor: pointer;
- height: 28px !important;
- box-sizing: border-box;
- line-height: 1 !important;
- vertical-align: middle;
-}
-
-.compact-button:hover {
- opacity: 0.9;
-}
-
.button-group {
display: flex;
gap: 5px;
@@ -93,88 +99,18 @@ article > header, article > footer {
box-sizing: border-box;
}
-.button-group .delete-btn {
- background: #ff4444 !important;
- border-color: #ff4444 !important;
- color: white !important;
-}
-
-.green-btn {
- background: #449944 !important;
- border-color: #449944 !important;
- color: white !important;
-}
-
-.green-btn:hover {
- background: #44bb44 !important;
- border-color: #44bb44 !important;
- color: white !important;
-}
-
-.profile-buttons {
- display: flex;
- gap: 10px;
- align-items: stretch;
-}
-
-.profile-button {
- flex: 1;
- padding: 0.75rem;
- display: flex;
- align-items: center;
- justify-content: center;
- text-decoration: none;
- border: 1px solid;
- border-radius: 4px;
- font-size: 1rem;
- cursor: pointer;
- box-sizing: border-box;
- height: 62px;
- min-height: 44px;
-}
-
-.profile-button.primary {
- background: var(--primary);
- border-color: var(--primary);
- color: var(--primary-inverse);
-}
-
-.profile-button.secondary {
- background: var(--secondary);
- border-color: var(--secondary);
- color: var(--secondary-inverse);
-}
-
-.adaptive-button {
- padding: 8px 12px !important;
+.compact-button {
+ padding: 3px 8px;
font-size: 0.85rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
- border: 1px solid var(--secondary);
border-radius: 4px;
- background: var(--secondary);
- color: var(--secondary-inverse);
cursor: pointer;
+ height: 28px;
box-sizing: border-box;
- text-align: center;
- white-space: normal;
- word-break: break-word;
- min-height: 44px;
- flex: 1;
- min-width: 120px;
- margin: 2px;
-}
-
-.adaptive-button:hover {
- opacity: 0.9;
-}
-
-.primary.adaptive-button {
- background: var(--primary);
- border-color: var(--primary);
- color: var(--primary-inverse);
+ line-height: 1;
}
.action-button {
@@ -184,200 +120,76 @@ article > header, article > footer {
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
text-decoration: none;
- border: 1px solid;
border-radius: 4px;
cursor: pointer;
- box-sizing: border-box;
height: 44px;
min-width: 140px;
white-space: nowrap;
- transition: all 0.3s ease;
+ transition: opacity 0.3s ease;
text-align: center;
}
-.action-button.primary {
- background: #007bff;
- border-color: #007bff;
- color: #fff;
-}
-
-.action-button.primary:hover {
- opacity: 0.9;
-}
-
+/* Цвета кнопок */
+.button-group .delete-btn,
.action-button.delete {
- margin-top: 1rem;
background: #ff4444;
border-color: #ff4444;
color: white;
}
+.button-group .delete-btn:hover,
.action-button.delete:hover {
background: #dd3333;
border-color: #dd3333;
+}
+
+.green-btn {
+ background: #449944;
+ border-color: #449944;
color: white;
}
-/* Таблицы */
-.compact-table {
- width: 100%;
- font-size: 0.9rem;
- border-collapse: collapse;
+.green-btn:hover {
+ background: #44bb44;
+ border-color: #44bb44;
}
-.compact-table th,
-.compact-table td {
- padding: 6px 8px;
- border-bottom: 1px solid #eee;
+.primary-btn {
+ background: var(--primary);
+ border-color: var(--primary);
+ color: var(--primary-inverse);
}
-.compact-table th {
- background: #f5f5f5;
- font-weight: bold;
+.secondary-btn {
+ background: var(--secondary);
+ border-color: var(--secondary);
+ color: var(--secondary-inverse);
}
-/* Markdown редактор */
-#content {
- transition: all 0.3s ease;
- border: 1px solid #ddd;
- border-radius: 4px;
- padding: 12px;
- font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
- font-size: 14px;
- line-height: 1.5;
- min-height: 400px;
- color: #111;
- background-color: #fff;
- resize: vertical;
- /* Добавляем эти свойства для правильного отображения переносов */
- white-space: pre-wrap; /* Сохраняет пробелы и переносы строк, переносит текст */
- word-wrap: break-word; /* Переносит длинные слова */
- overflow-wrap: break-word; /* Альтернативное название word-wrap */
- tab-size: 4; /* Размер табуляции */
-}
-
-#content:focus {
- border-color: #007bff;
- box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
- outline: none;
- color: #111;
- background-color: #fff;
-}
-
-/* Кастомный скроллбар для редактора */
-#content::-webkit-scrollbar {
- width: 8px;
-}
-
-#content::-webkit-scrollbar-track {
- background: #f1f1f1;
- border-radius: 4px;
-}
-
-#content::-webkit-scrollbar-thumb {
- background: #c1c1c1;
- border-radius: 4px;
-}
-
-#content::-webkit-scrollbar-thumb:hover {
- background: #a8a8a8;
-}
-
-#content {
- scrollbar-width: thin;
- scrollbar-color: #c1c1c1 #f1f1f1;
-}
-
-/* Элементы управления редактором */
-.editor-controls {
- position: sticky !important;
- top: 10px !important;
- right: 10px !important;
- z-index: 100 !important;
- display: flex;
- gap: 5px;
- justify-content: flex-end;
- margin-bottom: 10px;
-}
-
-.editor-controls button {
- width: 40px !important;
- height: 40px !important;
- border-radius: 50% !important;
- border: 1px solid #ddd !important;
- background: white !important;
- cursor: pointer !important;
- font-size: 16px !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important;
- transition: all 0.3s ease !important;
- color: #333333 !important;
-}
-
-.editor-controls button:hover {
- transform: scale(1.1) !important;
- background-color: #f8f9fa !important;
- box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important;
-}
-
-/* Полноэкранный режим редактора */
-#fullscreen-controls {
- position: fixed !important;
- z-index: 9999 !important;
- display: flex !important;
- gap: 5px !important;
-}
-
-#fullscreen-controls button {
- width: 45px !important;
- height: 45px !important;
- border-radius: 50% !important;
- border: 1px solid #ddd !important;
- background: white !important;
- cursor: pointer !important;
- font-size: 18px !important;
- display: flex !important;
- align-items: center !important;
- justify-content: center !important;
- box-shadow: 0 2px 5px rgba(0,0,0,0.3) !important;
- transition: all 0.3s ease !important;
- color: #333333 !important;
-}
-
-#fullscreen-controls button:hover {
- transform: scale(1.1) !important;
- background-color: #f8f9fa !important;
- box-shadow: 0 4px 8px rgba(0,0,0,0.4) !important;
-}
-
-/* Стили для отображения контента книг */
+/* ===== КНИГИ И КОНТЕНТ ===== */
.book-content {
line-height: 1.7;
font-family: Georgia, serif;
+ max-width: 100%;
}
.book-content h1 {
font-size: 2em;
- margin-top: 2rem;
- margin-bottom: 1rem;
+ margin: 2rem 0 1rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.book-content h2 {
font-size: 1.6em;
- margin-top: 1.5rem;
- margin-bottom: 1rem;
+ margin: 1.5rem 0 1rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.3rem;
}
.book-content h3 {
font-size: 1.3em;
- margin-top: 1.2rem;
- margin-bottom: 0.8rem;
+ margin: 1.2rem 0 0.8rem;
}
.book-content p {
@@ -388,8 +200,7 @@ article > header, article > footer {
.book-content blockquote {
border-left: 4px solid #007bff;
padding-left: 1.5rem;
- margin-left: 0;
- margin-right: 0;
+ margin: 1rem 0;
color: #555;
font-style: italic;
background: #f8f9fa;
@@ -411,13 +222,11 @@ article > header, article > footer {
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
- border-left: 4px solid #007bff;
}
.book-content pre code {
background: none;
padding: 0;
- color: inherit;
}
.book-content ul, .book-content ol {
@@ -425,41 +234,28 @@ article > header, article > footer {
padding-left: 2rem;
}
-.book-content li {
- margin-bottom: 0.3rem;
-}
-
.book-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
- box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.book-content th, .book-content td {
border: 1px solid #ddd;
padding: 10px 12px;
- text-align: left;
}
-.book-content th {
- background: #f5f5f5;
- font-weight: bold;
+/* Центрируем таблицы в книжном контенте */
+.book-content table {
+ margin-left: auto;
+ margin-right: auto;
}
-.book-content tr:nth-child(even) {
- background: #f9f9f9;
-}
-
-.dialogue {
- margin-left: 2rem;
- font-style: italic;
- color: #2c5aa0;
-}
-
-/* Обложки и медиа */
+/* ===== МЕДИА ===== */
.book-cover {
transition: transform 0.3s ease;
+ display: block;
+ margin: 0 auto;
}
.book-cover:hover {
@@ -479,7 +275,6 @@ article > header, article > footer {
margin: 0 auto 1rem;
}
-/* Аватарки и профиль */
.avatar-container {
text-align: center;
margin-bottom: 1.5rem;
@@ -491,6 +286,8 @@ article > header, article > footer {
border-radius: 50%;
border: 3px solid #007bff;
object-fit: cover;
+ display: block;
+ margin: 0 auto;
}
.avatar-placeholder {
@@ -506,37 +303,7 @@ article > header, article > footer {
margin: 0 auto;
}
-.author-bio {
- background: #f8f9fa;
- padding: 1.5rem;
- border-radius: 8px;
- margin: 1rem 0;
- line-height: 1.6;
-}
-
-.author-bio h1, .author-bio h2, .author-bio h3 {
- margin-top: 1rem;
- margin-bottom: 0.5rem;
-}
-
-.author-bio p {
- margin-bottom: 1rem;
-}
-
-.author-bio ul, .author-bio ol {
- margin-bottom: 1rem;
- padding-left: 2rem;
-}
-
-.author-bio blockquote {
- border-left: 4px solid #007bff;
- padding-left: 1rem;
- margin-left: 0;
- color: #555;
- font-style: italic;
-}
-
-/* Статистика */
+/* Центрируем статистику */
.author-stats {
display: flex;
justify-content: center;
@@ -549,121 +316,45 @@ article > header, article > footer {
text-align: center;
}
-.stat-number {
- font-size: 1.5em;
- font-weight: bold;
- color: #007bff;
+/* ===== QUILL РЕДАКТОР ===== */
+.writer-editor-container {
+ margin: 10px 0;
+ width: 100%;
}
-.stat-label {
- font-size: 0.9em;
- color: #666;
+.writer-editor-container .ql-editor {
+ min-height: 400px;
+ font-family: 'Georgia', serif;
+ line-height: 1.6;
}
-/* Серии книг */
-.series-books article {
- transition: transform 0.2s ease, box-shadow 0.2s ease;
- border: 1px solid #e0e0e0;
-}
-.series-books article:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 12px rgba(0,0,0,0.1);
-}
-
-.series-info {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 1rem;
- border-radius: 8px;
- margin-bottom: 2rem;
-}
-
-.series-badge {
- display: inline-block;
- background: #007bff;
- color: white;
- padding: 0.2rem 0.5rem;
- border-radius: 12px;
- font-size: 0.8rem;
- margin-left: 0.5rem;
-}
-
-/* Dashboard */
+/* ===== DASHBOARD ===== */
.dashboard-buttons {
display: flex;
gap: 10px;
margin-top: 1rem;
- flex-wrap: nowrap;
+ justify-content: center;
}
.dashboard-button {
- flex: 1;
text-align: center;
padding: 0.75rem 0.5rem;
text-decoration: none;
- border: 1px solid var(--secondary);
border-radius: 4px;
- background: var(--secondary);
- color: var(--secondary-inverse);
font-size: 0.9rem;
transition: all 0.3s ease;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
- white-space: nowrap;
-}
-
-.dashboard-button:hover {
- opacity: 0.9;
- transform: translateY(-1px);
-}
-
-.dashboard-button.new {
- background: var(--primary);
- border-color: var(--primary);
- color: var(--primary-inverse);
- flex: 0.7;
-}
-
-.stats-list {
- margin-top: 1rem;
-}
-
-.stats-list p {
- margin: 0.5rem 0;
- padding: 0.3rem 0;
- border-bottom: 1px solid #f0f0f0;
-}
-
-.stats-list p:last-child {
- border-bottom: none;
-}
-
-.series-stats {
- margin-top: 1rem;
- padding: 1rem;
- background: #f8f9fa;
- border-radius: 5px;
- border-left: 4px solid #6f42c1;
-}
-
-.series-stats p {
- margin: 0.5rem 0;
- font-size: 0.9rem;
-}
-
-.dashboard-section {
- margin-top: 2rem;
- padding-top: 1rem;
- border-top: 1px solid #eee;
}
.dashboard-item {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
padding: 1rem;
+ text-align: center;
}
.dashboard-item:hover {
@@ -671,12 +362,14 @@ article > header, article > footer {
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
+/* Центрируем welcome сообщение */
.welcome-message {
text-align: center;
padding: 3rem;
background: #f9f9f9;
border-radius: 8px;
- margin-top: 2rem;
+ margin: 2rem auto;
+ max-width: 800px;
}
.welcome-buttons {
@@ -687,50 +380,24 @@ article > header, article > footer {
margin-top: 1.5rem;
}
-.action-buttons {
- display: flex;
- gap: 5px;
- flex-wrap: wrap;
- margin-top: 0.5rem;
-}
-
-.action-buttons .compact-button {
- flex: 1;
- min-width: 80px;
- text-align: center;
- font-size: 0.8rem;
- padding: 0.3rem 0.5rem;
-}
-
-/* Улучшения для grid в dashboard */
-.grid {
- gap: 1rem;
-}
-
-.grid article {
- margin: 0;
- padding: 1.5rem;
-}
-
-/* Адаптивность */
+/* ===== АДАПТИВНОСТЬ ===== */
@media (max-width: 768px) {
- .adaptive-button {
- flex: 1 1 calc(50% - 10px);
- min-width: calc(50% - 10px);
- font-size: 0.8rem;
- padding: 10px 8px !important;
+ .container {
+ padding: 0 0.5rem;
}
- .action-button {
- padding: 0.6rem 1rem;
- font-size: 0.85rem;
- min-width: 120px;
- height: 42px;
+ .button-group {
+ flex-direction: column;
+ }
+
+ .dashboard-buttons {
+ flex-direction: column;
+ align-items: center;
}
.book-content {
font-size: 16px;
- line-height: 1.6;
+ padding: 0 0.5rem;
}
.book-content h1 {
@@ -741,224 +408,486 @@ article > header, article > footer {
font-size: 1.4em;
}
- .book-content h3 {
- font-size: 1.2em;
- }
-
- .book-content pre {
- font-size: 14px;
- }
-
- .author-books article {
- flex-direction: column;
- }
-
- .author-books .book-cover {
- align-self: center;
- }
-
.avatar, .avatar-placeholder {
width: 120px;
height: 120px;
- font-size: 2.5rem;
}
- .author-stats {
- gap: 1rem;
+ .action-button {
+ min-width: 120px;
+ padding: 0.6rem 1rem;
}
- .stat-number {
- font-size: 1.3em;
- }
-
- .dashboard-buttons {
- flex-direction: column;
- gap: 8px;
- }
-
- .dashboard-button {
- flex: none;
- width: 100%;
- }
-
- .dashboard-button.new {
- flex: none;
- width: 100%;
- }
-
- .welcome-buttons {
- flex-direction: column;
- align-items: center;
- }
-
- .welcome-buttons a {
- width: 100%;
- max-width: 250px;
- }
-
- .action-buttons {
- flex-direction: column;
- }
-
- .action-buttons .compact-button {
- width: 100%;
- }
-
- #fullscreen-controls {
- top: 10px !important;
- right: 10px !important;
- }
-
- #fullscreen-controls button {
- width: 60px !important;
- height: 60px !important;
- font-size: 24px !important;
- border: 2px solid #ddd !important;
- }
-
- .editor-controls button {
- width: 50px !important;
- height: 50px !important;
- font-size: 20px !important;
+ .welcome-message {
+ padding: 2rem 1rem;
+ margin: 1rem 0.5rem;
}
}
@media (max-width: 480px) {
- .adaptive-button {
- flex: 1 1 100%;
- min-width: 100%;
- }
-
- .action-button {
- padding: 0.5rem 0.8rem;
- font-size: 0.8rem;
- min-width: 110px;
- height: 40px;
- }
-
- .action-buttons-container {
- flex-direction: column;
- width: 100%;
- }
-
- .action-buttons-container .action-button {
- width: 100%;
- min-width: auto;
+ .book-content h1 {
+ font-size: 1.4em;
}
.avatar, .avatar-placeholder {
width: 100px;
height: 100px;
- font-size: 2rem;
+ }
+
+ .action-button {
+ width: 100%;
+ min-width: auto;
}
.author-stats {
+ flex-direction: column;
+ gap: 1rem;
+ }
+}
+
+/* Стили для управления сериями */
+.books-list {
+ border: 1px solid #e0e0e0;
+ border-radius: 4px;
+ background: #fafafa;
+}
+
+.book-item {
+ display: flex;
+ align-items: center;
+ padding: 12px;
+ border-bottom: 1px solid #e0e0e0;
+ background: white;
+ transition: all 0.2s ease;
+}
+
+.book-item:last-child {
+ border-bottom: none;
+}
+
+.book-item:hover {
+ background: #f8f9fa;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+}
+
+.book-item.sortable-ghost {
+ opacity: 0.6;
+ background: #e3f2fd;
+}
+
+.book-item.sortable-chosen {
+ background: #e3f2fd;
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
+}
+
+.book-drag-handle {
+ padding: 0 12px;
+ color: #666;
+ font-size: 1.2rem;
+ cursor: move;
+ user-select: none;
+}
+
+.book-drag-handle:hover {
+ color: #007bff;
+}
+
+.book-info {
+ flex: 1;
+ padding: 0 12px;
+}
+
+.book-info strong {
+ display: block;
+ margin-bottom: 4px;
+ color: #333;
+}
+
+.book-info small {
+ color: #666;
+ font-size: 0.8rem;
+}
+
+.book-actions {
+ display: flex;
+ gap: 8px;
+}
+
+/* Адаптивность для мобильных */
+@media (max-width: 768px) {
+ .book-item {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 10px;
+ }
+
+ .book-drag-handle {
+ align-self: flex-start;
+ }
+
+ .book-actions {
+ align-self: stretch;
+ justify-content: space-between;
+ }
+
+ .book-actions .compact-button {
+ flex: 1;
+ text-align: center;
+ }
+}
+
+.series-grid {
+ display: grid;
+ gap: 1.5rem;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+}
+
+.series-card {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ padding: 1.5rem;
+ transition: all 0.3s ease;
+ position: relative;
+ overflow: hidden;
+}
+
+.series-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 25px rgba(0,0,0,0.1);
+ border-color: #007bff;
+}
+
+.series-card::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 4px;
+ height: 100%;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+}
+
+.series-header {
+ margin-bottom: 1rem;
+}
+
+.series-title {
+ font-size: 1.3rem;
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+ color: #333;
+}
+
+.series-title a {
+ text-decoration: none;
+ color: inherit;
+}
+
+.series-title a:hover {
+ color: #007bff;
+}
+
+.series-meta {
+ color: #666;
+ font-size: 0.9rem;
+}
+
+.series-description {
+ color: #555;
+ line-height: 1.5;
+ margin-bottom: 1.5rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.series-stats-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+ text-align: center;
+}
+
+.series-stat {
+ padding: 0.5rem;
+}
+
+.series-stat-number {
+ font-size: 1.4rem;
+ font-weight: bold;
+ color: #6f42c1;
+ display: block;
+}
+
+.series-stat-label {
+ font-size: 0.8rem;
+ color: #666;
+ display: block;
+}
+
+.series-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+/* Пустое состояние */
+.series-empty-state {
+ text-align: center;
+ padding: 3rem 2rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+ border: 2px dashed #dee2e6;
+}
+
+.series-empty-icon {
+ font-size: 3rem;
+ margin-bottom: 1rem;
+ opacity: 0.5;
+}
+
+/* Адаптивность для серий */
+@media (max-width: 768px) {
+ .series-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .series-stats-grid {
+ grid-template-columns: repeat(3, 1fr);
+ }
+
+ .series-actions {
+ flex-direction: column;
+ }
+
+ .series-actions .compact-button {
+ width: 100%;
+ text-align: center;
+ }
+}
+
+@media (max-width: 480px) {
+ .series-card {
+ padding: 1rem;
+ }
+
+ .series-stats-grid {
+ grid-template-columns: 1fr;
+ gap: 0.25rem;
+ }
+
+ .series-stat {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.25rem 0;
+ }
+
+ .series-stat-number {
+ font-size: 1.1rem;
+ }
+}
+/* ===== СТИЛИ ДЛЯ СПИСКА КНИГ ===== */
+.books-grid {
+ display: grid;
+ gap: 1.5rem;
+ grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
+}
+
+.book-card {
+ background: white;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+ overflow: hidden;
+ transition: all 0.3s ease;
+ position: relative;
+}
+
+.book-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 25px rgba(0,0,0,0.1);
+ border-color: #007bff;
+}
+
+.book-cover-container {
+ position: relative;
+ height: 200px;
+ overflow: hidden;
+ background: #f8f9fa;
+}
+
+.book-cover {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform 0.3s ease;
+}
+
+.book-card:hover .book-cover {
+ transform: scale(1.05);
+}
+
+.cover-placeholder {
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ font-size: 3rem;
+}
+
+.book-status {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ background: rgba(255, 255, 255, 0.9);
+ border-radius: 50%;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.8rem;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+}
+
+.book-status.published {
+ background: rgba(40, 167, 69, 0.9);
+ color: white;
+}
+
+.book-info {
+ padding: 1.5rem;
+}
+
+.book-title {
+ margin: 0 0 0.5rem 0;
+ font-size: 1.2rem;
+ line-height: 1.3;
+}
+
+.book-title a {
+ text-decoration: none;
+ color: inherit;
+}
+
+.book-title a:hover {
+ color: #007bff;
+}
+
+.book-genre {
+ margin: 0 0 0.5rem 0;
+ color: #666;
+ font-style: italic;
+ font-size: 0.9rem;
+}
+
+.book-description {
+ margin: 0 0 1rem 0;
+ color: #555;
+ line-height: 1.4;
+ font-size: 0.9rem;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.book-stats {
+ display: flex;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ padding: 0.75rem;
+ background: #f8f9fa;
+ border-radius: 4px;
+ font-size: 0.85rem;
+}
+
+.stat-item {
+ text-align: center;
+ flex: 1;
+}
+
+.stat-item strong {
+ display: block;
+ font-size: 1.1rem;
+ color: #6f42c1;
+}
+
+.book-actions {
+ display:grid;
+ gap: 0.5rem;
+ flex-wrap: nowrap;
+}
+
+.book-actions .compact-button {
+ flex: 1;
+ min-width: 0;
+ text-align: center;
+ white-space: nowrap;
+}
+
+/* Пустое состояние */
+.books-empty-state {
+ text-align: center;
+ padding: 3rem 2rem;
+ background: #f8f9fa;
+ border-radius: 8px;
+ border: 2px dashed #dee2e6;
+}
+
+.books-empty-icon {
+ font-size: 4rem;
+ margin-bottom: 1rem;
+ opacity: 0.5;
+}
+
+/* Футер со статистикой */
+.books-stats-footer {
+ margin-top: 2rem;
+ padding: 1rem;
+ background: #f8f9fa;
+ border-radius: 5px;
+ text-align: center;
+ color: #666;
+}
+
+/* Адаптивность */
+@media (max-width: 768px) {
+ .books-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .book-stats {
flex-direction: column;
gap: 0.5rem;
}
- .dashboard-item {
- padding: 0.8rem;
+ .book-actions {
+ flex-direction: column;
}
- .dashboard-button {
- font-size: 0.85rem;
- padding: 0.6rem 0.4rem;
+ .book-actions .compact-button {
+ width: 100%;
+ }
+}
+
+@media (max-width: 480px) {
+ .book-info {
+ padding: 1rem;
}
- .welcome-message {
- padding: 2rem 1rem;
+ .book-cover-container {
+ height: 160px;
}
- #fullscreen-controls button {
- width: 55px !important;
- height: 55px !important;
- font-size: 22px !important;
+ .cover-placeholder {
+ font-size: 2rem;
}
-}
-
-@media (min-width: 769px) {
- #fullscreen-controls {
- top: 15px !important;
- right: 15px !important;
- }
-
- #fullscreen-controls button {
- width: 50px !important;
- height: 50px !important;
- font-size: 20px !important;
- }
-}
-
-/* Полноэкранные режимы редактора */
-#content.mobile-fullscreen {
- position: fixed !important;
- top: 50px !important;
- left: 0 !important;
- width: 100vw !important;
- height: calc(100vh - 100px) !important;
- z-index: 9998 !important;
- background-color: white !important;
- border: 2px solid #007bff !important;
- border-radius: 0 !important;
- font-size: 18px !important;
- padding: 15px !important;
- margin: 0 !important;
- box-sizing: border-box !important;
- resize: none !important;
- box-shadow: none !important;
- overflow-y: auto !important;
- -webkit-overflow-scrolling: touch !important;
-}
-
-#content.desktop-fullscreen {
- position: fixed !important;
- top: 5vh !important;
- left: 5vw !important;
- width: 90vw !important;
- height: 90vh !important;
- z-index: 9998 !important;
- background-color: white !important;
- border: 2px solid #007bff !important;
- border-radius: 8px !important;
- font-size: 16px !important;
- padding: 20px !important;
- margin: 0 !important;
- box-sizing: border-box !important;
- resize: none !important;
- box-shadow: 0 0 20px rgba(0,0,0,0.3) !important;
-}
-
-/* Стили для TinyMCE редактора */
-.tox-tinymce {
- border-radius: 4px !important;
- border: 1px solid #ddd !important;
-}
-
-.tox-tinymce:focus {
- border-color: #007bff !important;
- box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
-}
-
-/* Адаптивность для TinyMCE */
-@media (max-width: 768px) {
- .tox-tinymce {
- border-radius: 0 !important;
- }
-
- .tox-toolbar {
- flex-wrap: wrap !important;
- }
-}
-
-.alert-info {
- background: #d1ecf1;
- color: #0c5460;
- border: 1px solid #bee5eb;
-}
-
-.alert-warning {
- background: #fff3cd;
- color: #856404;
- border: 1px solid #ffeaa7;
}
\ No newline at end of file
diff --git a/assets/index.php b/assets/index.php
old mode 100644
new mode 100755
diff --git a/assets/js/autosave.js b/assets/js/autosave.js
index 1c74600..92a84e3 100755
--- a/assets/js/autosave.js
+++ b/assets/js/autosave.js
@@ -1,28 +1,69 @@
// assets/js/autosave.js
document.addEventListener('DOMContentLoaded', function() {
- const contentTextarea = document.getElementById('content');
- const titleInput = document.getElementById('title');
- const statusSelect = document.getElementById('status');
+ // Ждем инициализации редактора
+ setTimeout(() => {
+ initializeAutoSave();
+ }, 1000);
+});
+
+function initializeAutoSave() {
+ console.log('AutoSave: Initializing...');
- // Проверяем, что это редактирование существующей главы
- const urlParams = new URLSearchParams(window.location.search);
- const isEditMode = urlParams.has('id');
+ // Ищем активные редакторы Quill
+ const quillEditors = document.querySelectorAll('.ql-editor');
+ const textareas = document.querySelectorAll('textarea.writer-editor');
- if (!contentTextarea || !isEditMode) {
- console.log('Автосохранение отключено: создание новой главы');
+ if (quillEditors.length === 0 || textareas.length === 0) {
+ console.log('AutoSave: No Quill editors found, retrying in 1s...');
+ setTimeout(initializeAutoSave, 1000);
return;
}
-
+
+ console.log(`AutoSave: Found ${quillEditors.length} Quill editor(s)`);
+
+ // Для каждого редактора настраиваем автосейв
+ quillEditors.forEach((quillEditor, index) => {
+ const textarea = textareas[index];
+ if (!textarea) return;
+
+ setupAutoSaveForEditor(quillEditor, textarea, index);
+ });
+}
+
+function setupAutoSaveForEditor(quillEditor, textarea, editorIndex) {
let saveTimeout;
let isSaving = false;
- let lastSavedContent = contentTextarea.value;
+ let lastSavedContent = textarea.value;
+ let changeCount = 0;
+
+ // Получаем экземпляр Quill из контейнера
+ const quillContainer = quillEditor.closest('.ql-container');
+ const quillInstance = quillContainer ? Quill.find(quillContainer) : null;
+ if (!quillInstance) {
+ console.error(`AutoSave: Could not find Quill instance for editor ${editorIndex}`);
+ return;
+ }
+
+ console.log(`AutoSave: Setting up for editor ${editorIndex}`);
+
function showSaveMessage(message) {
let messageEl = document.getElementById('autosave-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.id = 'autosave-message';
- messageEl.style.cssText = 'position: fixed; top: 10px; right: 10px; padding: 8px 12px; background: #333; color: white; border-radius: 3px; z-index: 1000; font-size: 0.8rem;';
+ messageEl.style.cssText = `
+ position: fixed;
+ top: 70px;
+ right: 10px;
+ padding: 8px 12px;
+ background: #28a745;
+ color: white;
+ border-radius: 3px;
+ z-index: 10000;
+ font-size: 0.8rem;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ `;
document.body.appendChild(messageEl);
}
@@ -31,68 +72,151 @@ document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => {
messageEl.style.display = 'none';
- }, 1500);
+ }, 2000);
}
-
+
+ function showError(message) {
+ let messageEl = document.getElementById('autosave-message');
+ if (!messageEl) {
+ messageEl = document.createElement('div');
+ messageEl.id = 'autosave-message';
+ messageEl.style.cssText = `
+ position: fixed;
+ top: 70px;
+ right: 10px;
+ padding: 8px 12px;
+ background: #dc3545;
+ color: white;
+ border-radius: 3px;
+ z-index: 10000;
+ font-size: 0.8rem;
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
+ `;
+ document.body.appendChild(messageEl);
+ }
+
+ messageEl.textContent = message;
+ messageEl.style.background = '#dc3545';
+ messageEl.style.display = 'block';
+
+ setTimeout(() => {
+ messageEl.style.display = 'none';
+ messageEl.style.background = '#28a745';
+ }, 3000);
+ }
+
function autoSave() {
- if (isSaving) return;
-
- const currentContent = contentTextarea.value;
- const currentTitle = titleInput ? titleInput.value : '';
- const currentStatus = statusSelect ? statusSelect.value : 'draft';
-
- if (currentContent === lastSavedContent) return;
+ if (isSaving) {
+ console.log('AutoSave: Already saving, skipping...');
+ return;
+ }
+
+ const currentContent = textarea.value;
+ // Проверяем, изменилось ли содержимое
+ if (currentContent === lastSavedContent) {
+ console.log('AutoSave: No changes detected');
+ return;
+ }
+
+ changeCount++;
+ console.log(`AutoSave: Changes detected (${changeCount}), saving...`);
+
isSaving = true;
-
+
+ // Показываем индикатор сохранения
+ showSaveMessage('Сохранение...');
+
const formData = new FormData();
formData.append('content', currentContent);
- formData.append('title', currentTitle);
- formData.append('status', currentStatus);
- formData.append('autosave', 'true');
- formData.append('csrf_token', document.querySelector('input[name="csrf_token"]').value);
- fetch(window.location.href, {
+ // Добавляем title если есть
+ const titleInput = document.querySelector('input[name="title"]');
+ if (titleInput) {
+ formData.append('title', titleInput.value);
+ }
+
+ // Добавляем status если есть
+ const statusSelect = document.querySelector('select[name="status"]');
+ if (statusSelect) {
+ formData.append('status', statusSelect.value);
+ }
+
+ formData.append('autosave', 'true');
+ formData.append('csrf_token', document.querySelector('input[name="csrf_token"]')?.value || '');
+
+ const currentUrl = window.location.href;
+
+ fetch(currentUrl, {
method: 'POST',
body: formData
})
- .then(response => response.json())
+ .then(response => {
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ return response.json();
+ })
.then(data => {
if (data.success) {
lastSavedContent = currentContent;
- showSaveMessage('Сохранено: ' + new Date().toLocaleTimeString());
+ showSaveMessage('Автосохранено: ' + new Date().toLocaleTimeString());
+ console.log('AutoSave: Successfully saved');
+ } else {
+ throw new Error(data.error || 'Unknown error');
}
})
.catch(error => {
- console.error('Ошибка автосохранения:', error);
+ console.error('AutoSave Error:', error);
+ showError('Ошибка автосохранения: ' + error.message);
})
.finally(() => {
isSaving = false;
});
}
-
- contentTextarea.addEventListener('input', function() {
- clearTimeout(saveTimeout);
- saveTimeout = setTimeout(autoSave, 2000);
+
+ // Слушаем изменения в Quill редакторе
+ quillInstance.on('text-change', function(delta, oldDelta, source) {
+ if (source === 'user') {
+ console.log('AutoSave: Text changed by user');
+ clearTimeout(saveTimeout);
+ saveTimeout = setTimeout(autoSave, 2000); // Сохраняем через 2 секунды после изменения
+ }
});
-
+
+ // Также слушаем изменения в title и status
+ const titleInput = document.querySelector('input[name="title"]');
if (titleInput) {
titleInput.addEventListener('input', function() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(autoSave, 2000);
});
}
-
+
+ const statusSelect = document.querySelector('select[name="status"]');
if (statusSelect) {
- statusSelect.addEventListener('change', autoSave);
+ statusSelect.addEventListener('change', function() {
+ clearTimeout(saveTimeout);
+ saveTimeout = setTimeout(autoSave, 1000);
+ });
}
-
+
+ // Предупреждение при закрытии страницы с несохраненными изменениями
window.addEventListener('beforeunload', function(e) {
- if (contentTextarea.value !== lastSavedContent) {
+ if (textarea.value !== lastSavedContent && !isSaving) {
e.preventDefault();
e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?';
+ return e.returnValue;
}
});
-
- //console.log('Автосохранение включено для редактирования главы');
-});
\ No newline at end of file
+
+ // Периодическое сохранение каждые 30 секунд (на всякий случай)
+ setInterval(() => {
+ if (textarea.value !== lastSavedContent && !isSaving) {
+ console.log('AutoSave: Periodic save triggered');
+ autoSave();
+ }
+ }, 30000);
+
+ console.log(`AutoSave: Successfully set up for editor ${editorIndex}`);
+}
\ No newline at end of file
diff --git a/assets/js/editor.js b/assets/js/editor.js
new file mode 100644
index 0000000..e063f9c
--- /dev/null
+++ b/assets/js/editor.js
@@ -0,0 +1,102 @@
+// assets/js/editor.js
+class WriterEditor {
+ constructor() {
+ this.editors = [];
+ this.init();
+ }
+
+ init() {
+ // Инициализируем редакторы для текстовых областей с классом .writer-editor
+ document.querySelectorAll('textarea.writer-editor').forEach(textarea => {
+ this.initEditor(textarea);
+ });
+ }
+
+ initEditor(textarea) {
+ // Создаем контейнер для Quill
+ const editorContainer = document.createElement('div');
+ editorContainer.className = 'writer-editor-container';
+ editorContainer.style.height = '500px';
+ editorContainer.style.marginBottom = '20px';
+
+ // Вставляем контейнер перед textarea
+ textarea.parentNode.insertBefore(editorContainer, textarea);
+
+ // Скрываем оригинальный textarea
+ textarea.style.display = 'none';
+
+ // Настройки Quill
+ const quill = new Quill(editorContainer, {
+ theme: 'snow',
+ modules: {
+ toolbar: [
+ [{ 'header': [1, 2, 3, false] }],
+ ['bold', 'italic', 'underline', 'strike'],
+ ['blockquote', 'code-block'],
+ [{ 'list': 'ordered'}, { 'list': 'bullet' }],
+ [{ 'script': 'sub'}, { 'script': 'super' }],
+ [{ 'indent': '-1'}, { 'indent': '+1' }],
+ [{ 'direction': 'rtl' }],
+ [{ 'size': ['small', false, 'large', 'huge'] }],
+ [{ 'color': [] }, { 'background': [] }],
+ [{ 'font': [] }],
+ [{ 'align': [] }],
+ ['link', 'image', 'video'],
+ ['clean']
+ ],
+ history: {
+ delay: 1000,
+ maxStack: 100,
+ userOnly: true
+ }
+ },
+ placeholder: 'Начните писать вашу главу...',
+ formats: [
+ 'header', 'bold', 'italic', 'underline', 'strike',
+ 'blockquote', 'code-block', 'list', 'bullet',
+ 'script', 'indent', 'direction', 'size',
+ 'color', 'background', 'font', 'align',
+ 'link', 'image', 'video'
+ ]
+ });
+
+ // Устанавливаем начальное содержимое
+ if (textarea.value) {
+ quill.root.innerHTML = textarea.value;
+ }
+
+ // Обновляем textarea при изменении содержимого
+ quill.on('text-change', () => {
+ textarea.value = quill.root.innerHTML;
+ });
+
+ // Сохраняем ссылку на редактор
+ this.editors.push({
+ quill: quill,
+ textarea: textarea
+ });
+
+ return quill;
+ }
+
+ // Метод для получения HTML содержимого
+ getContent(editorIndex = 0) {
+ if (this.editors[editorIndex]) {
+ return this.editors[editorIndex].quill.root.innerHTML;
+ }
+ return '';
+ }
+
+ // Метод для установки содержимого
+ setContent(content, editorIndex = 0) {
+ if (this.editors[editorIndex]) {
+ this.editors[editorIndex].quill.root.innerHTML = content;
+ }
+ }
+}
+
+
+// Инициализация редактора при загрузке страницы
+document.addEventListener('DOMContentLoaded', function() {
+ window.writerEditor = new WriterEditor();
+});
\ No newline at end of file
diff --git a/assets/js/index.php b/assets/js/index.php
old mode 100644
new mode 100755
diff --git a/assets/js/markdown-editor.js b/assets/js/markdown-editor.js
deleted file mode 100755
index 11cdf44..0000000
--- a/assets/js/markdown-editor.js
+++ /dev/null
@@ -1,575 +0,0 @@
-document.addEventListener('DOMContentLoaded', function() {
- const contentTextarea = document.getElementById('content');
- const previewForm = document.getElementById('preview-form');
-
- if (!contentTextarea) return;
-
- let isFullscreen = false;
- let originalStyles = {};
- let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
-
-
- function normalizeContent(text) {
- // Заменяем множественные переносы на двойные
- text = text.replace(/\n{3,}/g, '\n\n');
- // Убираем пустые строки в начале и конце
- return text.trim();
- }
-
- initEditor();
-
- function initEditor() {
- // Нормализуем контент при загрузке
- if (contentTextarea.value) {
- contentTextarea.value = normalizeContent(contentTextarea.value);
- }
- autoResize();
- contentTextarea.addEventListener('input', autoResize);
- contentTextarea.addEventListener('input', processDialogues);
- contentTextarea.addEventListener('keydown', handleTab);
- contentTextarea.addEventListener('input', updatePreviewContent);
-
- updatePreviewContent();
- addControlButtons();
-
- // На мобильных устройствах добавляем обработчик изменения ориентации
- if (isMobile) {
- window.addEventListener('orientationchange', function() {
- if (isFullscreen) {
- setTimeout(adjustForMobileKeyboard, 300);
- }
- });
-
- // Обработчик для виртуальной клавиатуры
- window.addEventListener('resize', function() {
- if (isFullscreen && isMobile) {
- adjustForMobileKeyboard();
- }
- });
- }
- }
-
- function autoResize() {
- if (isFullscreen) return;
-
- contentTextarea.style.height = 'auto';
- contentTextarea.style.height = contentTextarea.scrollHeight + 'px';
- }
-
- function processDialogues() {
- const lines = contentTextarea.value.split('\n');
- let changed = false;
-
- const processedLines = lines.map(line => {
- if (line.trim().startsWith('- ') && line.trim().length > 2) {
- const trimmed = line.trim();
- const restOfLine = trimmed.substring(2);
- if (/^[a-zA-Zа-яА-Я]/.test(restOfLine)) {
- changed = true;
- return line.replace(trimmed, `— ${restOfLine}`);
- }
- }
- return line;
- });
-
- if (changed) {
- const cursorPos = contentTextarea.selectionStart;
- contentTextarea.value = processedLines.join('\n');
- contentTextarea.setSelectionRange(cursorPos, cursorPos);
- if (!isFullscreen) autoResize();
- }
- }
-
- function handleTab(e) {
- if (e.key === 'Tab') {
- e.preventDefault();
- const start = contentTextarea.selectionStart;
- const end = contentTextarea.selectionEnd;
-
- contentTextarea.value = contentTextarea.value.substring(0, start) + ' ' + contentTextarea.value.substring(end);
- contentTextarea.selectionStart = contentTextarea.selectionEnd = start + 4;
- if (!isFullscreen) autoResize();
- }
- }
-
- function updatePreviewContent() {
- if (previewForm) {
- document.getElementById('preview-content').value = contentTextarea.value;
- }
- }
-
- function adjustForMobileKeyboard() {
- if (!isMobile || !isFullscreen) return;
-
- // На мобильных устройствах уменьшаем высоту textarea, чтобы клавиатура не перекрывала контент
- const viewportHeight = window.innerHeight;
- const keyboardHeight = viewportHeight * 0.4; // Предполагаемая высота клавиатуры (40% экрана)
- const availableHeight = viewportHeight - keyboardHeight - 80; // 80px для кнопок и отступов
-
- contentTextarea.style.height = availableHeight + 'px';
- contentTextarea.style.paddingBottom = '20px';
-
- // Прокручиваем к курсору
- setTimeout(() => {
- const cursorPos = contentTextarea.selectionStart;
- if (cursorPos > 0) {
- scrollToCursor();
- }
- }, 100);
- }
-
- function scrollToCursor() {
- const textarea = contentTextarea;
- const cursorPos = textarea.selectionStart;
-
- // Создаем временный элемент для измерения позиции курсора
- const tempDiv = document.createElement('div');
- tempDiv.style.cssText = `
- position: absolute;
- top: -1000px;
- left: -1000px;
- width: ${textarea.clientWidth}px;
- padding: ${textarea.style.padding};
- font: ${getComputedStyle(textarea).font};
- line-height: ${textarea.style.lineHeight};
- white-space: pre-wrap;
- word-wrap: break-word;
- visibility: hidden;
- `;
-
- const textBeforeCursor = textarea.value.substring(0, cursorPos);
- tempDiv.textContent = textBeforeCursor;
-
- document.body.appendChild(tempDiv);
- const textHeight = tempDiv.offsetHeight;
- document.body.removeChild(tempDiv);
-
- // Прокручиваем так, чтобы курсор был виден
- const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 24;
- const visibleHeight = textarea.clientHeight;
- const cursorLine = Math.floor(textHeight / lineHeight);
- const visibleLines = Math.floor(visibleHeight / lineHeight);
-
- const targetScroll = Math.max(0, (cursorLine - Math.floor(visibleLines / 3)) * lineHeight);
-
- textarea.scrollTop = targetScroll;
- }
-
- function addControlButtons() {
- const container = contentTextarea.parentElement;
-
- const controlsContainer = document.createElement('div');
- controlsContainer.className = 'editor-controls';
-
- const fullscreenBtn = createButton('🔲', 'Полноэкранный режим', toggleFullscreen);
- const helpBtn = createButton('❓', 'Справка по Markdown', showHelp);
-
- controlsContainer.appendChild(fullscreenBtn);
- controlsContainer.appendChild(helpBtn);
-
- container.insertBefore(controlsContainer, contentTextarea);
-
- function toggleFullscreen() {
- if (!isFullscreen) {
- enterFullscreen();
- } else {
- exitFullscreen();
- }
- }
-
- function enterFullscreen() {
- originalStyles = {
- position: contentTextarea.style.position,
- top: contentTextarea.style.top,
- left: contentTextarea.style.left,
- width: contentTextarea.style.width,
- height: contentTextarea.style.height,
- zIndex: contentTextarea.style.zIndex,
- backgroundColor: contentTextarea.style.backgroundColor,
- border: contentTextarea.style.border,
- borderRadius: contentTextarea.style.borderRadius,
- fontSize: contentTextarea.style.fontSize,
- padding: contentTextarea.style.padding,
- margin: contentTextarea.style.margin
- };
-
- if (isMobile) {
- // Для мобильных - адаптивный режим с учетом клавиатуры
- const viewportHeight = window.innerHeight;
- const availableHeight = viewportHeight - 100; // Оставляем место для кнопок
-
- Object.assign(contentTextarea.style, {
- position: 'fixed',
- top: '50px',
- left: '0',
- width: '100vw',
- height: availableHeight + 'px',
- zIndex: '9998',
- backgroundColor: 'white',
- border: '2px solid #007bff',
- borderRadius: '0',
- fontSize: '18px',
- padding: '15px',
- margin: '0',
- boxSizing: 'border-box',
- resize: 'none'
- });
-
- // На мобильных устройствах фокусируем textarea сразу
- setTimeout(() => {
- contentTextarea.focus();
- }, 300);
- } else {
- // Для ПК - классический полноэкранный режим
- Object.assign(contentTextarea.style, {
- position: 'fixed',
- top: '5vh',
- left: '5vw',
- width: '90vw',
- height: '90vh',
- zIndex: '9998',
- backgroundColor: 'white',
- border: '2px solid #007bff',
- borderRadius: '8px',
- fontSize: '16px',
- padding: '20px',
- margin: '0',
- boxSizing: 'border-box',
- resize: 'none'
- });
- }
-
- controlsContainer.style.display = 'none';
- createFullscreenControls();
-
- isFullscreen = true;
- document.body.style.overflow = 'hidden';
- }
-
- function exitFullscreen() {
- Object.assign(contentTextarea.style, originalStyles);
-
- controlsContainer.style.display = 'flex';
- removeFullscreenControls();
-
- isFullscreen = false;
- document.body.style.overflow = '';
-
- autoResize();
- }
-
- function createFullscreenControls() {
- const fullscreenControls = document.createElement('div');
- fullscreenControls.id = 'fullscreen-controls';
-
- const exitBtn = createButton('❌', 'Выйти из полноэкранного режима', exitFullscreen);
- const helpBtnFullscreen = createButton('❓', 'Справка по Markdown', showHelp);
-
- // Для мобильных увеличиваем кнопки и добавляем отступы
- const buttonSize = isMobile ? '60px' : '50px';
- const fontSize = isMobile ? '24px' : '20px';
- const topPosition = isMobile ? '10px' : '15px';
-
- [exitBtn, helpBtnFullscreen].forEach(btn => {
- btn.style.cssText = `
- width: ${buttonSize};
- height: ${buttonSize};
- border-radius: 50%;
- border: 1px solid #ddd;
- background: white;
- cursor: pointer;
- font-size: ${fontSize};
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 2px 5px rgba(0,0,0,0.3);
- transition: all 0.3s ease;
- color: #333333;
- touch-action: manipulation;
- `;
- });
-
- fullscreenControls.appendChild(helpBtnFullscreen);
- fullscreenControls.appendChild(exitBtn);
-
- fullscreenControls.style.cssText = `
- position: fixed;
- top: ${topPosition};
- right: 10px;
- z-index: 9999;
- display: flex;
- gap: 5px;
- `;
-
- document.body.appendChild(fullscreenControls);
-
- // Предотвращаем всплытие событий от кнопок к textarea
- fullscreenControls.addEventListener('touchstart', function(e) {
- e.stopPropagation();
- });
-
- fullscreenControls.addEventListener('touchend', function(e) {
- e.stopPropagation();
- });
- }
-
- function removeFullscreenControls() {
- const fullscreenControls = document.getElementById('fullscreen-controls');
- if (fullscreenControls) {
- fullscreenControls.remove();
- }
- }
-
- // Выход по ESC
- document.addEventListener('keydown', function(e) {
- if (e.key === 'Escape' && isFullscreen) {
- exitFullscreen();
- }
- });
-
- // На мобильных устройствах добавляем обработчик для выхода по тапу вне textarea
- if (isMobile) {
- document.addEventListener('touchstart', function(e) {
- if (isFullscreen && !contentTextarea.contains(e.target) &&
- !document.getElementById('fullscreen-controls')?.contains(e.target)) {
- exitFullscreen();
- }
- });
- }
-
- // Обработчик фокуса для мобильных устройств
- if (isMobile) {
- contentTextarea.addEventListener('focus', function() {
- if (isFullscreen) {
- setTimeout(adjustForMobileKeyboard, 100);
- }
- });
- }
- }
-
- function createButton(icon, title, onClick) {
- const button = document.createElement('button');
- button.innerHTML = icon;
- button.title = title;
- button.type = 'button';
-
- const buttonSize = isMobile ? '50px' : '40px';
- const fontSize = isMobile ? '20px' : '16px';
-
- button.style.cssText = `
- width: ${buttonSize};
- height: ${buttonSize};
- border-radius: 50%;
- border: 1px solid #ddd;
- background: white;
- cursor: pointer;
- font-size: ${fontSize};
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 2px 5px rgba(0,0,0,0.2);
- transition: all 0.3s ease;
- color: #333333;
- touch-action: manipulation;
- `;
-
- button.addEventListener('mouseenter', function() {
- this.style.transform = 'scale(1.1)';
- this.style.backgroundColor = '#f8f9fa';
- this.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
- });
-
- button.addEventListener('mouseleave', function() {
- this.style.transform = 'scale(1)';
- this.style.backgroundColor = 'white';
- this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
- });
-
- button.addEventListener('click', onClick);
-
- // Для мобильных устройств
- button.addEventListener('touchstart', function(e) {
- e.stopPropagation();
- this.style.transform = 'scale(1.1)';
- this.style.backgroundColor = '#f8f9fa';
- this.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
- });
-
- button.addEventListener('touchend', function(e) {
- e.stopPropagation();
- this.style.transform = 'scale(1)';
- this.style.backgroundColor = 'white';
- this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
- onClick();
- });
-
- return button;
- }
-
- function showHelp() {
- const helpContent = `
-
-
Справка по Markdown
-
-
-
Основное форматирование
-
-
Жирный текст: **текст** или __текст__
-
Наклонный текст: *текст* или _текст_
-
Подчеркнутый текст: <u>текст</u>
-
Зачеркнутый текст: ~~текст~~
-
-
-
-
-
Заголовки
-
-
Заголовок 1 (# Заголовок)
- Заголовок 2 (## Заголовок)
- Заголовок 3 (### Заголовок)
-
-
-
-
-
Цитаты
-
-
- > Это цитата
-
-
- > > Вложенная цитата
-
-
-
-
-
-
Диалоги
-
-
Автоматическое преобразование:
-
- Привет! → — Привет!
-
- Дефис в начале строки автоматически заменяется на тире с пробелом
-
-
-
-
-
-
Списки
-
-
Маркированный список:
-
- - - Элемент списка
- - - Другой элемент
-
-
Нумерованный список:
-
- - 1. Первый элемент
- - 2. Второй элемент
-
-
-
-
-
-
Код
-
-
Код в строке:
-
\`код в строке\`
-
Блок кода:
-
-\`\`\`
-блок кода
-многострочный
-\`\`\`
-
-
-
-
-
💡 Подсказка: Используйте кнопку "👁️ Предпросмотр" чтобы увидеть как будет выглядеть готовый текст!
-
-
- `;
-
- const modal = document.createElement('div');
- modal.style.cssText = `
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: white;
- border: 2px solid #007bff;
- border-radius: 10px;
- padding: 25px;
- z-index: 10000;
- width: 90%;
- max-width: 700px;
- max-height: 85vh;
- overflow-y: auto;
- box-shadow: 0 10px 30px rgba(0,0,0,0.3);
- `;
-
- const closeBtn = document.createElement('button');
- closeBtn.innerHTML = '✕';
- closeBtn.title = 'Закрыть справку';
- closeBtn.style.cssText = `
- position: absolute;
- top: 15px;
- right: 15px;
- background: #ff4444;
- color: white;
- border: none;
- font-size: 18px;
- cursor: pointer;
- width: 35px;
- height: 35px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: background 0.3s ease;
- `;
-
- closeBtn.addEventListener('mouseenter', function() {
- this.style.background = '#cc0000';
- });
-
- closeBtn.addEventListener('mouseleave', function() {
- this.style.background = '#ff4444';
- });
-
- closeBtn.addEventListener('click', function() {
- modal.remove();
- overlay.remove();
- });
-
- modal.innerHTML = helpContent;
- modal.appendChild(closeBtn);
-
- const overlay = document.createElement('div');
- overlay.style.cssText = `
- position: fixed;
- top: 0;
- left: 0;
- width: 100vw;
- height: 100vh;
- background: rgba(0,0,0,0.5);
- z-index: 9999;
- `;
-
- overlay.addEventListener('click', function() {
- modal.remove();
- overlay.remove();
- });
-
- document.body.appendChild(overlay);
- document.body.appendChild(modal);
-
- const closeHandler = function(e) {
- if (e.key === 'Escape') {
- modal.remove();
- overlay.remove();
- document.removeEventListener('keydown', closeHandler);
- }
- };
- document.addEventListener('keydown', closeHandler);
- }
-});
\ No newline at end of file
diff --git a/config/config.php b/config/config.php
old mode 100644
new mode 100755
diff --git a/config/index.php b/config/index.php
old mode 100644
new mode 100755
diff --git a/controllers/AdminController.php b/controllers/AdminController.php
new file mode 100755
index 0000000..7abee6b
--- /dev/null
+++ b/controllers/AdminController.php
@@ -0,0 +1,140 @@
+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' => 'Добавление пользователя'
+ ]);
+ }
+}
+?>
\ No newline at end of file
diff --git a/controllers/AuthController.php b/controllers/AuthController.php
old mode 100644
new mode 100755
diff --git a/controllers/BaseController.php b/controllers/BaseController.php
old mode 100644
new mode 100755
index 572b033..ce51395
--- a/controllers/BaseController.php
+++ b/controllers/BaseController.php
@@ -24,6 +24,23 @@ class BaseController {
}
}
+ protected function requireAdmin() {
+ if (!is_logged_in()) {
+ $this->redirect('/login');
+ return;
+ }
+
+ global $pdo;
+ $userModel = new User($pdo);
+ $user = $userModel->findById($_SESSION['user_id']);
+
+ if (!$user || $user['id'] != 1) { // Предполагаем, что администратор имеет ID = 1
+ $_SESSION['error'] = "У вас нет прав администратора";
+ $this->redirect('/dashboard');
+ exit;
+ }
+ }
+
protected function jsonResponse($data) {
header('Content-Type: application/json');
echo json_encode($data);
diff --git a/controllers/BookController.php b/controllers/BookController.php
old mode 100644
new mode 100755
index f4377fe..19ec443
--- a/controllers/BookController.php
+++ b/controllers/BookController.php
@@ -21,13 +21,9 @@ class BookController extends BaseController {
$this->requireLogin();
$seriesModel = new Series($this->pdo);
$series = $seriesModel->findByUser($_SESSION['user_id']);
-
- // Возвращаем типы редакторов для выбора
- $editor_types = [
- 'markdown' => 'Markdown редактор',
- 'html' => 'HTML редактор (TinyMCE)'
- ];
-
+ $error = '';
+ $cover_error = '';
+
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
@@ -46,25 +42,38 @@ class BookController extends BaseController {
'description' => trim($_POST['description'] ?? ''),
'genre' => trim($_POST['genre'] ?? ''),
'user_id' => $_SESSION['user_id'],
- 'editor_type' => $_POST['editor_type'] ?? 'markdown',
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
'published' => isset($_POST['published']) ? 1 : 0
];
if ($bookModel->create($data)) {
- $_SESSION['success'] = "Книга успешно создана";
$new_book_id = $this->pdo->lastInsertId();
- $this->redirect("/books/{$new_book_id}/edit");
+
+ // Обработка загрузки обложки
+ if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
+ $cover_result = handleCoverUpload($_FILES['cover_image'], $new_book_id);
+ if ($cover_result['success']) {
+ $bookModel->updateCover($new_book_id, $cover_result['filename']);
+ } else {
+ $cover_error = $cover_result['error'];
+ // Сохраняем ошибку в сессии, чтобы показать после редиректа
+ $_SESSION['cover_error'] = $cover_error;
+ }
+ }
+
+ $_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : "");
+ $this->redirect("/books/{$new_book_id}/edit");
} else {
$_SESSION['error'] = "Ошибка при создании книги";
}
+
}
$this->render('books/create', [
'series' => $series,
- 'editor_types' => $editor_types,
- 'selected_editor' => 'markdown', // по умолчанию
+ 'error' => $error,
+ 'cover_error' => $cover_error,
'page_title' => 'Создание новой книги'
]);
}
@@ -82,11 +91,6 @@ class BookController extends BaseController {
$seriesModel = new Series($this->pdo);
$series = $seriesModel->findByUser($_SESSION['user_id']);
- // Типы редакторов для выбора
- $editor_types = [
- 'markdown' => 'Markdown редактор',
- 'html' => 'HTML редактор (TinyMCE)'
- ];
$error = '';
$cover_error = '';
@@ -98,30 +102,17 @@ class BookController extends BaseController {
$title = trim($_POST['title'] ?? '');
if (empty($title)) {
$error = "Название книги обязательно";
- } else {
- $old_editor_type = $book['editor_type'];
- $new_editor_type = $_POST['editor_type'] ?? 'markdown';
- $editor_changed = ($old_editor_type !== $new_editor_type);
-
+ } else {
$data = [
'title' => $title,
'description' => trim($_POST['description'] ?? ''),
'genre' => trim($_POST['genre'] ?? ''),
'user_id' => $_SESSION['user_id'],
- 'editor_type' => $new_editor_type,
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null,
'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null,
'published' => isset($_POST['published']) ? 1 : 0
];
- // Обработка смены редактора (прежде чем обновлять книгу)
- if ($editor_changed) {
- $conversion_success = $bookModel->convertChaptersContent($id, $old_editor_type, $new_editor_type);
- if (!$conversion_success) {
- $_SESSION['warning'] = "Внимание: не удалось автоматически сконвертировать содержание всех глав.";
- }
- }
-
// Обработка обложки
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
$cover_result = handleCoverUpload($_FILES['cover_image'], $id);
@@ -142,9 +133,6 @@ class BookController extends BaseController {
if ($success) {
$success_message = "Книга успешно обновлена";
- if ($editor_changed) {
- $success_message .= ". Содержание глав сконвертировано в новый формат.";
- }
$_SESSION['success'] = $success_message;
$this->redirect("/books/{$id}/edit");
} else {
@@ -162,7 +150,6 @@ class BookController extends BaseController {
'book' => $book,
'series' => $series,
'chapters' => $chapters,
- 'editor_types' => $editor_types,
'error' => $error,
'cover_error' => $cover_error,
'page_title' => 'Редактирование книги'
@@ -193,6 +180,69 @@ class BookController extends BaseController {
$this->redirect('/books');
}
+
+ public function deleteAll() {
+ $this->requireLogin();
+ $user_id = $_SESSION['user_id'];
+
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $_SESSION['error'] = "Ошибка безопасности";
+ $this->redirect('/books');
+ }
+
+ $bookModel = new Book($this->pdo);
+
+ // Получаем все книги пользователя
+ $books = $bookModel->findByUser($user_id);
+ if (empty($books)) {
+ $_SESSION['info'] = "У вас нет книг для удаления";
+ $this->redirect('/books');
+ }
+
+ try {
+ $this->pdo->beginTransaction();
+
+ $deleted_count = 0;
+ $deleted_covers = 0;
+
+ foreach ($books as $book) {
+ // Удаляем обложку если она есть
+ if (!empty($book['cover_image'])) {
+ $cover_path = COVERS_PATH . $book['cover_image'];
+ if (file_exists($cover_path) && unlink($cover_path)) {
+ $deleted_covers++;
+ }
+ }
+
+ // Удаляем главы книги
+ $stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id = ?");
+ $stmt->execute([$book['id']]);
+
+ // Удаляем саму книгу
+ $stmt = $this->pdo->prepare("DELETE FROM books WHERE id = ? AND user_id = ?");
+ $stmt->execute([$book['id'], $user_id]);
+
+ $deleted_count++;
+ }
+
+ $this->pdo->commit();
+
+ $message = "Все книги успешно удалены ($deleted_count книг";
+ if ($deleted_covers > 0) {
+ $message .= ", удалено $deleted_covers обложек";
+ }
+ $message .= ")";
+
+ $_SESSION['success'] = $message;
+ } catch (Exception $e) {
+ $this->pdo->rollBack();
+ error_log("Ошибка при массовом удалении: " . $e->getMessage());
+ $_SESSION['error'] = "Произошла ошибка при удалении книг: " . $e->getMessage();
+ }
+
+ $this->redirect('/books');
+ }
+
public function viewPublic($share_token) {
$bookModel = new Book($this->pdo);
$book = $bookModel->findByShareToken($share_token);
@@ -216,29 +266,6 @@ class BookController extends BaseController {
]);
}
- public function normalizeContent($id) {
- $this->requireLogin();
- if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
- $_SESSION['error'] = "Неверный метод запроса";
- $this->redirect("/books/{$id}/edit");
- }
- if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
- $_SESSION['error'] = "Ошибка безопасности";
- $this->redirect("/books/{$id}/edit");
- }
- $user_id = $_SESSION['user_id'];
- $bookModel = new Book($this->pdo);
- if (!$bookModel->userOwnsBook($id, $user_id)) {
- $_SESSION['error'] = "У вас нет доступа к этой книге";
- $this->redirect('/books');
- }
- if ($bookModel->normalizeBookContent($id)) {
- $_SESSION['success'] = "Контент глав успешно нормализован";
- } else {
- $_SESSION['error'] = "Ошибка при нормализации контента";
- }
- $this->redirect("/books/{$id}/edit");
- }
public function regenerateToken($id) {
$this->requireLogin();
diff --git a/controllers/ChapterController.php b/controllers/ChapterController.php
old mode 100644
new mode 100755
index 437de82..2680ebc
--- a/controllers/ChapterController.php
+++ b/controllers/ChapterController.php
@@ -3,7 +3,6 @@
require_once 'controllers/BaseController.php';
require_once 'models/Chapter.php';
require_once 'models/Book.php';
-require_once 'includes/parsedown/ParsedownExtra.php';
class ChapterController extends BaseController {
@@ -94,11 +93,29 @@ class ChapterController extends BaseController {
// Проверяем права доступа к главе
if (!$chapterModel->userOwnsChapter($id, $user_id)) {
+ if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
+ // Для AJAX запросов возвращаем JSON
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'error' => 'Доступ запрещен']);
+ exit;
+ }
$_SESSION['error'] = "У вас нет доступа к этой главе";
$this->redirect('/books');
}
$chapter = $chapterModel->findById($id);
+
+ // Дополнительная проверка - глава должна существовать
+ if (!$chapter) {
+ if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'error' => 'Глава не найдена']);
+ exit;
+ }
+ $_SESSION['error'] = "Глава не найдена";
+ $this->redirect('/books');
+ }
+
$book = $bookModel->findById($chapter['book_id']);
$error = '';
@@ -119,6 +136,20 @@ class ChapterController extends BaseController {
'status' => $status
];
+ // Если это запрос автосейва, возвращаем JSON ответ
+ if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
+ if ($chapterModel->update($id, $data)) {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => true]);
+ exit;
+ } else {
+ header('Content-Type: application/json');
+ echo json_encode(['success' => false, 'error' => 'Ошибка при сохранении']);
+ exit;
+ }
+ }
+
+ // Обычный POST запрос (сохранение формы)
if ($chapterModel->update($id, $data)) {
$_SESSION['success'] = "Глава успешно обновлена";
$this->redirect("/books/{$chapter['book_id']}/chapters");
@@ -174,23 +205,12 @@ class ChapterController extends BaseController {
public function preview() {
$this->requireLogin();
- require_once 'includes/parsedown/ParsedownExtra.php';
- $Parsedown = new ParsedownExtra();
$content = $_POST['content'] ?? '';
$title = $_POST['title'] ?? 'Предпросмотр';
- $editor_type = $_POST['editor_type'] ?? 'markdown';
- // Обрабатываем контент в зависимости от типа редактора
- if ($editor_type == 'markdown') {
- // Нормализуем Markdown перед преобразованием
- $normalized_content = $this->normalizeMarkdownContent($content);
- $html_content = $Parsedown->text($normalized_content);
- } else {
- // Для HTML редактора нормализуем контент
- $normalized_content = $this->normalizeHtmlContent($content);
- $html_content = $normalized_content;
- }
+ // Просто используем HTML как есть
+ $html_content = $content;
$this->render('chapters/preview', [
'content' => $html_content,
@@ -199,114 +219,5 @@ class ChapterController extends BaseController {
]);
}
- private function normalizeMarkdownContent($markdown) {
- // Нормализация Markdown - убеждаемся, что есть пустые строки между абзацами
- $lines = explode("\n", $markdown);
- $normalized = [];
- $inParagraph = false;
-
- foreach ($lines as $line) {
- $trimmed = trim($line);
-
- if (empty($trimmed)) {
- // Пустая строка - конец абзаца
- if ($inParagraph) {
- $normalized[] = '';
- $inParagraph = false;
- }
- continue;
- }
-
- // Проверяем, не является ли строка началом списка
- if (preg_match('/^[\*\-\+] /', $line) || preg_match('/^\d+\./', $line)) {
- if ($inParagraph) {
- $normalized[] = ''; // Завершаем предыдущий абзац
- $inParagraph = false;
- }
- $normalized[] = $line;
- continue;
- }
-
- // Проверяем, не является ли строка началом цитаты
- if (preg_match('/^> /', $line) || preg_match('/^— /', $line)) {
- if ($inParagraph) {
- $normalized[] = ''; // Завершаем предыдущий абзац
- $inParagraph = false;
- }
- $normalized[] = $line;
- continue;
- }
-
- // Проверяем, не является ли строка заголовком
- if (preg_match('/^#+ /', $line)) {
- if ($inParagraph) {
- $normalized[] = ''; // Завершаем предыдущий абзац
- $inParagraph = false;
- }
- $normalized[] = $line;
- $normalized[] = ''; // Пустая строка после заголовка
- continue;
- }
-
- // Непустая строка - часть абзаца
- if (!$inParagraph && !empty($normalized) && end($normalized) !== '') {
- // Добавляем пустую строку перед новым абзацем
- $normalized[] = '';
- }
-
- $normalized[] = $line;
- $inParagraph = true;
- }
-
- return implode("\n", $normalized);
- }
-
- // И метод для нормализации HTML контента
- private function normalizeHtmlContent($html) {
- // Оборачиваем текст без тегов в
- if (!preg_match('/<[^>]+>/', $html) && trim($html) !== '') {
- $lines = explode("\n", trim($html));
- $wrapped = [];
- $inParagraph = false;
-
- foreach ($lines as $line) {
- $trimmed = trim($line);
-
- if (empty($trimmed)) {
- if ($inParagraph) {
- $wrapped[] = '
';
- $inParagraph = false;
- }
- continue;
- }
-
- // Проверяем на начало списка
- if (preg_match('/^[\*\-\+] /', $trimmed) || preg_match('/^\d+\./', $trimmed)) {
- if ($inParagraph) {
- $wrapped[] = '';
- $inParagraph = false;
- }
- // Обрабатываем списки отдельно
- $wrapped[] = '- ' . htmlspecialchars($trimmed) . '
';
- continue;
- }
-
- if (!$inParagraph) {
- $wrapped[] = '' . htmlspecialchars($trimmed);
- $inParagraph = true;
- } else {
- $wrapped[] = htmlspecialchars($trimmed);
- }
- }
-
- if ($inParagraph) {
- $wrapped[] = '
';
- }
-
- return implode("\n", $wrapped);
- }
-
- return $html;
- }
}
?>
\ No newline at end of file
diff --git a/controllers/DashboardController.php b/controllers/DashboardController.php
old mode 100644
new mode 100755
diff --git a/controllers/ExportController.php b/controllers/ExportController.php
old mode 100644
new mode 100755
index da07a15..6be8934
--- a/controllers/ExportController.php
+++ b/controllers/ExportController.php
@@ -4,7 +4,6 @@ require_once 'controllers/BaseController.php';
require_once 'models/Book.php';
require_once 'models/Chapter.php';
require_once 'vendor/autoload.php';
-require_once 'includes/parsedown/ParsedownExtra.php';
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
@@ -69,20 +68,20 @@ class ExportController extends BaseController {
}
private function handleExport($book, $chapters, $is_public, $author_name, $format) {
- $Parsedown = new ParsedownExtra();
+
switch ($format) {
case 'pdf':
- $this->exportPDF($book, $chapters, $is_public, $author_name, $Parsedown);
+ $this->exportPDF($book, $chapters, $is_public, $author_name);
break;
case 'docx':
- $this->exportDOCX($book, $chapters, $is_public, $author_name, $Parsedown);
+ $this->exportDOCX($book, $chapters, $is_public, $author_name);
break;
case 'html':
- $this->exportHTML($book, $chapters, $is_public, $author_name, $Parsedown);
+ $this->exportHTML($book, $chapters, $is_public, $author_name);
break;
case 'txt':
- $this->exportTXT($book, $chapters, $is_public, $author_name, $Parsedown);
+ $this->exportTXT($book, $chapters, $is_public, $author_name);
break;
default:
$_SESSION['error'] = "Неверный формат экспорта";
@@ -94,7 +93,7 @@ class ExportController extends BaseController {
}
function exportPDF($book, $chapters, $is_public, $author_name) {
- global $Parsedown;
+
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
@@ -200,11 +199,9 @@ class ExportController extends BaseController {
// Контент главы
$pdf->SetFont('dejavusans', '', 11);
- if ($book['editor_type'] == 'markdown') {
- $htmlContent = $Parsedown->text($chapter['content']);
- } else {
- $htmlContent = $chapter['content'];
- }
+
+ $htmlContent = $chapter['content'];
+
$pdf->writeHTML($htmlContent, true, false, true, false, '');
$pdf->Ln(8);
@@ -223,8 +220,7 @@ class ExportController extends BaseController {
}
function exportDOCX($book, $chapters, $is_public, $author_name) {
- global $Parsedown;
-
+
$phpWord = new PhpWord();
// Стили документа
@@ -263,11 +259,8 @@ class ExportController extends BaseController {
// Описание
if (!empty($book['description'])) {
- if ($book['editor_type'] == 'markdown') {
- $descriptionParagraphs = $this->markdownToParagraphs($book['description']);
- } else {
- $descriptionParagraphs = $this->htmlToParagraphs($book['description']);
- }
+
+ $descriptionParagraphs = $this->htmlToParagraphs($book['description']);
foreach ($descriptionParagraphs as $paragraph) {
if (!empty(trim($paragraph))) {
@@ -303,14 +296,11 @@ class ExportController extends BaseController {
$section->addText($chapter['title'], ['bold' => true, 'size' => 14]);
$section->addTextBreak(1);
- // Получаем очищенный текст и разбиваем на абзацы в зависимости от типа редактора
- if ($book['editor_type'] == 'markdown') {
- $cleanContent = $this->cleanMarkdown($chapter['content']);
- $paragraphs = $this->markdownToParagraphs($cleanContent);
- } else {
- $cleanContent = strip_tags($chapter['content']);
- $paragraphs = $this->htmlToParagraphs($chapter['content']);
- }
+ // Получаем очищенный текст и разбиваем на абзацы
+
+ $cleanContent = strip_tags($chapter['content']);
+ $paragraphs = $this->htmlToParagraphs($chapter['content']);
+
// Добавляем каждый абзац
foreach ($paragraphs as $paragraph) {
@@ -342,7 +332,6 @@ class ExportController extends BaseController {
}
function exportHTML($book, $chapters, $is_public, $author_name) {
- global $Parsedown;
$html = '
@@ -520,11 +509,7 @@ class ExportController extends BaseController {
if (!empty($book['description'])) {
$html .= '';
- if ($book['editor_type'] == 'markdown') {
- $html .= nl2br(htmlspecialchars($book['description']));
- } else {
- $html .= $book['description'];
- }
+ $html .= $book['description'];
$html .= '
';
}
@@ -546,15 +531,7 @@ class ExportController extends BaseController {
foreach ($chapters as $index => $chapter) {
$html .= '';
$html .= '
' . htmlspecialchars($chapter['title']) . '
';
-
- // Обрабатываем контент в зависимости от типа редактора
- if ($book['editor_type'] == 'markdown') {
- $htmlContent = $Parsedown->text($chapter['content']);
- } else {
- $htmlContent = $chapter['content'];
- }
-
- $html .= '
' . $htmlContent . '
';
+ $html .= '
' . $chapter['content']. '
';
$html .= '
';
if ($index < count($chapters) - 1) {
@@ -589,13 +566,8 @@ class ExportController extends BaseController {
if (!empty($book['description'])) {
$content .= "ОПИСАНИЕ:\n";
- // Обрабатываем описание в зависимости от типа редактора
- if ($book['editor_type'] == 'markdown') {
- $descriptionText = $this->cleanMarkdown($book['description']);
- } else {
- $descriptionText = strip_tags($book['description']);
- }
-
+ // Обрабатываем описание
+ $descriptionText = strip_tags($book['description']);
$content .= wordwrap($descriptionText, 144) . "\n\n";
}
@@ -616,14 +588,9 @@ class ExportController extends BaseController {
$content .= $chapter['title'] . "\n";
$content .= str_repeat("-", 60) . "\n\n";
- // Получаем очищенный текст в зависимости от типа редактора
- if ($book['editor_type'] == 'markdown') {
- $cleanContent = $this->cleanMarkdown($chapter['content']);
- $paragraphs = $this->markdownToParagraphs($cleanContent);
- } else {
- $cleanContent = strip_tags($chapter['content']);
- $paragraphs = $this->htmlToPlainTextParagraphs($cleanContent);
- }
+ // Получаем очищенный текст
+ $cleanContent = strip_tags($chapter['content']);
+ $paragraphs = $this->htmlToPlainTextParagraphs($cleanContent);
foreach ($paragraphs as $paragraph) {
if (!empty(trim($paragraph))) {
@@ -648,180 +615,7 @@ class ExportController extends BaseController {
exit;
}
-
- // Функция для преобразования Markdown в чистый текст с форматированием абзацев
- function markdownToPlainText($markdown) {
- // Обрабатываем диалоги (заменяем - на —)
- $markdown = preg_replace('/^- (.+)$/m', "— $1", $markdown);
-
- // Убираем Markdown разметку, но сохраняем переносы строк
- $text = $markdown;
-
- // Убираем заголовки
- $text = preg_replace('/^#+\s+/m', '', $text);
-
- // Убираем жирный и курсив
- $text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
- $text = preg_replace('/\*(.*?)\*/', '$1', $text);
- $text = preg_replace('/__(.*?)__/', '$1', $text);
- $text = preg_replace('/_(.*?)_/', '$1', $text);
-
- // Убираем зачеркивание
- $text = preg_replace('/~~(.*?)~~/', '$1', $text);
-
- // Убираем код (встроенный)
- $text = preg_replace('/`(.*?)`/', '$1', $text);
-
- // Убираем блоки кода (сохраняем содержимое)
- $text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
-
- // Убираем ссылки
- $text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
-
- // Обрабатываем списки - заменяем маркеры на *
- $text = preg_replace('/^[\*\-+]\s+/m', '* ', $text);
- $text = preg_replace('/^\d+\.\s+/m', '* ', $text);
-
- // Обрабатываем цитаты
- $text = preg_replace('/^>\s+/m', '', $text);
-
- return $text;
- }
- // Функция для разбивки Markdown на абзацы с сохранением структуры
- function markdownToParagraphs($markdown) {
- // Нормализуем переносы строк
- $text = str_replace(["\r\n", "\r"], "\n", $markdown);
-
- // Обрабатываем диалоги (заменяем - на —)
- $text = preg_replace('/^- (.+)$/m', "— $1", $text);
-
- // Разбиваем на строки
- $lines = explode("\n", $text);
- $paragraphs = [];
- $currentParagraph = '';
-
- foreach ($lines as $line) {
- $trimmedLine = trim($line);
-
- // Пустая строка - конец абзаца
- if (empty($trimmedLine)) {
- if (!empty($currentParagraph)) {
- $paragraphs[] = $currentParagraph;
- $currentParagraph = '';
- }
- continue;
- }
-
- // Диалог (начинается с —) всегда начинает новый абзац
- if (str_starts_with($trimmedLine, '—')) {
- if (!empty($currentParagraph)) {
- $paragraphs[] = $currentParagraph;
- }
- $currentParagraph = $trimmedLine;
- $paragraphs[] = $currentParagraph;
- $currentParagraph = '';
- continue;
- }
-
- // Заголовки (начинаются с #) всегда начинают новый абзац
- if (str_starts_with($trimmedLine, '#')) {
- if (!empty($currentParagraph)) {
- $paragraphs[] = $currentParagraph;
- }
- $currentParagraph = preg_replace('/^#+\s+/', '', $trimmedLine);
- $paragraphs[] = $currentParagraph;
- $currentParagraph = '';
- continue;
- }
-
- // Обычный текст - добавляем к текущему абзацу
- if (!empty($currentParagraph)) {
- $currentParagraph .= ' ' . $trimmedLine;
- } else {
- $currentParagraph = $trimmedLine;
- }
- }
-
- // Добавляем последний абзац
- if (!empty($currentParagraph)) {
- $paragraphs[] = $currentParagraph;
- }
-
- return $paragraphs;
- }
-
- // Функция для очистки Markdown разметки
- function cleanMarkdown($markdown) {
- $text = $markdown;
-
- // Убираем заголовки
- $text = preg_replace('/^#+\s+/m', '', $text);
-
- // Убираем жирный и курсив
- $text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
- $text = preg_replace('/\*(.*?)\*/', '$1', $text);
- $text = preg_replace('/__(.*?)__/', '$1', $text);
- $text = preg_replace('/_(.*?)_/', '$1', $text);
-
- // Убираем зачеркивание
- $text = preg_replace('/~~(.*?)~~/', '$1', $text);
-
- // Убираем код
- $text = preg_replace('/`(.*?)`/', '$1', $text);
- $text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
-
- // Убираем ссылки
- $text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
-
- // Обрабатываем списки - убираем маркеры
- $text = preg_replace('/^[\*\-+]\s+/m', '', $text);
- $text = preg_replace('/^\d+\.\s+/m', '', $text);
-
- // Обрабатываем цитаты
- $text = preg_replace('/^>\s+/m', '', $text);
-
- return $text;
- }
-
- // Функция для форматирования текста с сохранением абзацев и диалогов
- function formatPlainText($text) {
- $lines = explode("\n", $text);
- $formatted = [];
- $in_paragraph = false;
-
- foreach ($lines as $line) {
- $line = trim($line);
-
- if (empty($line)) {
- if ($in_paragraph) {
- $formatted[] = ''; // Пустая строка для разделения абзацев
- $in_paragraph = false;
- }
- continue;
- }
-
- // Диалоги начинаются с —
- if (str_starts_with($line, '—')) {
- if ($in_paragraph) {
- $formatted[] = ''; // Разделяем абзацы перед диалогом
- }
- $formatted[] = $line;
- $formatted[] = ''; // Пустая строка после диалога
- $in_paragraph = false;
- } else {
- // Обычный текст
- $formatted[] = $line;
- $in_paragraph = true;
- }
- }
-
- return implode("\n", array_filter($formatted, function($line) {
- return $line !== '' || !empty($line);
- }));
- }
-
-
- // // Новая функция для разбивки HTML на абзацы
+ // Функция для разбивки HTML на абзацы
function htmlToParagraphs($html) {
// Убираем HTML теги и нормализуем пробелы
$text = strip_tags($html);
@@ -837,6 +631,7 @@ class ExportController extends BaseController {
return $paragraphs;
}
+
function htmlToPlainTextParagraphs($html) {
// Убираем HTML теги
$text = strip_tags($html);
diff --git a/controllers/SeriesController.php b/controllers/SeriesController.php
old mode 100644
new mode 100755
index 2bc3e16..10b5504
--- a/controllers/SeriesController.php
+++ b/controllers/SeriesController.php
@@ -3,7 +3,6 @@
require_once 'controllers/BaseController.php';
require_once 'models/Series.php';
require_once 'models/Book.php';
-require_once 'includes/parsedown/ParsedownExtra.php';
class SeriesController extends BaseController {
@@ -178,17 +177,134 @@ class SeriesController extends BaseController {
$total_chapters += $book_stats['chapter_count'] ?? 0;
}
- $Parsedown = new ParsedownExtra();
-
$this->render('series/view_public', [
'series' => $series,
'books' => $books,
'author' => $author,
'total_words' => $total_words,
'total_chapters' => $total_chapters,
- 'Parsedown' => $Parsedown,
'page_title' => $series['title'] . ' — серия книг'
]);
}
+
+ public function addBook($series_id) {
+ $this->requireLogin();
+
+ $user_id = $_SESSION['user_id'];
+ $seriesModel = new Series($this->pdo);
+ $bookModel = new Book($this->pdo);
+
+ if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой серии";
+ $this->redirect('/series');
+ }
+
+ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $_SESSION['error'] = "Ошибка безопасности";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ $book_id = (int)($_POST['book_id'] ?? 0);
+ $sort_order = (int)($_POST['sort_order'] ?? 0);
+
+ if (!$book_id) {
+ $_SESSION['error'] = "Выберите книгу";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ // Проверяем, что книга принадлежит пользователю
+ if (!$bookModel->userOwnsBook($book_id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой книге";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ // Добавляем книгу в серию
+ if ($bookModel->updateSeriesInfo($book_id, $series_id, $sort_order)) {
+ $_SESSION['success'] = "Книга добавлена в серию";
+ } else {
+ $_SESSION['error'] = "Ошибка при добавлении книги в серию";
+ }
+
+ $this->redirect("/series/{$series_id}/edit");
+ }
+ }
+
+ public function removeBook($series_id, $book_id) {
+ $this->requireLogin();
+
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ $_SESSION['error'] = "Неверный метод запроса";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $_SESSION['error'] = "Ошибка безопасности";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ $user_id = $_SESSION['user_id'];
+ $seriesModel = new Series($this->pdo);
+ $bookModel = new Book($this->pdo);
+
+ if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой серии";
+ $this->redirect('/series');
+ }
+
+ // Проверяем, что книга принадлежит пользователю
+ if (!$bookModel->userOwnsBook($book_id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой книге";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ // Удаляем книгу из серии
+ if ($bookModel->removeFromSeries($book_id)) {
+ $_SESSION['success'] = "Книга удалена из серии";
+ } else {
+ $_SESSION['error'] = "Ошибка при удалении книги из серии";
+ }
+
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ public function updateBookOrder($series_id) {
+ $this->requireLogin();
+
+ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
+ $_SESSION['error'] = "Неверный метод запроса";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
+ $_SESSION['error'] = "Ошибка безопасности";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ $user_id = $_SESSION['user_id'];
+ $seriesModel = new Series($this->pdo);
+ $bookModel = new Book($this->pdo);
+
+ if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
+ $_SESSION['error'] = "У вас нет доступа к этой серии";
+ $this->redirect('/series');
+ }
+
+ $order_data = $_POST['order'] ?? [];
+
+ if (empty($order_data)) {
+ $_SESSION['error'] = "Нет данных для обновления";
+ $this->redirect("/series/{$series_id}/edit");
+ }
+
+ // Обновляем порядок книг
+ if ($bookModel->reorderSeriesBooks($series_id, $order_data)) {
+ $_SESSION['success'] = "Порядок книг обновлен";
+ } else {
+ $_SESSION['error'] = "Ошибка при обновлении порядка книг";
+ }
+
+ $this->redirect("/series/{$series_id}/edit");
+ }
}
?>
\ No newline at end of file
diff --git a/controllers/UserController.php b/controllers/UserController.php
old mode 100644
new mode 100755
index 6306ce4..27c7e67
--- a/controllers/UserController.php
+++ b/controllers/UserController.php
@@ -3,7 +3,6 @@
require_once 'controllers/BaseController.php';
require_once 'models/User.php';
require_once 'models/Book.php';
-require_once 'includes/parsedown/ParsedownExtra.php';
class UserController extends BaseController {
@@ -101,7 +100,7 @@ class UserController extends BaseController {
$total_chapters += $book_stats['chapter_count'] ?? 0;
}
- $Parsedown = new ParsedownExtra();
+
$this->render('user/view_public', [
'user' => $user,
@@ -109,7 +108,6 @@ class UserController extends BaseController {
'total_books' => $total_books,
'total_words' => $total_words,
'total_chapters' => $total_chapters,
- 'Parsedown' => $Parsedown,
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
]);
}
diff --git a/includes/index.php b/includes/index.php
old mode 100644
new mode 100755
diff --git a/includes/parsedown/Parsedown.php b/includes/parsedown/Parsedown.php
deleted file mode 100755
index 38edfe9..0000000
--- a/includes/parsedown/Parsedown.php
+++ /dev/null
@@ -1,1994 +0,0 @@
-textElements($text);
-
- # convert to markup
- $markup = $this->elements($Elements);
-
- # trim line breaks
- $markup = trim($markup, "\n");
-
- return $markup;
- }
-
- protected function textElements($text)
- {
- # make sure no definitions are set
- $this->DefinitionData = array();
-
- # standardize line breaks
- $text = str_replace(array("\r\n", "\r"), "\n", $text);
-
- # remove surrounding line breaks
- $text = trim($text, "\n");
-
- # split text into lines
- $lines = explode("\n", $text);
-
- # iterate through lines to identify blocks
- return $this->linesElements($lines);
- }
-
- #
- # Setters
- #
-
- function setBreaksEnabled($breaksEnabled)
- {
- $this->breaksEnabled = $breaksEnabled;
-
- return $this;
- }
-
- protected $breaksEnabled;
-
- function setMarkupEscaped($markupEscaped)
- {
- $this->markupEscaped = $markupEscaped;
-
- return $this;
- }
-
- protected $markupEscaped;
-
- function setUrlsLinked($urlsLinked)
- {
- $this->urlsLinked = $urlsLinked;
-
- return $this;
- }
-
- protected $urlsLinked = true;
-
- function setSafeMode($safeMode)
- {
- $this->safeMode = (bool) $safeMode;
-
- return $this;
- }
-
- protected $safeMode;
-
- function setStrictMode($strictMode)
- {
- $this->strictMode = (bool) $strictMode;
-
- return $this;
- }
-
- protected $strictMode;
-
- protected $safeLinksWhitelist = array(
- 'http://',
- 'https://',
- 'ftp://',
- 'ftps://',
- 'mailto:',
- 'tel:',
- 'data:image/png;base64,',
- 'data:image/gif;base64,',
- 'data:image/jpeg;base64,',
- 'irc:',
- 'ircs:',
- 'git:',
- 'ssh:',
- 'news:',
- 'steam:',
- );
-
- #
- # Lines
- #
-
- protected $BlockTypes = array(
- '#' => array('Header'),
- '*' => array('Rule', 'List'),
- '+' => array('List'),
- '-' => array('SetextHeader', 'Table', 'Rule', 'List'),
- '0' => array('List'),
- '1' => array('List'),
- '2' => array('List'),
- '3' => array('List'),
- '4' => array('List'),
- '5' => array('List'),
- '6' => array('List'),
- '7' => array('List'),
- '8' => array('List'),
- '9' => array('List'),
- ':' => array('Table'),
- '<' => array('Comment', 'Markup'),
- '=' => array('SetextHeader'),
- '>' => array('Quote'),
- '[' => array('Reference'),
- '_' => array('Rule'),
- '`' => array('FencedCode'),
- '|' => array('Table'),
- '~' => array('FencedCode'),
- );
-
- # ~
-
- protected $unmarkedBlockTypes = array(
- 'Code',
- );
-
- #
- # Blocks
- #
-
- protected function lines(array $lines)
- {
- return $this->elements($this->linesElements($lines));
- }
-
- protected function linesElements(array $lines)
- {
- $Elements = array();
- $CurrentBlock = null;
-
- foreach ($lines as $line)
- {
- if (chop($line) === '')
- {
- if (isset($CurrentBlock))
- {
- $CurrentBlock['interrupted'] = (isset($CurrentBlock['interrupted'])
- ? $CurrentBlock['interrupted'] + 1 : 1
- );
- }
-
- continue;
- }
-
- while (($beforeTab = strstr($line, "\t", true)) !== false)
- {
- $shortage = 4 - mb_strlen($beforeTab, 'utf-8') % 4;
-
- $line = $beforeTab
- . str_repeat(' ', $shortage)
- . substr($line, strlen($beforeTab) + 1)
- ;
- }
-
- $indent = strspn($line, ' ');
-
- $text = $indent > 0 ? substr($line, $indent) : $line;
-
- # ~
-
- $Line = array('body' => $line, 'indent' => $indent, 'text' => $text);
-
- # ~
-
- if (isset($CurrentBlock['continuable']))
- {
- $methodName = 'block' . $CurrentBlock['type'] . 'Continue';
- $Block = $this->$methodName($Line, $CurrentBlock);
-
- if (isset($Block))
- {
- $CurrentBlock = $Block;
-
- continue;
- }
- else
- {
- if ($this->isBlockCompletable($CurrentBlock['type']))
- {
- $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
- $CurrentBlock = $this->$methodName($CurrentBlock);
- }
- }
- }
-
- # ~
-
- $marker = $text[0];
-
- # ~
-
- $blockTypes = $this->unmarkedBlockTypes;
-
- if (isset($this->BlockTypes[$marker]))
- {
- foreach ($this->BlockTypes[$marker] as $blockType)
- {
- $blockTypes []= $blockType;
- }
- }
-
- #
- # ~
-
- foreach ($blockTypes as $blockType)
- {
- $Block = $this->{"block$blockType"}($Line, $CurrentBlock);
-
- if (isset($Block))
- {
- $Block['type'] = $blockType;
-
- if ( ! isset($Block['identified']))
- {
- if (isset($CurrentBlock))
- {
- $Elements[] = $this->extractElement($CurrentBlock);
- }
-
- $Block['identified'] = true;
- }
-
- if ($this->isBlockContinuable($blockType))
- {
- $Block['continuable'] = true;
- }
-
- $CurrentBlock = $Block;
-
- continue 2;
- }
- }
-
- # ~
-
- if (isset($CurrentBlock) and $CurrentBlock['type'] === 'Paragraph')
- {
- $Block = $this->paragraphContinue($Line, $CurrentBlock);
- }
-
- if (isset($Block))
- {
- $CurrentBlock = $Block;
- }
- else
- {
- if (isset($CurrentBlock))
- {
- $Elements[] = $this->extractElement($CurrentBlock);
- }
-
- $CurrentBlock = $this->paragraph($Line);
-
- $CurrentBlock['identified'] = true;
- }
- }
-
- # ~
-
- if (isset($CurrentBlock['continuable']) and $this->isBlockCompletable($CurrentBlock['type']))
- {
- $methodName = 'block' . $CurrentBlock['type'] . 'Complete';
- $CurrentBlock = $this->$methodName($CurrentBlock);
- }
-
- # ~
-
- if (isset($CurrentBlock))
- {
- $Elements[] = $this->extractElement($CurrentBlock);
- }
-
- # ~
-
- return $Elements;
- }
-
- protected function extractElement(array $Component)
- {
- if ( ! isset($Component['element']))
- {
- if (isset($Component['markup']))
- {
- $Component['element'] = array('rawHtml' => $Component['markup']);
- }
- elseif (isset($Component['hidden']))
- {
- $Component['element'] = array();
- }
- }
-
- return $Component['element'];
- }
-
- protected function isBlockContinuable($Type)
- {
- return method_exists($this, 'block' . $Type . 'Continue');
- }
-
- protected function isBlockCompletable($Type)
- {
- return method_exists($this, 'block' . $Type . 'Complete');
- }
-
- #
- # Code
-
- protected function blockCode($Line, $Block = null)
- {
- if (isset($Block) and $Block['type'] === 'Paragraph' and ! isset($Block['interrupted']))
- {
- return;
- }
-
- if ($Line['indent'] >= 4)
- {
- $text = substr($Line['body'], 4);
-
- $Block = array(
- 'element' => array(
- 'name' => 'pre',
- 'element' => array(
- 'name' => 'code',
- 'text' => $text,
- ),
- ),
- );
-
- return $Block;
- }
- }
-
- protected function blockCodeContinue($Line, $Block)
- {
- if ($Line['indent'] >= 4)
- {
- if (isset($Block['interrupted']))
- {
- $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
-
- unset($Block['interrupted']);
- }
-
- $Block['element']['element']['text'] .= "\n";
-
- $text = substr($Line['body'], 4);
-
- $Block['element']['element']['text'] .= $text;
-
- return $Block;
- }
- }
-
- protected function blockCodeComplete($Block)
- {
- return $Block;
- }
-
- #
- # Comment
-
- protected function blockComment($Line)
- {
- if ($this->markupEscaped or $this->safeMode)
- {
- return;
- }
-
- if (strpos($Line['text'], '') !== false)
- {
- $Block['closed'] = true;
- }
-
- return $Block;
- }
- }
-
- protected function blockCommentContinue($Line, array $Block)
- {
- if (isset($Block['closed']))
- {
- return;
- }
-
- $Block['element']['rawHtml'] .= "\n" . $Line['body'];
-
- if (strpos($Line['text'], '-->') !== false)
- {
- $Block['closed'] = true;
- }
-
- return $Block;
- }
-
- #
- # Fenced Code
-
- protected function blockFencedCode($Line)
- {
- $marker = $Line['text'][0];
-
- $openerLength = strspn($Line['text'], $marker);
-
- if ($openerLength < 3)
- {
- return;
- }
-
- $infostring = trim(substr($Line['text'], $openerLength), "\t ");
-
- if (strpos($infostring, '`') !== false)
- {
- return;
- }
-
- $Element = array(
- 'name' => 'code',
- 'text' => '',
- );
-
- if ($infostring !== '')
- {
- /**
- * https://www.w3.org/TR/2011/WD-html5-20110525/elements.html#classes
- * Every HTML element may have a class attribute specified.
- * The attribute, if specified, must have a value that is a set
- * of space-separated tokens representing the various classes
- * that the element belongs to.
- * [...]
- * The space characters, for the purposes of this specification,
- * are U+0020 SPACE, U+0009 CHARACTER TABULATION (tab),
- * U+000A LINE FEED (LF), U+000C FORM FEED (FF), and
- * U+000D CARRIAGE RETURN (CR).
- */
- $language = substr($infostring, 0, strcspn($infostring, " \t\n\f\r"));
-
- $Element['attributes'] = array('class' => "language-$language");
- }
-
- $Block = array(
- 'char' => $marker,
- 'openerLength' => $openerLength,
- 'element' => array(
- 'name' => 'pre',
- 'element' => $Element,
- ),
- );
-
- return $Block;
- }
-
- protected function blockFencedCodeContinue($Line, $Block)
- {
- if (isset($Block['complete']))
- {
- return;
- }
-
- if (isset($Block['interrupted']))
- {
- $Block['element']['element']['text'] .= str_repeat("\n", $Block['interrupted']);
-
- unset($Block['interrupted']);
- }
-
- if (($len = strspn($Line['text'], $Block['char'])) >= $Block['openerLength']
- and chop(substr($Line['text'], $len), ' ') === ''
- ) {
- $Block['element']['element']['text'] = substr($Block['element']['element']['text'], 1);
-
- $Block['complete'] = true;
-
- return $Block;
- }
-
- $Block['element']['element']['text'] .= "\n" . $Line['body'];
-
- return $Block;
- }
-
- protected function blockFencedCodeComplete($Block)
- {
- return $Block;
- }
-
- #
- # Header
-
- protected function blockHeader($Line)
- {
- $level = strspn($Line['text'], '#');
-
- if ($level > 6)
- {
- return;
- }
-
- $text = trim($Line['text'], '#');
-
- if ($this->strictMode and isset($text[0]) and $text[0] !== ' ')
- {
- return;
- }
-
- $text = trim($text, ' ');
-
- $Block = array(
- 'element' => array(
- 'name' => 'h' . $level,
- 'handler' => array(
- 'function' => 'lineElements',
- 'argument' => $text,
- 'destination' => 'elements',
- )
- ),
- );
-
- return $Block;
- }
-
- #
- # List
-
- protected function blockList($Line, ?array $CurrentBlock = null)
- {
- list($name, $pattern) = $Line['text'][0] <= '-' ? array('ul', '[*+-]') : array('ol', '[0-9]{1,9}+[.\)]');
-
- if (preg_match('/^('.$pattern.'([ ]++|$))(.*+)/', $Line['text'], $matches))
- {
- $contentIndent = strlen($matches[2]);
-
- if ($contentIndent >= 5)
- {
- $contentIndent -= 1;
- $matches[1] = substr($matches[1], 0, -$contentIndent);
- $matches[3] = str_repeat(' ', $contentIndent) . $matches[3];
- }
- elseif ($contentIndent === 0)
- {
- $matches[1] .= ' ';
- }
-
- $markerWithoutWhitespace = strstr($matches[1], ' ', true);
-
- $Block = array(
- 'indent' => $Line['indent'],
- 'pattern' => $pattern,
- 'data' => array(
- 'type' => $name,
- 'marker' => $matches[1],
- 'markerType' => ($name === 'ul' ? $markerWithoutWhitespace : substr($markerWithoutWhitespace, -1)),
- ),
- 'element' => array(
- 'name' => $name,
- 'elements' => array(),
- ),
- );
- $Block['data']['markerTypeRegex'] = preg_quote($Block['data']['markerType'], '/');
-
- if ($name === 'ol')
- {
- $listStart = ltrim(strstr($matches[1], $Block['data']['markerType'], true), '0') ?: '0';
-
- if ($listStart !== '1')
- {
- if (
- isset($CurrentBlock)
- and $CurrentBlock['type'] === 'Paragraph'
- and ! isset($CurrentBlock['interrupted'])
- ) {
- return;
- }
-
- $Block['element']['attributes'] = array('start' => $listStart);
- }
- }
-
- $Block['li'] = array(
- 'name' => 'li',
- 'handler' => array(
- 'function' => 'li',
- 'argument' => !empty($matches[3]) ? array($matches[3]) : array(),
- 'destination' => 'elements'
- )
- );
-
- $Block['element']['elements'] []= & $Block['li'];
-
- return $Block;
- }
- }
-
- protected function blockListContinue($Line, array $Block)
- {
- if (isset($Block['interrupted']) and empty($Block['li']['handler']['argument']))
- {
- return null;
- }
-
- $requiredIndent = ($Block['indent'] + strlen($Block['data']['marker']));
-
- if ($Line['indent'] < $requiredIndent
- and (
- (
- $Block['data']['type'] === 'ol'
- and preg_match('/^[0-9]++'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
- ) or (
- $Block['data']['type'] === 'ul'
- and preg_match('/^'.$Block['data']['markerTypeRegex'].'(?:[ ]++(.*)|$)/', $Line['text'], $matches)
- )
- )
- ) {
- if (isset($Block['interrupted']))
- {
- $Block['li']['handler']['argument'] []= '';
-
- $Block['loose'] = true;
-
- unset($Block['interrupted']);
- }
-
- unset($Block['li']);
-
- $text = isset($matches[1]) ? $matches[1] : '';
-
- $Block['indent'] = $Line['indent'];
-
- $Block['li'] = array(
- 'name' => 'li',
- 'handler' => array(
- 'function' => 'li',
- 'argument' => array($text),
- 'destination' => 'elements'
- )
- );
-
- $Block['element']['elements'] []= & $Block['li'];
-
- return $Block;
- }
- elseif ($Line['indent'] < $requiredIndent and $this->blockList($Line))
- {
- return null;
- }
-
- if ($Line['text'][0] === '[' and $this->blockReference($Line))
- {
- return $Block;
- }
-
- if ($Line['indent'] >= $requiredIndent)
- {
- if (isset($Block['interrupted']))
- {
- $Block['li']['handler']['argument'] []= '';
-
- $Block['loose'] = true;
-
- unset($Block['interrupted']);
- }
-
- $text = substr($Line['body'], $requiredIndent);
-
- $Block['li']['handler']['argument'] []= $text;
-
- return $Block;
- }
-
- if ( ! isset($Block['interrupted']))
- {
- $text = preg_replace('/^[ ]{0,'.$requiredIndent.'}+/', '', $Line['body']);
-
- $Block['li']['handler']['argument'] []= $text;
-
- return $Block;
- }
- }
-
- protected function blockListComplete(array $Block)
- {
- if (isset($Block['loose']))
- {
- foreach ($Block['element']['elements'] as &$li)
- {
- if (end($li['handler']['argument']) !== '')
- {
- $li['handler']['argument'] []= '';
- }
- }
- }
-
- return $Block;
- }
-
- #
- # Quote
-
- protected function blockQuote($Line)
- {
- if (preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
- {
- $Block = array(
- 'element' => array(
- 'name' => 'blockquote',
- 'handler' => array(
- 'function' => 'linesElements',
- 'argument' => (array) $matches[1],
- 'destination' => 'elements',
- )
- ),
- );
-
- return $Block;
- }
- }
-
- protected function blockQuoteContinue($Line, array $Block)
- {
- if (isset($Block['interrupted']))
- {
- return;
- }
-
- if ($Line['text'][0] === '>' and preg_match('/^>[ ]?+(.*+)/', $Line['text'], $matches))
- {
- $Block['element']['handler']['argument'] []= $matches[1];
-
- return $Block;
- }
-
- if ( ! isset($Block['interrupted']))
- {
- $Block['element']['handler']['argument'] []= $Line['text'];
-
- return $Block;
- }
- }
-
- #
- # Rule
-
- protected function blockRule($Line)
- {
- $marker = $Line['text'][0];
-
- if (substr_count($Line['text'], $marker) >= 3 and chop($Line['text'], " $marker") === '')
- {
- $Block = array(
- 'element' => array(
- 'name' => 'hr',
- ),
- );
-
- return $Block;
- }
- }
-
- #
- # Setext
-
- protected function blockSetextHeader($Line, ?array $Block = null)
- {
- if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
- {
- return;
- }
-
- if ($Line['indent'] < 4 and chop(chop($Line['text'], ' '), $Line['text'][0]) === '')
- {
- $Block['element']['name'] = $Line['text'][0] === '=' ? 'h1' : 'h2';
-
- return $Block;
- }
- }
-
- #
- # Markup
-
- protected function blockMarkup($Line)
- {
- if ($this->markupEscaped or $this->safeMode)
- {
- return;
- }
-
- if (preg_match('/^<[\/]?+(\w*)(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+(\/)?>/', $Line['text'], $matches))
- {
- $element = strtolower($matches[1]);
-
- if (in_array($element, $this->textLevelElements))
- {
- return;
- }
-
- $Block = array(
- 'name' => $matches[1],
- 'element' => array(
- 'rawHtml' => $Line['text'],
- 'autobreak' => true,
- ),
- );
-
- return $Block;
- }
- }
-
- protected function blockMarkupContinue($Line, array $Block)
- {
- if (isset($Block['closed']) or isset($Block['interrupted']))
- {
- return;
- }
-
- $Block['element']['rawHtml'] .= "\n" . $Line['body'];
-
- return $Block;
- }
-
- #
- # Reference
-
- protected function blockReference($Line)
- {
- if (strpos($Line['text'], ']') !== false
- and preg_match('/^\[(.+?)\]:[ ]*+(\S+?)>?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $Line['text'], $matches)
- ) {
- $id = strtolower($matches[1]);
-
- $Data = array(
- 'url' => $matches[2],
- 'title' => isset($matches[3]) ? $matches[3] : null,
- );
-
- $this->DefinitionData['Reference'][$id] = $Data;
-
- $Block = array(
- 'element' => array(),
- );
-
- return $Block;
- }
- }
-
- #
- # Table
-
- protected function blockTable($Line, ?array $Block = null)
- {
- if ( ! isset($Block) or $Block['type'] !== 'Paragraph' or isset($Block['interrupted']))
- {
- return;
- }
-
- if (
- strpos($Block['element']['handler']['argument'], '|') === false
- and strpos($Line['text'], '|') === false
- and strpos($Line['text'], ':') === false
- or strpos($Block['element']['handler']['argument'], "\n") !== false
- ) {
- return;
- }
-
- if (chop($Line['text'], ' -:|') !== '')
- {
- return;
- }
-
- $alignments = array();
-
- $divider = $Line['text'];
-
- $divider = trim($divider);
- $divider = trim($divider, '|');
-
- $dividerCells = explode('|', $divider);
-
- foreach ($dividerCells as $dividerCell)
- {
- $dividerCell = trim($dividerCell);
-
- if ($dividerCell === '')
- {
- return;
- }
-
- $alignment = null;
-
- if ($dividerCell[0] === ':')
- {
- $alignment = 'left';
- }
-
- if (substr($dividerCell, - 1) === ':')
- {
- $alignment = $alignment === 'left' ? 'center' : 'right';
- }
-
- $alignments []= $alignment;
- }
-
- # ~
-
- $HeaderElements = array();
-
- $header = $Block['element']['handler']['argument'];
-
- $header = trim($header);
- $header = trim($header, '|');
-
- $headerCells = explode('|', $header);
-
- if (count($headerCells) !== count($alignments))
- {
- return;
- }
-
- foreach ($headerCells as $index => $headerCell)
- {
- $headerCell = trim($headerCell);
-
- $HeaderElement = array(
- 'name' => 'th',
- 'handler' => array(
- 'function' => 'lineElements',
- 'argument' => $headerCell,
- 'destination' => 'elements',
- )
- );
-
- if (isset($alignments[$index]))
- {
- $alignment = $alignments[$index];
-
- $HeaderElement['attributes'] = array(
- 'style' => "text-align: $alignment;",
- );
- }
-
- $HeaderElements []= $HeaderElement;
- }
-
- # ~
-
- $Block = array(
- 'alignments' => $alignments,
- 'identified' => true,
- 'element' => array(
- 'name' => 'table',
- 'elements' => array(),
- ),
- );
-
- $Block['element']['elements'] []= array(
- 'name' => 'thead',
- );
-
- $Block['element']['elements'] []= array(
- 'name' => 'tbody',
- 'elements' => array(),
- );
-
- $Block['element']['elements'][0]['elements'] []= array(
- 'name' => 'tr',
- 'elements' => $HeaderElements,
- );
-
- return $Block;
- }
-
- protected function blockTableContinue($Line, array $Block)
- {
- if (isset($Block['interrupted']))
- {
- return;
- }
-
- if (count($Block['alignments']) === 1 or $Line['text'][0] === '|' or strpos($Line['text'], '|'))
- {
- $Elements = array();
-
- $row = $Line['text'];
-
- $row = trim($row);
- $row = trim($row, '|');
-
- preg_match_all('/(?:(\\\\[|])|[^|`]|`[^`]++`|`)++/', $row, $matches);
-
- $cells = array_slice($matches[0], 0, count($Block['alignments']));
-
- foreach ($cells as $index => $cell)
- {
- $cell = trim($cell);
-
- $Element = array(
- 'name' => 'td',
- 'handler' => array(
- 'function' => 'lineElements',
- 'argument' => $cell,
- 'destination' => 'elements',
- )
- );
-
- if (isset($Block['alignments'][$index]))
- {
- $Element['attributes'] = array(
- 'style' => 'text-align: ' . $Block['alignments'][$index] . ';',
- );
- }
-
- $Elements []= $Element;
- }
-
- $Element = array(
- 'name' => 'tr',
- 'elements' => $Elements,
- );
-
- $Block['element']['elements'][1]['elements'] []= $Element;
-
- return $Block;
- }
- }
-
- #
- # ~
- #
-
- protected function paragraph($Line)
- {
- return array(
- 'type' => 'Paragraph',
- 'element' => array(
- 'name' => 'p',
- 'handler' => array(
- 'function' => 'lineElements',
- 'argument' => $Line['text'],
- 'destination' => 'elements',
- ),
- ),
- );
- }
-
- protected function paragraphContinue($Line, array $Block)
- {
- if (isset($Block['interrupted']))
- {
- return;
- }
-
- $Block['element']['handler']['argument'] .= "\n".$Line['text'];
-
- return $Block;
- }
-
- #
- # Inline Elements
- #
-
- protected $InlineTypes = array(
- '!' => array('Image'),
- '&' => array('SpecialCharacter'),
- '*' => array('Emphasis'),
- ':' => array('Url'),
- '<' => array('UrlTag', 'EmailTag', 'Markup'),
- '[' => array('Link'),
- '_' => array('Emphasis'),
- '`' => array('Code'),
- '~' => array('Strikethrough'),
- '\\' => array('EscapeSequence'),
- );
-
- # ~
-
- protected $inlineMarkerList = '!*_&[:<`~\\';
-
- #
- # ~
- #
-
- public function line($text, $nonNestables = array())
- {
- return $this->elements($this->lineElements($text, $nonNestables));
- }
-
- protected function lineElements($text, $nonNestables = array())
- {
- # standardize line breaks
- $text = str_replace(array("\r\n", "\r"), "\n", $text);
-
- $Elements = array();
-
- $nonNestables = (empty($nonNestables)
- ? array()
- : array_combine($nonNestables, $nonNestables)
- );
-
- # $excerpt is based on the first occurrence of a marker
-
- while ($excerpt = strpbrk($text, $this->inlineMarkerList))
- {
- $marker = $excerpt[0];
-
- $markerPosition = strlen($text) - strlen($excerpt);
-
- $Excerpt = array('text' => $excerpt, 'context' => $text);
-
- foreach ($this->InlineTypes[$marker] as $inlineType)
- {
- # check to see if the current inline type is nestable in the current context
-
- if (isset($nonNestables[$inlineType]))
- {
- continue;
- }
-
- $Inline = $this->{"inline$inlineType"}($Excerpt);
-
- if ( ! isset($Inline))
- {
- continue;
- }
-
- # makes sure that the inline belongs to "our" marker
-
- if (isset($Inline['position']) and $Inline['position'] > $markerPosition)
- {
- continue;
- }
-
- # sets a default inline position
-
- if ( ! isset($Inline['position']))
- {
- $Inline['position'] = $markerPosition;
- }
-
- # cause the new element to 'inherit' our non nestables
-
-
- $Inline['element']['nonNestables'] = isset($Inline['element']['nonNestables'])
- ? array_merge($Inline['element']['nonNestables'], $nonNestables)
- : $nonNestables
- ;
-
- # the text that comes before the inline
- $unmarkedText = substr($text, 0, $Inline['position']);
-
- # compile the unmarked text
- $InlineText = $this->inlineText($unmarkedText);
- $Elements[] = $InlineText['element'];
-
- # compile the inline
- $Elements[] = $this->extractElement($Inline);
-
- # remove the examined text
- $text = substr($text, $Inline['position'] + $Inline['extent']);
-
- continue 2;
- }
-
- # the marker does not belong to an inline
-
- $unmarkedText = substr($text, 0, $markerPosition + 1);
-
- $InlineText = $this->inlineText($unmarkedText);
- $Elements[] = $InlineText['element'];
-
- $text = substr($text, $markerPosition + 1);
- }
-
- $InlineText = $this->inlineText($text);
- $Elements[] = $InlineText['element'];
-
- foreach ($Elements as &$Element)
- {
- if ( ! isset($Element['autobreak']))
- {
- $Element['autobreak'] = false;
- }
- }
-
- return $Elements;
- }
-
- #
- # ~
- #
-
- protected function inlineText($text)
- {
- $Inline = array(
- 'extent' => strlen($text),
- 'element' => array(),
- );
-
- $Inline['element']['elements'] = self::pregReplaceElements(
- $this->breaksEnabled ? '/[ ]*+\n/' : '/(?:[ ]*+\\\\|[ ]{2,}+)\n/',
- array(
- array('name' => 'br'),
- array('text' => "\n"),
- ),
- $text
- );
-
- return $Inline;
- }
-
- protected function inlineCode($Excerpt)
- {
- $marker = $Excerpt['text'][0];
-
- if (preg_match('/^(['.$marker.']++)[ ]*+(.+?)[ ]*+(? strlen($matches[0]),
- 'element' => array(
- 'name' => 'code',
- 'text' => $text,
- ),
- );
- }
- }
-
- protected function inlineEmailTag($Excerpt)
- {
- $hostnameLabel = '[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?';
-
- $commonMarkEmail = '[a-zA-Z0-9.!#$%&\'*+\/=?^_`{|}~-]++@'
- . $hostnameLabel . '(?:\.' . $hostnameLabel . ')*';
-
- if (strpos($Excerpt['text'], '>') !== false
- and preg_match("/^<((mailto:)?$commonMarkEmail)>/i", $Excerpt['text'], $matches)
- ){
- $url = $matches[1];
-
- if ( ! isset($matches[2]))
- {
- $url = "mailto:$url";
- }
-
- return array(
- 'extent' => strlen($matches[0]),
- 'element' => array(
- 'name' => 'a',
- 'text' => $matches[1],
- 'attributes' => array(
- 'href' => $url,
- ),
- ),
- );
- }
- }
-
- protected function inlineEmphasis($Excerpt)
- {
- if ( ! isset($Excerpt['text'][1]))
- {
- return;
- }
-
- $marker = $Excerpt['text'][0];
-
- if ($Excerpt['text'][1] === $marker and preg_match($this->StrongRegex[$marker], $Excerpt['text'], $matches))
- {
- $emphasis = 'strong';
- }
- elseif (preg_match($this->EmRegex[$marker], $Excerpt['text'], $matches))
- {
- $emphasis = 'em';
- }
- else
- {
- return;
- }
-
- return array(
- 'extent' => strlen($matches[0]),
- 'element' => array(
- 'name' => $emphasis,
- 'handler' => array(
- 'function' => 'lineElements',
- 'argument' => $matches[1],
- 'destination' => 'elements',
- )
- ),
- );
- }
-
- protected function inlineEscapeSequence($Excerpt)
- {
- if (isset($Excerpt['text'][1]) and in_array($Excerpt['text'][1], $this->specialCharacters))
- {
- return array(
- 'element' => array('rawHtml' => $Excerpt['text'][1]),
- 'extent' => 2,
- );
- }
- }
-
- protected function inlineImage($Excerpt)
- {
- if ( ! isset($Excerpt['text'][1]) or $Excerpt['text'][1] !== '[')
- {
- return;
- }
-
- $Excerpt['text']= substr($Excerpt['text'], 1);
-
- $Link = $this->inlineLink($Excerpt);
-
- if ($Link === null)
- {
- return;
- }
-
- $Inline = array(
- 'extent' => $Link['extent'] + 1,
- 'element' => array(
- 'name' => 'img',
- 'attributes' => array(
- 'src' => $Link['element']['attributes']['href'],
- 'alt' => $Link['element']['handler']['argument'],
- ),
- 'autobreak' => true,
- ),
- );
-
- $Inline['element']['attributes'] += $Link['element']['attributes'];
-
- unset($Inline['element']['attributes']['href']);
-
- return $Inline;
- }
-
- protected function inlineLink($Excerpt)
- {
- $Element = array(
- 'name' => 'a',
- 'handler' => array(
- 'function' => 'lineElements',
- 'argument' => null,
- 'destination' => 'elements',
- ),
- 'nonNestables' => array('Url', 'Link'),
- 'attributes' => array(
- 'href' => null,
- 'title' => null,
- ),
- );
-
- $extent = 0;
-
- $remainder = $Excerpt['text'];
-
- if (preg_match('/\[((?:[^][]++|(?R))*+)\]/', $remainder, $matches))
- {
- $Element['handler']['argument'] = $matches[1];
-
- $extent += strlen($matches[0]);
-
- $remainder = substr($remainder, $extent);
- }
- else
- {
- return;
- }
-
- if (preg_match('/^[(]\s*+((?:[^ ()]++|[(][^ )]+[)])++)(?:[ ]+("[^"]*+"|\'[^\']*+\'))?\s*+[)]/', $remainder, $matches))
- {
- $Element['attributes']['href'] = $matches[1];
-
- if (isset($matches[2]))
- {
- $Element['attributes']['title'] = substr($matches[2], 1, - 1);
- }
-
- $extent += strlen($matches[0]);
- }
- else
- {
- if (preg_match('/^\s*\[(.*?)\]/', $remainder, $matches))
- {
- $definition = strlen($matches[1]) ? $matches[1] : $Element['handler']['argument'];
- $definition = strtolower($definition);
-
- $extent += strlen($matches[0]);
- }
- else
- {
- $definition = strtolower($Element['handler']['argument']);
- }
-
- if ( ! isset($this->DefinitionData['Reference'][$definition]))
- {
- return;
- }
-
- $Definition = $this->DefinitionData['Reference'][$definition];
-
- $Element['attributes']['href'] = $Definition['url'];
- $Element['attributes']['title'] = $Definition['title'];
- }
-
- return array(
- 'extent' => $extent,
- 'element' => $Element,
- );
- }
-
- protected function inlineMarkup($Excerpt)
- {
- if ($this->markupEscaped or $this->safeMode or strpos($Excerpt['text'], '>') === false)
- {
- return;
- }
-
- if ($Excerpt['text'][1] === '/' and preg_match('/^<\/\w[\w-]*+[ ]*+>/s', $Excerpt['text'], $matches))
- {
- return array(
- 'element' => array('rawHtml' => $matches[0]),
- 'extent' => strlen($matches[0]),
- );
- }
-
- if ($Excerpt['text'][1] === '!' and preg_match('/^/s', $Excerpt['text'], $matches))
- {
- return array(
- 'element' => array('rawHtml' => $matches[0]),
- 'extent' => strlen($matches[0]),
- );
- }
-
- if ($Excerpt['text'][1] !== ' ' and preg_match('/^<\w[\w-]*+(?:[ ]*+'.$this->regexHtmlAttribute.')*+[ ]*+\/?>/s', $Excerpt['text'], $matches))
- {
- return array(
- 'element' => array('rawHtml' => $matches[0]),
- 'extent' => strlen($matches[0]),
- );
- }
- }
-
- protected function inlineSpecialCharacter($Excerpt)
- {
- if (substr($Excerpt['text'], 1, 1) !== ' ' and strpos($Excerpt['text'], ';') !== false
- and preg_match('/^&(#?+[0-9a-zA-Z]++);/', $Excerpt['text'], $matches)
- ) {
- return array(
- 'element' => array('rawHtml' => '&' . $matches[1] . ';'),
- 'extent' => strlen($matches[0]),
- );
- }
-
- return;
- }
-
- protected function inlineStrikethrough($Excerpt)
- {
- if ( ! isset($Excerpt['text'][1]))
- {
- return;
- }
-
- if ($Excerpt['text'][1] === '~' and preg_match('/^~~(?=\S)(.+?)(?<=\S)~~/', $Excerpt['text'], $matches))
- {
- return array(
- 'extent' => strlen($matches[0]),
- 'element' => array(
- 'name' => 'del',
- 'handler' => array(
- 'function' => 'lineElements',
- 'argument' => $matches[1],
- 'destination' => 'elements',
- )
- ),
- );
- }
- }
-
- protected function inlineUrl($Excerpt)
- {
- if ($this->urlsLinked !== true or ! isset($Excerpt['text'][2]) or $Excerpt['text'][2] !== '/')
- {
- return;
- }
-
- if (strpos($Excerpt['context'], 'http') !== false
- and preg_match('/\bhttps?+:[\/]{2}[^\s<]+\b\/*+/ui', $Excerpt['context'], $matches, PREG_OFFSET_CAPTURE)
- ) {
- $url = $matches[0][0];
-
- $Inline = array(
- 'extent' => strlen($matches[0][0]),
- 'position' => $matches[0][1],
- 'element' => array(
- 'name' => 'a',
- 'text' => $url,
- 'attributes' => array(
- 'href' => $url,
- ),
- ),
- );
-
- return $Inline;
- }
- }
-
- protected function inlineUrlTag($Excerpt)
- {
- if (strpos($Excerpt['text'], '>') !== false and preg_match('/^<(\w++:\/{2}[^ >]++)>/i', $Excerpt['text'], $matches))
- {
- $url = $matches[1];
-
- return array(
- 'extent' => strlen($matches[0]),
- 'element' => array(
- 'name' => 'a',
- 'text' => $url,
- 'attributes' => array(
- 'href' => $url,
- ),
- ),
- );
- }
- }
-
- # ~
-
- protected function unmarkedText($text)
- {
- $Inline = $this->inlineText($text);
- return $this->element($Inline['element']);
- }
-
- #
- # Handlers
- #
-
- protected function handle(array $Element)
- {
- if (isset($Element['handler']))
- {
- if (!isset($Element['nonNestables']))
- {
- $Element['nonNestables'] = array();
- }
-
- if (is_string($Element['handler']))
- {
- $function = $Element['handler'];
- $argument = $Element['text'];
- unset($Element['text']);
- $destination = 'rawHtml';
- }
- else
- {
- $function = $Element['handler']['function'];
- $argument = $Element['handler']['argument'];
- $destination = $Element['handler']['destination'];
- }
-
- $Element[$destination] = $this->{$function}($argument, $Element['nonNestables']);
-
- if ($destination === 'handler')
- {
- $Element = $this->handle($Element);
- }
-
- unset($Element['handler']);
- }
-
- return $Element;
- }
-
- protected function handleElementRecursive(array $Element)
- {
- return $this->elementApplyRecursive(array($this, 'handle'), $Element);
- }
-
- protected function handleElementsRecursive(array $Elements)
- {
- return $this->elementsApplyRecursive(array($this, 'handle'), $Elements);
- }
-
- protected function elementApplyRecursive($closure, array $Element)
- {
- $Element = call_user_func($closure, $Element);
-
- if (isset($Element['elements']))
- {
- $Element['elements'] = $this->elementsApplyRecursive($closure, $Element['elements']);
- }
- elseif (isset($Element['element']))
- {
- $Element['element'] = $this->elementApplyRecursive($closure, $Element['element']);
- }
-
- return $Element;
- }
-
- protected function elementApplyRecursiveDepthFirst($closure, array $Element)
- {
- if (isset($Element['elements']))
- {
- $Element['elements'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['elements']);
- }
- elseif (isset($Element['element']))
- {
- $Element['element'] = $this->elementsApplyRecursiveDepthFirst($closure, $Element['element']);
- }
-
- $Element = call_user_func($closure, $Element);
-
- return $Element;
- }
-
- protected function elementsApplyRecursive($closure, array $Elements)
- {
- foreach ($Elements as &$Element)
- {
- $Element = $this->elementApplyRecursive($closure, $Element);
- }
-
- return $Elements;
- }
-
- protected function elementsApplyRecursiveDepthFirst($closure, array $Elements)
- {
- foreach ($Elements as &$Element)
- {
- $Element = $this->elementApplyRecursiveDepthFirst($closure, $Element);
- }
-
- return $Elements;
- }
-
- protected function element(array $Element)
- {
- if ($this->safeMode)
- {
- $Element = $this->sanitiseElement($Element);
- }
-
- # identity map if element has no handler
- $Element = $this->handle($Element);
-
- $hasName = isset($Element['name']);
-
- $markup = '';
-
- if ($hasName)
- {
- $markup .= '<' . $Element['name'];
-
- if (isset($Element['attributes']))
- {
- foreach ($Element['attributes'] as $name => $value)
- {
- if ($value === null)
- {
- continue;
- }
-
- $markup .= " $name=\"".self::escape($value).'"';
- }
- }
- }
-
- $permitRawHtml = false;
-
- if (isset($Element['text']))
- {
- $text = $Element['text'];
- }
- // very strongly consider an alternative if you're writing an
- // extension
- elseif (isset($Element['rawHtml']))
- {
- $text = $Element['rawHtml'];
-
- $allowRawHtmlInSafeMode = isset($Element['allowRawHtmlInSafeMode']) && $Element['allowRawHtmlInSafeMode'];
- $permitRawHtml = !$this->safeMode || $allowRawHtmlInSafeMode;
- }
-
- $hasContent = isset($text) || isset($Element['element']) || isset($Element['elements']);
-
- if ($hasContent)
- {
- $markup .= $hasName ? '>' : '';
-
- if (isset($Element['elements']))
- {
- $markup .= $this->elements($Element['elements']);
- }
- elseif (isset($Element['element']))
- {
- $markup .= $this->element($Element['element']);
- }
- else
- {
- if (!$permitRawHtml)
- {
- $markup .= self::escape($text, true);
- }
- else
- {
- $markup .= $text;
- }
- }
-
- $markup .= $hasName ? '' . $Element['name'] . '>' : '';
- }
- elseif ($hasName)
- {
- $markup .= ' />';
- }
-
- return $markup;
- }
-
- protected function elements(array $Elements)
- {
- $markup = '';
-
- $autoBreak = true;
-
- foreach ($Elements as $Element)
- {
- if (empty($Element))
- {
- continue;
- }
-
- $autoBreakNext = (isset($Element['autobreak'])
- ? $Element['autobreak'] : isset($Element['name'])
- );
- // (autobreak === false) covers both sides of an element
- $autoBreak = !$autoBreak ? $autoBreak : $autoBreakNext;
-
- $markup .= ($autoBreak ? "\n" : '') . $this->element($Element);
- $autoBreak = $autoBreakNext;
- }
-
- $markup .= $autoBreak ? "\n" : '';
-
- return $markup;
- }
-
- # ~
-
- protected function li($lines)
- {
- $Elements = $this->linesElements($lines);
-
- if ( ! in_array('', $lines)
- and isset($Elements[0]) and isset($Elements[0]['name'])
- and $Elements[0]['name'] === 'p'
- ) {
- unset($Elements[0]['name']);
- }
-
- return $Elements;
- }
-
- #
- # AST Convenience
- #
-
- /**
- * Replace occurrences $regexp with $Elements in $text. Return an array of
- * elements representing the replacement.
- */
- protected static function pregReplaceElements($regexp, $Elements, $text)
- {
- $newElements = array();
-
- while (preg_match($regexp, $text, $matches, PREG_OFFSET_CAPTURE))
- {
- $offset = $matches[0][1];
- $before = substr($text, 0, $offset);
- $after = substr($text, $offset + strlen($matches[0][0]));
-
- $newElements[] = array('text' => $before);
-
- foreach ($Elements as $Element)
- {
- $newElements[] = $Element;
- }
-
- $text = $after;
- }
-
- $newElements[] = array('text' => $text);
-
- return $newElements;
- }
-
- #
- # Deprecated Methods
- #
-
- function parse($text)
- {
- $markup = $this->text($text);
-
- return $markup;
- }
-
- protected function sanitiseElement(array $Element)
- {
- static $goodAttribute = '/^[a-zA-Z0-9][a-zA-Z0-9-_]*+$/';
- static $safeUrlNameToAtt = array(
- 'a' => 'href',
- 'img' => 'src',
- );
-
- if ( ! isset($Element['name']))
- {
- unset($Element['attributes']);
- return $Element;
- }
-
- if (isset($safeUrlNameToAtt[$Element['name']]))
- {
- $Element = $this->filterUnsafeUrlInAttribute($Element, $safeUrlNameToAtt[$Element['name']]);
- }
-
- if ( ! empty($Element['attributes']))
- {
- foreach ($Element['attributes'] as $att => $val)
- {
- # filter out badly parsed attribute
- if ( ! preg_match($goodAttribute, $att))
- {
- unset($Element['attributes'][$att]);
- }
- # dump onevent attribute
- elseif (self::striAtStart($att, 'on'))
- {
- unset($Element['attributes'][$att]);
- }
- }
- }
-
- return $Element;
- }
-
- protected function filterUnsafeUrlInAttribute(array $Element, $attribute)
- {
- foreach ($this->safeLinksWhitelist as $scheme)
- {
- if (self::striAtStart($Element['attributes'][$attribute], $scheme))
- {
- return $Element;
- }
- }
-
- $Element['attributes'][$attribute] = str_replace(':', '%3A', $Element['attributes'][$attribute]);
-
- return $Element;
- }
-
- #
- # Static Methods
- #
-
- protected static function escape($text, $allowQuotes = false)
- {
- return htmlspecialchars($text, $allowQuotes ? ENT_NOQUOTES : ENT_QUOTES, 'UTF-8');
- }
-
- protected static function striAtStart($string, $needle)
- {
- $len = strlen($needle);
-
- if ($len > strlen($string))
- {
- return false;
- }
- else
- {
- return strtolower(substr($string, 0, $len)) === strtolower($needle);
- }
- }
-
- static function instance($name = 'default')
- {
- if (isset(self::$instances[$name]))
- {
- return self::$instances[$name];
- }
-
- $instance = new static();
-
- self::$instances[$name] = $instance;
-
- return $instance;
- }
-
- private static $instances = array();
-
- #
- # Fields
- #
-
- protected $DefinitionData;
-
- #
- # Read-Only
-
- protected $specialCharacters = array(
- '\\', '`', '*', '_', '{', '}', '[', ']', '(', ')', '>', '#', '+', '-', '.', '!', '|', '~'
- );
-
- protected $StrongRegex = array(
- '*' => '/^[*]{2}((?:\\\\\*|[^*]|[*][^*]*+[*])+?)[*]{2}(?![*])/s',
- '_' => '/^__((?:\\\\_|[^_]|_[^_]*+_)+?)__(?!_)/us',
- );
-
- protected $EmRegex = array(
- '*' => '/^[*]((?:\\\\\*|[^*]|[*][*][^*]+?[*][*])+?)[*](?![*])/s',
- '_' => '/^_((?:\\\\_|[^_]|__[^_]*__)+?)_(?!_)\b/us',
- );
-
- protected $regexHtmlAttribute = '[a-zA-Z_:][\w:.-]*+(?:\s*+=\s*+(?:[^"\'=<>`\s]+|"[^"]*+"|\'[^\']*+\'))?+';
-
- protected $voidElements = array(
- 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source',
- );
-
- protected $textLevelElements = array(
- 'a', 'br', 'bdo', 'abbr', 'blink', 'nextid', 'acronym', 'basefont',
- 'b', 'em', 'big', 'cite', 'small', 'spacer', 'listing',
- 'i', 'rp', 'del', 'code', 'strike', 'marquee',
- 'q', 'rt', 'ins', 'font', 'strong',
- 's', 'tt', 'kbd', 'mark',
- 'u', 'xm', 'sub', 'nobr',
- 'sup', 'ruby',
- 'var', 'span',
- 'wbr', 'time',
- );
-}
diff --git a/includes/parsedown/ParsedownExtra.php b/includes/parsedown/ParsedownExtra.php
deleted file mode 100755
index 6ffae81..0000000
--- a/includes/parsedown/ParsedownExtra.php
+++ /dev/null
@@ -1,18 +0,0 @@
- array(
- 'name' => 'div',
- 'attributes' => array('class' => 'dialogue'),
- 'text' => ltrim($Line['text'], '� ')
- )
- );
- }
-
- return parent::blockQuote($Line);
- }
-}
\ No newline at end of file
diff --git a/includes/parsedown/index.php b/includes/parsedown/index.php
deleted file mode 100644
index e69de29..0000000
diff --git a/index.php b/index.php
index 8d0b0b5..b4fd915 100755
--- a/index.php
+++ b/index.php
@@ -122,6 +122,7 @@ $router->add('/books', 'BookController@index');
$router->add('/books/create', 'BookController@create');
$router->add('/books/{id}/edit', 'BookController@edit');
$router->add('/books/{id}/delete', 'BookController@delete');
+$router->add('/books/delete-all', 'BookController@deleteAll');
$router->add('/books/{id}/normalize', 'BookController@normalizeContent');
$router->add('/books/{id}/regenerate-token', 'BookController@regenerateToken');
@@ -137,7 +138,9 @@ $router->add('/series', 'SeriesController@index');
$router->add('/series/create', 'SeriesController@create');
$router->add('/series/{id}/edit', 'SeriesController@edit');
$router->add('/series/{id}/delete', 'SeriesController@delete');
-
+$router->add('/series/{id}/add-book', 'SeriesController@addBook');
+$router->add('/series/{id}/remove-book/{book_id}', 'SeriesController@removeBook');
+$router->add('/series/{id}/update-order', 'SeriesController@updateBookOrder');
// Профиль
$router->add('/profile', 'UserController@profile');
@@ -154,6 +157,14 @@ $router->add('/book/{share_token}', 'BookController@viewPublic');
$router->add('/author/{id}', 'UserController@viewPublic');
$router->add('/series/{id}/view', 'SeriesController@viewPublic');
+
+// Администрирование
+$router->add('/admin/users', 'AdminController@users');
+$router->add('/admin/add-user', 'AdminController@addUser');
+$router->add('/admin/user/{user_id}/toggle-status', 'AdminController@toggleUserStatus');
+$router->add('/admin/user/{user_id}/delete', 'AdminController@deleteUser');
+
+
// Обработка запроса
$requestUri = $_SERVER['REQUEST_URI'];
$router->handle($requestUri);
diff --git a/install.php b/install.php
index 32e2d18..d1c1222 100755
--- a/install.php
+++ b/install.php
@@ -61,7 +61,6 @@ CREATE TABLE `books` (
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`share_token` varchar(32) DEFAULT NULL,
`published` tinyint(1) NOT NULL DEFAULT 0,
- `editor_type` ENUM('markdown', 'html') DEFAULT 'markdown',
PRIMARY KEY (`id`),
UNIQUE KEY `share_token` (`share_token`),
KEY `user_id` (`user_id`),
@@ -219,6 +218,11 @@ define('SITE_URL', '{$site_url}');
// Настройки приложения
define('APP_NAME', 'Web Writer');
+
+define('CONTROLLERS_PATH', __DIR__ . '/../controllers/');
+define('VIEWS_PATH', __DIR__ . '/../views/');
+define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
+
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
define('COVERS_URL', SITE_URL . '/uploads/covers/');
@@ -242,6 +246,8 @@ try {
die("Ошибка подключения к базе данных");
}
+
+
// Автозагрузка моделей
spl_autoload_register(function (\$class_name) {
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
diff --git a/models/Book.php b/models/Book.php
index 4d10dd9..878bb34 100755
--- a/models/Book.php
+++ b/models/Book.php
@@ -1,6 +1,5 @@
pdo->prepare("
- INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, editor_type)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
+ INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
");
return $stmt->execute([
$data['title'],
@@ -55,14 +53,12 @@ class Book {
$data['series_id'] ?? null,
$data['sort_order_in_series'] ?? null,
$share_token,
- $published,
- $editor_type
+ $published
]);
}
public function update($id, $data) {
$published = isset($data['published']) ? (int)$data['published'] : 0;
- $editor_type = $data['editor_type'] ?? 'markdown';
// Преобразуем пустые строки в NULL для integer полей
$series_id = !empty($data['series_id']) ? (int)$data['series_id'] : null;
@@ -70,7 +66,7 @@ class Book {
$stmt = $this->pdo->prepare("
UPDATE books
- SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?, editor_type = ?
+ SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?
WHERE id = ? AND user_id = ?
");
return $stmt->execute([
@@ -80,7 +76,6 @@ class Book {
$series_id, // Теперь это либо integer, либо NULL
$sort_order_in_series, // Теперь это либо integer, либо NULL
$published,
- $editor_type,
$id,
$data['user_id']
]);
@@ -106,6 +101,39 @@ class Book {
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 = ?");
@@ -172,24 +200,6 @@ class Book {
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
- public function reorderSeriesBooks($series_id, $new_order) {
- try {
- $this->pdo->beginTransaction();
-
- foreach ($new_order as $order => $book_id) {
- $stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
- $stmt->execute([$order + 1, $book_id, $series_id]);
- }
-
- $this->pdo->commit();
- return true;
- } catch (Exception $e) {
- $this->pdo->rollBack();
- return false;
- }
- }
-
-
public function getBookStats($book_id, $only_published_chapters = false) {
$sql = "
SELECT
@@ -209,74 +219,49 @@ class Book {
return $stmt->fetch(PDO::FETCH_ASSOC);
}
- public function convertChaptersContent($book_id, $from_editor, $to_editor) {
- try {
- $this->pdo->beginTransaction();
-
- // Получаем все главы книги
- $chapters = $this->getAllChapters($book_id);
-
- foreach ($chapters as $chapter) {
- $converted_content = $this->convertContent(
- $chapter['content'],
- $from_editor,
- $to_editor
- );
+
+
+ private function getAllChapters($book_id) {
+ $stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
+ $stmt->execute([$book_id]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ private function updateChapterContent($chapter_id, $content) {
+ $word_count = $this->countWords($content);
+ $stmt = $this->pdo->prepare("
+ UPDATE chapters
+ SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
+ WHERE id = ?
+ ");
+ return $stmt->execute([$content, $word_count, $chapter_id]);
+ }
+
+ public function getBooksNotInSeries($user_id, $series_id = null) {
+ $sql = "SELECT * FROM books WHERE user_id = ? AND (series_id IS NULL OR series_id = ?)";
+ $stmt = $this->pdo->prepare($sql);
+ $stmt->execute([$user_id, $series_id]);
+ return $stmt->fetchAll(PDO::FETCH_ASSOC);
+ }
+
+ public function reorderSeriesBooks($series_id, $new_order) {
+ try {
+ $this->pdo->beginTransaction();
- // Обновляем контент главы
- $this->updateChapterContent($chapter['id'], $converted_content);
+ foreach ($new_order as $order => $book_id) {
+ $stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
+ $stmt->execute([$order + 1, $book_id, $series_id]);
+ }
+
+ $this->pdo->commit();
+ return true;
+ } catch (Exception $e) {
+ $this->pdo->rollBack();
+ error_log("Ошибка при обновлении порядка книг: " . $e->getMessage());
+ return false;
}
+ }
- $this->pdo->commit();
- return true;
- } catch (Exception $e) {
- $this->pdo->rollBack();
- error_log("Error converting chapters: " . $e->getMessage());
- return false;
- }
-}
-
-private function getAllChapters($book_id) {
- $stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
- $stmt->execute([$book_id]);
- return $stmt->fetchAll(PDO::FETCH_ASSOC);
-}
-
-private function updateChapterContent($chapter_id, $content) {
- $word_count = $this->countWords($content);
- $stmt = $this->pdo->prepare("
- UPDATE chapters
- SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
- WHERE id = ?
- ");
- return $stmt->execute([$content, $word_count, $chapter_id]);
-}
-
-private function convertContent($content, $from_editor, $to_editor) {
- if ($from_editor === $to_editor) {
- return $content;
- }
-
- require_once __DIR__ . '/../includes/parsedown/ParsedownExtra.php';
-
- try {
- if ($from_editor === 'markdown' && $to_editor === 'html') {
- // Markdown to HTML
- $parsedown = new ParsedownExtra();
- return $parsedown->text($content);
- } elseif ($from_editor === 'html' && $to_editor === 'markdown') {
- // HTML to Markdown (упрощенная версия)
- return $this->htmlToMarkdown($content);
- }
- } catch (Exception $e) {
- error_log("Error converting content from {$from_editor} to {$to_editor}: " . $e->getMessage());
- return $content;
- }
-
- return $content;
-}
-
-
private function countWords($text) {
$text = strip_tags($text);
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
@@ -284,280 +269,6 @@ private function convertContent($content, $from_editor, $to_editor) {
$words = array_filter($words);
return count($words);
}
-
-
- private function markdownToHtmlWithParagraphs($markdown) {
- $parsedown = new ParsedownExtra();
-
- // Включаем разметку строк для лучшей обработки абзацев
- $parsedown->setBreaksEnabled(true);
-
- // Обрабатываем Markdown
- $html = $parsedown->text($markdown);
-
- // Дополнительная обработка для обеспечения правильной структуры абзацев
- $html = $this->ensureParagraphStructure($html);
-
- return $html;
- }
-
- private function ensureParagraphStructure($html) {
- // Если HTML не содержит тегов абзацев или div'ов, оборачиваем в
- if (!preg_match('/<(p|div|h[1-6]|blockquote|pre|ul|ol|li)/i', $html)) {
- // Разбиваем на строки и оборачиваем каждую непустую строку в
- $lines = explode("\n", trim($html));
- $wrappedLines = [];
-
- foreach ($lines as $line) {
- $line = trim($line);
- if (!empty($line)) {
- // Пропускаем уже обернутые строки
- if (!preg_match('/^<[^>]+>/', $line) || preg_match('/^<(p|div|h[1-6])/i', $line)) {
- $wrappedLines[] = $line;
- } else {
- $wrappedLines[] = "
{$line}
";
- }
- }
- }
-
- $html = implode("\n", $wrappedLines);
- }
-
- // Убеждаемся, что теги правильно закрыты
- $html = $this->balanceTags($html);
-
- return $html;
- }
-
- private function balanceTags($html) {
- // Простая балансировка тегов - в реальном проекте лучше использовать DOMDocument
- $tags = [
- 'p' => 0,
- 'div' => 0,
- 'span' => 0,
- 'strong' => 0,
- 'em' => 0,
- ];
-
- // Счетчик открывающих и закрывающих тегов
- foreach ($tags as $tag => &$count) {
- $open = substr_count($html, "<{$tag}>") + substr_count($html, "<{$tag} ");
- $close = substr_count($html, "{$tag}>");
- $count = $open - $close;
- }
-
- // Добавляем недостающие закрывающие теги
- foreach ($tags as $tag => $count) {
- if ($count > 0) {
- $html .= str_repeat("{$tag}>", $count);
- }
- }
-
- return $html;
- }
- private function htmlToMarkdown($html) {
- // Сначала нормализуем HTML структуру
- $html = $this->normalizeHtml($html);
-
- // Базовая конвертация HTML в Markdown
- $markdown = $html;
-
- // 1. Сначала обрабатываем абзацы - заменяем на двойные переносы строк
- $markdown = preg_replace_callback('/]*>(.*?)<\/p>/is', function($matches) {
- $content = trim($matches[1]);
- if (!empty($content)) {
- return $content . "\n\n";
- }
- return '';
- }, $markdown);
-
- // 2. Обрабатываем разрывы строк
- $markdown = preg_replace('/
]*>\s*<\/br[^>]*>/i', "\n", $markdown);
- $markdown = preg_replace('/
]*>/i', " \n", $markdown); // Два пробела для Markdown разрыва
-
- // 3. Заголовки
- $markdown = preg_replace('/