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']) . ''; + $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. Завершение
+
+ + +
+ +
+ + + + + + + + + +
+

Настройки базы данных

+ + + + + + + + + + +
+ + + +
+

Создание администратора

+

Создайте учетную запись администратора для управления приложением.

+ + + + + + + + + +
+ Назад + +
+
+ + + + +
+

Перед установкой убедитесь, что:

+
    +
  • Сервер MySQL запущен и доступен
  • +
  • У вас есть данные для подключения к БД (хост, пользователь, пароль)
  • +
  • Папка config/ доступна для записи
  • +
  • Папка uploads/ доступна для записи
  • +
+
+ +
+
+ + +// ./views/layouts/header.php + + + + + + + <?= e($page_title ?? 'Web Writer') ?> + + + + + + + + +
+ +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ +// ./views/layouts/footer.php + +
+ + + + + + +// ./views/dashboard/index.php + + +

Панель управления

+ +
+
+

📚 Книги

+
+ +
+ Всего книг +
+ +
+

📑 Главы

+
+ +
+ Всего глав +
+ +
+

📝 Слова

+
+ +
+ Всего слов +
+ +
+

🌐 Публикации

+
+ +
+ Опубликовано книг +
+
+ +
+
+

Недавние книги

+ + +
+

+ + + +

+ +

+ +
+ + Глав: | + Слов: | + Статус: + +
+
+ + + +
+ Все книги +
+ + + + +
+ +
+

Мои серии

+ + +
+

+ + + +

+ +

+ +
+ + Книг: | + Слов: + +
+
+ + +
+ Все серии +
+ + + + +

Быстрые действия

+ +
+
+ + +// ./views/auth/login.php + + +
+

Вход в систему

+ + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ + +
+ +
+

Нет аккаунта? Зарегистрируйтесь здесь

+
+
+ + +// ./views/auth/register.php + + +
+

Регистрация

+ + +
+ +
+ + + +
+ +
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+

Уже есть аккаунт? Войдите здесь

+
+
+ + +// ./views/chapters/preview.php + + + + + + <?= e($page_title) ?> + + + + + +
+

+
+
+
+ +
+ + + +// ./views/chapters/index.php + + +
+

Главы книги:

+
+ ➕ Новая глава + ✏️ Редактировать книгу + 👁️ Просмотреть книгу + 📚 Все книги +
+
+ + +
+

В этой книге пока нет глав

+

Создайте первую главу для вашей книги

+ 📝 Создать первую главу +
+ +
+ + + + + + + + + + + + + $chapter): ?> + + + + + + + + + + +
Название главыСтатусСловОбновленоДействия
+ + +
+ +
+ + + + + + +
+ + ✏️ + +
+ + +
+
+
+
+ +
+ Статистика: + Всего глав: | + Всего слов: | + Опубликовано: +
+ + + +// ./views/chapters/create.php + + +

Новая глава для:

+ + +
+ +
+ + +
+ + +
+ + + + + + +
+ + + + Опубликованные главы видны в публичном доступе + +
+
+ +
+ + + + + + ❌ Отмена + +
+
+ + + + + +// ./views/chapters/edit.php + + +

Редактирование главы:

+ + +
+ +
+ + +
+ + +
+ + + + + + + +
+ + + + Опубликованные главы видны в публичном доступе + +
+
+ +
+ + + + + + ❌ Отмена + +
+
+ +
+

Информация о главе

+

Книга:

+

Количество слов:

+

Создана:

+

Обновлена:

+
+ + + + + +// ./views/series/index.php + + +
+

Мои серии книг

+ ➕ Создать серию +
+ + + + +
+ +
+
+

+ +

+
+ Создана + + • Обновлена + +
+
+ + +
+ +
+ + +
+
+ + книг +
+
+ + слов +
+
+ + 0 ? round($ser['total_words'] / $ser['book_count']) : 0; + echo number_format($avg_words); + ?> + + слов/книга +
+
+ + +
+ +
+ + + +// ./views/series/create.php + + +

Создание новой серии

+ + +
+ +
+ + +
+ + +
+ + + + + +
+ +
+ + + + ❌ Отмена + +
+
+ +
+

Что такое серия?

+

Серия позволяет объединить несколько книг в одну тематическую коллекцию. Это полезно для:

+
    +
  • Циклов книг с общим сюжетом
  • +
  • Книг в одном мире или вселенной
  • +
  • Организации книг по темам или жанрам
  • +
+

Вы сможете добавить книги в серию после её создания.

+
+ + +// ./views/series/edit.php + + +

Редактирование серии:

+ +
+
+
+

Основная информация

+
+ + + + + + + +
+
+ +
+

Добавить книгу в серию

+ getBooksNotInSeries($_SESSION['user_id'], $series['id']); + ?> + + +
+ + + + + + + +
+ +

Все ваши книги уже добавлены в эту серию или у вас нет доступных книг.

+ Создать новую книгу + +
+
+ +
+
+

Книги в серии ()

+ + +
+
+ + +
+ $book): ?> +
+
+
+ + Порядок: +
+
+ Редактировать + + + + +
+ +
+ +
+ + + +
+ +

В этой серии пока нет книг. Добавьте книги с помощью формы слева.

+ +
+ +
+

Статистика серии

+
+

Количество книг:

+ getBookStats($book['id']); + $total_words += $stats['total_words'] ?? 0; + $total_chapters += $stats['chapter_count'] ?? 0; + } + ?> +

Всего глав:

+

Всего слов:

+
+
+
+
+ + + + + + + +// ./views/series/view_public.php + + +
+
+
+

+

+ Серия книг от + +

+ + +
+ +
+ + +
+ Книг: + Глав: + Слов: +
+
+ + +
+

В этой серии пока нет опубликованных книг

+

Автор еще не опубликовал книги из этой серии

+
+ +
+

Книги серии

+ + +
+ +
+ <?= e($book['title']) ?> +
+ +
+
+ 📚 +
+
+ + +
+

+ + Книга
+ + +

+ + +

+ + + +

+ + +
+ + Читать + + + getBookStats($book['id'], true); + ?> + + + Глав: | Слов: + +
+
+
+ +
+ + +
+

+ Серия создана в • + Автор: +

+
+
+
+ + + + +// ./views/errors/404.php + + +
+

404 - Страница не найдена

+

+ Запрашиваемая страница не существует или была перемещена. +

+
+ 🏠 На главную + 📚 К книгам + + 🔑 Войти + +
+
+ + +// ./views/admin/add_user.php + + +
+

Добавление пользователя

+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + + ❌ Отмена + +
+
+
+ + +// ./views/admin/users.php + + +
+

Управление пользователями

+ + +
+ + +
+ + + +
+ + +
+ + +
+

Всего пользователей:

+ ➕ Добавить пользователя +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IDИмя пользователяОтображаемое имяEmailДата регистрацииСтатусДействия
+ + +
(Вы) + +
+ + +
Вход: + +
+ + + + + +
+
+ + +
+
+ + +
+
+ + Текущий пользователь + +
+
+ +
+ + +// ./views/user/profile.php + + +

Мой профиль

+ + +
+ +
+ + +
+
+

Основная информация

+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + Поддерживается Markdown форматирование + +
+ +
+ + + ↩️ Назад + +
+
+
+ +
+

Аватарка

+ +
+ + Аватарка + +
+ +
+ +
+ +
+ + +
+ + + + Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB. + Рекомендуемый размер: 200×200 пикселей. + + + +
+ ❌ +
+ +
+ +
+ + + + + +
+
+ + +
+

+ Примечание: Аватарка отображается на вашей публичной странице автора +

+
+ +
+
+ + + + +// ./views/user/view_public.php + + +
+
+
+ +
+ + <?= e($user['display_name'] ?: $user['username']) ?> + +
+ +
+ +
+ +

+ + + +
+ +
+ + + +
+
+
+
Книг
+
+
+
+
Глав
+
+
+
+
Слов
+
+
+
+ +

Публикации автора

+ + +
+

У этого автора пока нет опубликованных книг

+

Следите за обновлениями, скоро здесь появятся новые произведения!

+
+ +
+ +
+ +
+ <?= e($book['title']) ?> +
+ +
+
+ 📚 +
+
+ + +
+

+ + +

+ + + +

+ + + getBookStats($book['id'], true); + $chapter_count = $book_stats['chapter_count'] ?? 0; + $word_count = $book_stats['total_words'] ?? 0; + ?> + +
+ + Читать книгу + + + + Глав: | Слов: + +
+
+
+ +
+ + +
+

+ Страница автора создана в • + +

+
+
+
+ + + + +// ./views/books/index.php + + +

Мои книги

+ +

Всего книг:

+
+ ➕ Новая книга + + 🗑️ Удалить все книги + +
+ + + + +
+ + + +
+ + + + + +// ./views/books/create.php + +

Создание новой книги

+
+ +
+ + + + + + + + + +
+ +
+
+
+ + + ❌ Отмена + +
+
+ +// ./views/books/edit.php + +

Редактирование книги

+
+ +
+ + + + + + + + + +
+ + +
+

Текущая обложка:

+ Обложка +
+ +
+
+ + + + Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 5MB. + Рекомендуемый размер: 300×450 пикселей. + + +
+ ❌ +
+ +
+ + +
+ +
+
+
+ + + ❌ Отмена + +
+
+ + +
+

Публичная ссылка для чтения

+
+ + +
+ + +
+
+

+ Примечание: В публичном просмотре отображаются только главы со статусом "Опубликована" +

+
+
+

Экспорт книги

+

Экспортируйте книгу в различные форматы:

+
+ + 📄 PDF + + + 📝 DOCX + + + 🌐 HTML + + + 📄 TXT + +
+

+ Примечание: Экспортируются все главы книги (включая черновики) +

+
+
+

Главы этой книги

+
+ + 📑 Все главы + + + ✏️ Добавить главу + +
+ +
+ + + + + + + + + + + + + + + + + + + +
НазваниеСтатусСловДействия
+ + + + + + Редактировать + +
+
+ +
+

В этой книге пока нет глав.

+ + ✏️ Добавить первую главу + +
+ +
+
+
+ + +
+
+ + + + + +// ./views/books/view_public.php + + +
+
+
+ +
+ <?= e($book['title']) ?> +
+ + +

+ +

+ Автор: +

+ + +

+ +

+ + + +
+ +
+ + +
+ Глав: + Слов: + + 📄 Скачать книгу + +
+
+ + +
+

В этой книге пока нет глав

+

Автор еще не опубликовал содержание книги

+
+ +

Оглавление

+ +
+ $chapter): ?> + + +
+ +
+ + $chapter): ?> +
+

+ Глава : +

+ +
+ + text($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. 1. Первый элемент
  2. -
  3. 2. Второй элемент
  4. -
-
-
- -
-

Код

-
-

Код в строке:

-

\`код в строке\`

-

Блок кода:

-
-\`\`\`
-блок кода
-многострочный
-\`\`\`
-
-
- -
-

💡 Подсказка: Используйте кнопку "👁️ Предпросмотр" чтобы увидеть как будет выглядеть готовый текст!

-
-
- `; - - 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('/^\[(.+?)\]:[ ]*+?(?:[ ]+["\'(](.+)["\')])?[ ]*+$/', $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 ? '' : ''; - } - 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, ""); - $count = $open - $close; - } - - // Добавляем недостающие закрывающие теги - foreach ($tags as $tag => $count) { - if ($count > 0) { - $html .= str_repeat("", $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('/]*>(.*?)<\/h1>/is', "# $1\n\n", $markdown); - $markdown = preg_replace('/]*>(.*?)<\/h2>/is', "## $1\n\n", $markdown); - $markdown = preg_replace('/]*>(.*?)<\/h3>/is', "### $1\n\n", $markdown); - $markdown = preg_replace('/]*>(.*?)<\/h4>/is', "#### $1\n\n", $markdown); - $markdown = preg_replace('/]*>(.*?)<\/h5>/is', "##### $1\n\n", $markdown); - $markdown = preg_replace('/]*>(.*?)<\/h6>/is', "###### $1\n\n", $markdown); - - // 4. Жирный текст - $markdown = preg_replace('/]*>(.*?)<\/strong>/is', '**$1**', $markdown); - $markdown = preg_replace('/]*>(.*?)<\/b>/is', '**$1**', $markdown); - - // 5. Курсив - $markdown = preg_replace('/]*>(.*?)<\/em>/is', '*$1*', $markdown); - $markdown = preg_replace('/]*>(.*?)<\/i>/is', '*$1*', $markdown); - - // 6. Зачеркивание - $markdown = preg_replace('/]*>(.*?)<\/s>/is', '~~$1~~', $markdown); - $markdown = preg_replace('/]*>(.*?)<\/strike>/is', '~~$1~~', $markdown); - $markdown = preg_replace('/]*>(.*?)<\/del>/is', '~~$1~~', $markdown); - - // 7. Списки - $markdown = preg_replace('/]*>(.*?)<\/li>/is', "- $1\n", $markdown); - - // Обработка вложенных списков - $markdown = preg_replace('/]*>(.*?)<\/ul>/is', "\n$1\n", $markdown); - $markdown = preg_replace('/]*>(.*?)<\/ol>/is', "\n$1\n", $markdown); - - // 8. Блочные цитаты - $markdown = preg_replace('/]*>(.*?)<\/blockquote>/is', "> $1\n\n", $markdown); - - // 9. Код - $markdown = preg_replace('/]*>(.*?)<\/code>/is', '`$1`', $markdown); - $markdown = preg_replace('/]*>]*>(.*?)<\/code><\/pre>/is', "```\n$1\n```", $markdown); - $markdown = preg_replace('/]*>(.*?)<\/pre>/is', "```\n$1\n```", $markdown); - - // 10. Ссылки - $markdown = preg_replace('/]*href="([^"]*)"[^>]*>(.*?)<\/a>/is', '[$2]($1)', $markdown); - - // 11. Изображения - $markdown = preg_replace('/]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/is', '![$2]($1)', $markdown); - - // 12. Таблицы - $markdown = preg_replace_callback('/]*>(.*?)<\/table>/is', function($matches) { - $tableContent = $matches[1]; - // Простое преобразование таблицы в Markdown - $tableContent = preg_replace('/]*>(.*?)<\/th>/i', "| **$1** ", $tableContent); - $tableContent = preg_replace('/]*>(.*?)<\/td>/i', "| $1 ", $tableContent); - $tableContent = preg_replace('/]*>(.*?)<\/tr>/i', "$1|\n", $tableContent); - $tableContent = preg_replace('/]*>(.*?)<\/thead>/i', "$1", $tableContent); - $tableContent = preg_replace('/]*>(.*?)<\/tbody>/i', "$1", $tableContent); - - // Добавляем разделитель для заголовков таблицы - $tableContent = preg_replace('/\| \*\*[^\|]+\*\* [^\n]*?\|\n/', "$0| --- |\n", $tableContent, 1); - - return "\n" . $tableContent . "\n"; - }, $markdown); - - // 13. Удаляем все остальные HTML-теги - $markdown = strip_tags($markdown); - - // 14. Чистим лишние пробелы и переносы - $markdown = preg_replace('/\n{3,}/', "\n\n", $markdown); // Более двух переносов заменяем на два - $markdown = preg_replace('/^\s+|\s+$/m', '', $markdown); // Trim каждой строки - $markdown = preg_replace('/\n\s*\n/', "\n\n", $markdown); // Чистим пустые строки - $markdown = preg_replace('/^ +/m', '', $markdown); // Убираем отступы в начале строк - - $markdown = trim($markdown); - - // 15. Дополнительная нормализация - убеждаемся, что есть пустые строки между абзацами - $lines = explode("\n", $markdown); - $normalized = []; - $inParagraph = false; - - foreach ($lines as $line) { - $trimmed = trim($line); - - if (empty($trimmed)) { - // Пустая строка - конец абзаца - if ($inParagraph) { - $normalized[] = ''; - $inParagraph = false; - } - continue; - } - - // Непустая строка - if (!$inParagraph && !empty($normalized) && end($normalized) !== '') { - // Добавляем пустую строку перед новым абзацем - $normalized[] = ''; - } - - $normalized[] = $trimmed; - $inParagraph = true; - } - - return implode("\n", $normalized); - } - - private function normalizeHtml($html) { - // Нормализуем HTML структуру перед конвертацией - $html = preg_replace('/]*>(.*?)<\/div>/is', "

$1

", $html); - - // Убираем лишние пробелы - $html = preg_replace('/\s+/', ' ', $html); - - // Восстанавливаем структуру абзацев - $html = preg_replace('/([^>])\s*<\/(p|div)>\s*([^<])/', "$1\n\n$3", $html); - - return $html; - } - - public function normalizeBookContent($book_id) { - try { - $chapters = $this->getAllChapters($book_id); - $book = $this->findById($book_id); - - foreach ($chapters as $chapter) { - $normalized_content = ''; - - if ($book['editor_type'] == 'html') { - // Нормализуем HTML контент - $normalized_content = $this->normalizeHtmlContent($chapter['content']); - } else { - // Нормализуем Markdown контент - $normalized_content = $this->normalizeMarkdownContent($chapter['content']); - } - - if ($normalized_content !== $chapter['content']) { - $this->updateChapterContent($chapter['id'], $normalized_content); - } - } - - return true; - } catch (Exception $e) { - error_log("Error normalizing book content: " . $e->getMessage()); - return false; - } - } - - private function normalizeHtmlContent($html) { - // Простая нормализация HTML - оборачиваем текст без тегов в

- if (!preg_match('/<[^>]+>/', $html) && trim($html) !== '') { - // Если нет HTML тегов, оборачиваем в

- $lines = explode("\n", trim($html)); - $wrapped = array_map(function($line) { - $line = trim($line); - return $line ? "

{$line}

" : ''; - }, $lines); - return implode("\n", array_filter($wrapped)); - } - - return $html; - } - - private function normalizeMarkdownContent($markdown) { - // Нормализация Markdown - убеждаемся, что есть пустые строки между абзацами - $lines = explode("\n", $markdown); - $normalized = []; - $inParagraph = false; - - foreach ($lines as $line) { - $trimmed = trim($line); - - if (empty($trimmed)) { - // Пустая строка - конец абзаца - if ($inParagraph) { - $normalized[] = ''; - $inParagraph = false; - } - } else { - // Непустая строка - if (!$inParagraph && !empty($normalized) && end($normalized) !== '') { - // Добавляем пустую строку перед новым абзацем - $normalized[] = ''; - } - $normalized[] = $line; - $inParagraph = true; - } - } - - return implode("\n", $normalized); - } } ?> \ No newline at end of file diff --git a/models/Series.php b/models/Series.php old mode 100644 new mode 100755 diff --git a/models/User.php b/models/User.php index fea8b84..ecd46e0 100755 --- a/models/User.php +++ b/models/User.php @@ -66,17 +66,17 @@ class User { $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 delete($id) { - $stmt = $this->pdo->prepare("DELETE FROM users WHERE id = ?"); - return $stmt->execute([$id]); - } - public function updateLastLogin($id) { $stmt = $this->pdo->prepare("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?"); return $stmt->execute([$id]); diff --git a/models/index.php b/models/index.php old mode 100644 new mode 100755 diff --git a/uploads/avatars/index.php b/uploads/avatars/index.php old mode 100644 new mode 100755 diff --git a/uploads/covers/cover_2_1764062021.jpg b/uploads/covers/cover_2_1764062021.jpg new file mode 100644 index 0000000000000000000000000000000000000000..b165da3ef5b5668a49a7de5a17b8ee91ed352346 GIT binary patch literal 83534 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
%L1dNYU&;rYmG)ZHFtZN8fhJ; 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�zz!wX(Qoek;fBy(?Eyy`J^=%HKg*S3@05 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 b$Lf0bIpFnw3=tT}! literal 0 HcmV?d00001 diff --git a/uploads/covers/cover_3_1764051570.jpg b/uploads/covers/cover_3_1764051570.jpg new file mode 100644 index 0000000000000000000000000000000000000000..557bb0bd11a5c4f665536fe4e6ad3c7f4b324c9e GIT binary patch literal 27798 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 literal 0 HcmV?d00001 diff --git a/views/admin/add_user.php b/views/admin/add_user.php new file mode 100755 index 0000000..8df7904 --- /dev/null +++ b/views/admin/add_user.php @@ -0,0 +1,88 @@ + + + + + \ No newline at end of file diff --git a/views/admin/users.php b/views/admin/users.php new file mode 100755 index 0000000..04a3b06 --- /dev/null +++ b/views/admin/users.php @@ -0,0 +1,96 @@ + + +
+

Управление пользователями

+ + +
+ + +
+ + + +
+ + +
+ + +
+

Всего пользователей:

+ ➕ Добавить пользователя +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
IDИмя пользователяОтображаемое имяEmailДата регистрацииСтатусДействия
+ + +
(Вы) + +
+ + +
Вход: + +
+ + + + + +
+
+ + +
+
+ + +
+
+ + Текущий пользователь + +
+
+ +
+ + \ No newline at end of file diff --git a/views/auth/login.php b/views/auth/login.php old mode 100644 new mode 100755 diff --git a/views/auth/register.php b/views/auth/register.php old mode 100644 new mode 100755 diff --git a/views/books/create.php b/views/books/create.php old mode 100644 new mode 100755 index 2751717..ba1f639 --- a/views/books/create.php +++ b/views/books/create.php @@ -3,6 +3,23 @@ include 'views/layouts/header.php'; ?>

Создание новой книги

+ +
+ + +
+ + + +
+ +
+ + +
+ Ошибка загрузки обложки: +
+
@@ -21,13 +38,7 @@ include 'views/layouts/header.php'; value="" placeholder="Например: Фантастика, Роман, Детектив..." style="width: 100%; margin-bottom: 1.5rem;"> - - + @@ -46,6 +57,16 @@ include 'views/layouts/header.php'; placeholder="Краткое описание сюжета или аннотация..." rows="6" style="width: 100;"> +
+ + + + Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB. + +