// ./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 = trim($_POST['content']) ?? ''; $content = $this->cleanChapterContent($content); $status = $_POST['status'] ?? 'draft'; if (empty($title)) { $error = "Название главы обязательно"; } else { $data = [ 'book_id' => $book_id, 'title' => $title, 'content' => $content, 'status' => $status ]; if ($chapterModel->create($data)) { $_SESSION['success'] = "Глава успешно создана"; $this->redirect("/books/{$book_id}/chapters"); } else { $error = "Ошибка при создании главы"; } } } } $this->render('chapters/create', [ 'book' => $book, 'error' => $error, 'page_title' => "Новая глава для: " . e($book['title']) ]); } public function edit($id) { $this->requireLogin(); $user_id = $_SESSION['user_id']; $chapterModel = new Chapter($this->pdo); $bookModel = new Book($this->pdo); // Получаем главу и книгу $chapter = $chapterModel->findById($id); if (!$chapter) { if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') { header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Глава не найдена']); exit; } $_SESSION['error'] = "Глава не найдена"; $this->redirect('/books'); } $book = $bookModel->findById($chapter['book_id']); // Проверяем права доступа if (!$chapterModel->userOwnsChapter($id, $user_id)) { if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') { header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Доступ запрещен']); exit; } $_SESSION['error'] = "У вас нет доступа к этой главе"; $this->redirect('/books'); } // Обработка POST запроса if ($_SERVER['REQUEST_METHOD'] === 'POST') { $title = trim($_POST['title'] ?? ''); $content = $this->cleanChapterContent($_POST['content'] ?? ''); $status = $_POST['status'] ?? 'draft'; // Проверяем CSRF if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') { header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => 'Ошибка безопасности']); exit; } $error = "Ошибка безопасности"; } if (empty($title)) { $error = "Название главы обязательно"; } $data = ['title' => $title, 'content' => $content, 'status' => $status]; // Если это автосейв — возвращаем JSON сразу if (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') { if (empty($error)) { $success = $chapterModel->update($id, $data); header('Content-Type: application/json'); echo json_encode(['success' => $success, 'error' => $success ? null : 'Ошибка при сохранении']); } else { header('Content-Type: application/json'); echo json_encode(['success' => false, 'error' => $error]); } exit; } // Обычное сохранение формы if (empty($error)) { if ($chapterModel->update($id, $data)) { $_SESSION['success'] = "Глава успешно обновлена"; $this->redirect("/books/{$chapter['book_id']}/chapters"); } else { $error = "Ошибка при обновлении главы"; } } } // Рендер страницы $this->render('chapters/edit', [ 'chapter' => $chapter, 'book' => $book, 'error' => $error ?? '', 'page_title' => "Редактирование главы: " . e($chapter['title']) ]); } public function delete($id) { $this->requireLogin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $_SESSION['error'] = "Неверный метод запроса"; $this->redirect('/books'); } if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect('/books'); } $user_id = $_SESSION['user_id']; $chapterModel = new Chapter($this->pdo); // Проверяем права доступа if (!$chapterModel->userOwnsChapter($id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой главе"; $this->redirect('/books'); } $chapter = $chapterModel->findById($id); $book_id = $chapter['book_id']; // Удаляем главу if ($chapterModel->delete($id)) { $_SESSION['success'] = "Глава успешно удалена"; } else { $_SESSION['error'] = "Ошибка при удалении главы"; } $this->redirect("/books/{$book_id}/chapters"); } public function preview() { $this->requireLogin(); $content = $_POST['content'] ?? ''; $content = $this->cleanChapterContent($content); $title = $_POST['title'] ?? 'Предпросмотр'; $this->render('chapters/preview', [ 'content' => $content, 'title' => $title, 'page_title' => "Предпросмотр: " . e($title) ]); } // Добавьте эту функцию в начало файла function cleanChapterContent($content) { // Удаляем лишние пробелы в начале и конце $content = trim($content); // Удаляем пустые абзацы и параграфы, содержащие только пробелы $content = preg_replace('/]*>\s*(?:| )?\s*<\/p>/i', '', $content); $content = preg_replace('/]*>\s*<\/p>/i', '', $content); // Удаляем последовательные пустые абзацы $content = preg_replace('/(<\/p>\s*]*>)+/', '

', $content); // Удаляем лишние пробелы в начале и конце каждого параграфа $content = preg_replace('/(]*>)\s+/', '$1', $content); $content = preg_replace('/\s+<\/p>/', '

', $content); // Удаляем лишние переносы строк $content = preg_replace('/\n{3,}/', "\n\n", $content); return $content; } } ?> // ./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 = $chapterModel->getPublishedChapters($book['id']); // Получаем информацию об авторе $author_name = $this->getAuthorName($book['user_id']); $this->handleExport($book, $chapters, true, $author_name, $format); } private function getAuthorName($user_id) { $stmt = $this->pdo->prepare("SELECT display_name, username FROM users WHERE id = ?"); $stmt->execute([$user_id]); $author_info = $stmt->fetch(PDO::FETCH_ASSOC); if ($author_info && $author_info['display_name'] != "") { return $author_info['display_name']; } elseif ($author_info) { return $author_info['username']; } return "Неизвестный автор"; } private function handleExport($book, $chapters, $is_public, $author_name, $format) { switch ($format) { case 'pdf': $this->exportPDF($book, $chapters, $is_public, $author_name); break; case 'docx': $this->exportDOCX($book, $chapters, $is_public, $author_name); break; case 'html': $this->exportHTML($book, $chapters, $is_public, $author_name); break; case 'txt': $this->exportTXT($book, $chapters, $is_public, $author_name); break; default: $_SESSION['error'] = "Неверный формат экспорта"; $redirect_url = $is_public ? "/book/{$book['share_token']}" : "/books/{$book['id']}/edit"; $this->redirect($redirect_url); } } function exportPDF($book, $chapters, $is_public, $author_name) { $pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false); // Устанавливаем метаданные документа $pdf->SetCreator(APP_NAME); $pdf->SetAuthor($author_name); $pdf->SetTitle($book['title']); $pdf->SetSubject($book['genre'] ?? ''); // Устанавливаем margins $pdf->SetMargins(15, 25, 15); $pdf->SetHeaderMargin(10); $pdf->SetFooterMargin(10); // Устанавливаем авто разрыв страниц $pdf->SetAutoPageBreak(TRUE, 15); // Добавляем страницу $pdf->AddPage(); // Устанавливаем шрифт с поддержкой кириллицы $pdf->SetFont('dejavusans', '', 12); // Заголовок книги $pdf->SetFont('dejavusans', 'B', 18); $pdf->Cell(0, 10, $book['title'], 0, 1, 'C'); $pdf->Ln(2); // Автор $pdf->SetFont('dejavusans', 'I', 14); $pdf->Cell(0, 10, $author_name, 0, 1, 'C'); $pdf->Ln(5); // Обложка книги if (!empty($book['cover_image'])) { $cover_path = COVERS_PATH . $book['cover_image']; if (file_exists($cover_path)) { list($width, $height) = getimagesize($cover_path); $max_width = 80; $ratio = $width / $height; $new_height = $max_width / $ratio; $x = (210 - $max_width) / 2; $pdf->Image($cover_path, $x, $pdf->GetY(), $max_width, $new_height, '', '', 'N', false, 300, '', false, false, 0, false, false, false); $pdf->Ln($new_height + 5); } } // Жанр if (!empty($book['genre'])) { $pdf->SetFont('dejavusans', 'I', 12); $pdf->Cell(0, 10, 'Жанр: ' . $book['genre'], 0, 1, 'C'); $pdf->Ln(5); } // Описание if (!empty($book['description'])) { $pdf->SetFont('dejavusans', '', 11); $pdf->MultiCell(0, 6, $book['description'], 0, 'J'); $pdf->Ln(10); } // Интерактивное оглавление $chapterLinks = []; if (!empty($chapters)) { $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C'); $pdf->Ln(5); $toc_page = $pdf->getPage(); $pdf->SetFont('dejavusans', '', 11); foreach ($chapters as $index => $chapter) { $chapter_number = $index + 1; $link = $pdf->AddLink(); $chapterLinks[$chapter['id']] = $link; // Сохраняем ссылку для этой главы $pdf->Cell(0, 6, "{$chapter_number}. {$chapter['title']}", 0, 1, 'L', false, $link); } $pdf->Ln(10); } // Разделитель $pdf->Line(15, $pdf->GetY(), 195, $pdf->GetY()); $pdf->Ln(10); // Главы с закладками и правильными ссылками foreach ($chapters as $index => $chapter) { // Добавляем новую страницу для каждой главы $pdf->AddPage(); // УСТАНАВЛИВАЕМ ЯКОРЬ ДЛЯ ССЫЛКИ if (isset($chapterLinks[$chapter['id']])) { $pdf->SetLink($chapterLinks[$chapter['id']]); } // Устанавливаем закладку для этой главы $pdf->Bookmark($chapter['title'], 0, 0, '', 'B', array(0, 0, 0)); // Название главы $pdf->SetFont('dejavusans', 'B', 14); $pdf->Cell(0, 8, $chapter['title'], 0, 1); $pdf->Ln(2); // Контент главы $pdf->SetFont('dejavusans', '', 11); $htmlContent = $chapter['content']; $pdf->writeHTML($htmlContent, true, false, true, false, ''); $pdf->Ln(8); } // Футер с информацией $pdf->SetY(-25); $pdf->SetFont('dejavusans', 'I', 8); $pdf->Cell(0, 6, 'Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), 0, 1, 'C'); $pdf->Cell(0, 6, 'Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), 0, 1, 'C'); // Отправляем файл $filename = cleanFilename($book['title']) . '.pdf'; $pdf->Output($filename, 'D'); exit; } function exportDOCX($book, $chapters, $is_public, $author_name) { $phpWord = new PhpWord(); // Стили документа $phpWord->setDefaultFontName('Times New Roman'); $phpWord->setDefaultFontSize(12); // Секция документа $section = $phpWord->addSection(); // Заголовок книги $section->addText($book['title'], ['bold' => true, 'size' => 16], ['alignment' => 'center']); $section->addTextBreak(1); // Автор $section->addText($author_name, ['italic' => true, 'size' => 14], ['alignment' => 'center']); $section->addTextBreak(2); // Обложка книги if (!empty($book['cover_image'])) { $cover_path = COVERS_PATH . $book['cover_image']; if (file_exists($cover_path)) { $section->addImage($cover_path, [ 'width' => 150, 'height' => 225, 'alignment' => 'center' ]); $section->addTextBreak(2); } } // Жанр if (!empty($book['genre'])) { $section->addText('Жанр: ' . $book['genre'], ['italic' => true], ['alignment' => 'center']); $section->addTextBreak(1); } // Описание if (!empty($book['description'])) { $descriptionParagraphs = $this->htmlToParagraphs($book['description']); foreach ($descriptionParagraphs as $paragraph) { if (!empty(trim($paragraph))) { $section->addText($paragraph); } } $section->addTextBreak(2); } // Интерактивное оглавление if (!empty($chapters)) { $section->addText('Оглавление', ['bold' => true, 'size' => 14], ['alignment' => 'center']); $section->addTextBreak(1); foreach ($chapters as $index => $chapter) { $chapter_number = $index + 1; // Создаем гиперссылку на заголовок главы $section->addLink("chapter_{$chapter['id']}", "{$chapter_number}. {$chapter['title']}", null, null, true); $section->addTextBreak(1); } $section->addTextBreak(2); } // Разделитель $section->addPageBreak(); // Главы с закладками foreach ($chapters as $index => $chapter) { // Добавляем закладку для главы $section->addBookmark("chapter_{$chapter['id']}"); // Заголовок главы $section->addText($chapter['title'], ['bold' => true, 'size' => 14]); $section->addTextBreak(1); // Получаем очищенный текст и разбиваем на абзацы $cleanContent = strip_tags($chapter['content']); $paragraphs = $this->htmlToParagraphs($chapter['content']); // Добавляем каждый абзац foreach ($paragraphs as $paragraph) { if (!empty(trim($paragraph))) { $section->addText($paragraph); $section->addTextBreak(1); } } // Добавляем разрыв страницы между главами (кроме последней) if ($index < count($chapters) - 1) { $section->addPageBreak(); } } // Футер $section->addTextBreak(2); $section->addText('Экспортировано из ' . APP_NAME . ' - ' . date('d.m.Y H:i'), ['italic' => true, 'size' => 9]); $section->addText('Автор: ' . $author_name . ' | Всего глав: ' . count($chapters) . ' | Всего слов: ' . array_sum(array_column($chapters, 'word_count')), ['italic' => true, 'size' => 9]); // Сохраняем и отправляем $filename = cleanFilename($book['title']) . '.docx'; header('Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document'); header('Content-Disposition: attachment; filename="' . $filename . '"'); $objWriter = IOFactory::createWriter($phpWord, 'Word2007'); $objWriter->save('php://output'); exit; } function exportHTML($book, $chapters, $is_public, $author_name) { $html = ' ' . 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); $available_books = $bookModel->getBooksNotInSeries($user_id, $id); $this->render('series/edit', [ 'series' => $series, 'books_in_series' => $books_in_series, 'available_books' => $available_books, 'error' => $error, 'page_title' => "Редактирование серии: " . e($series['title']) ]); } public function delete($id) { $this->requireLogin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $_SESSION['error'] = "Неверный метод запроса"; $this->redirect('/series'); } if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect('/series'); } $user_id = $_SESSION['user_id']; $seriesModel = new Series($this->pdo); if (!$seriesModel->userOwnsSeries($id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой серии"; $this->redirect('/series'); } if ($seriesModel->delete($id, $user_id)) { $_SESSION['success'] = "Серия успешно удалена"; } else { $_SESSION['error'] = "Ошибка при удалении серии"; } $this->redirect('/series'); } public function viewPublic($id) { $seriesModel = new Series($this->pdo); $series = $seriesModel->findById($id); if (!$series) { http_response_code(404); $this->render('errors/404'); return; } // Получаем только опубликованные книги серии $books = $seriesModel->getBooksInSeries($id, true); // Получаем информацию об авторе $stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?"); $stmt->execute([$series['user_id']]); $author = $stmt->fetch(PDO::FETCH_ASSOC); // Получаем статистику по опубликованным книгам $bookModel = new Book($this->pdo); $total_words = 0; $total_chapters = 0; foreach ($books as $book) { $book_stats = $bookModel->getBookStats($book['id'], true); $total_words += $book_stats['total_words'] ?? 0; $total_chapters += $book_stats['chapter_count'] ?? 0; } $this->render('series/view_public', [ 'series' => $series, 'books' => $books, 'author' => $author, 'total_words' => $total_words, 'total_chapters' => $total_chapters, 'page_title' => $series['title'] . ' — серия книг' ]); } public function addBook($series_id) { $this->requireLogin(); $user_id = $_SESSION['user_id']; $seriesModel = new Series($this->pdo); $bookModel = new Book($this->pdo); if (!$seriesModel->userOwnsSeries($series_id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой серии"; $this->redirect('/series'); } if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect("/series/{$series_id}/edit"); } $book_id = (int)($_POST['book_id'] ?? 0); $sort_order = (int)($_POST['sort_order'] ?? 0); if (!$book_id) { $_SESSION['error'] = "Выберите книгу"; $this->redirect("/series/{$series_id}/edit"); } // Проверяем, что книга принадлежит пользователю if (!$bookModel->userOwnsBook($book_id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой книге"; $this->redirect("/series/{$series_id}/edit"); } // Добавляем книгу в серию if ($bookModel->updateSeriesInfo($book_id, $series_id, $sort_order)) { $_SESSION['success'] = "Книга добавлена в серию"; } else { $_SESSION['error'] = "Ошибка при добавлении книги в серию"; } $this->redirect("/series/{$series_id}/edit"); } } public function removeBook($series_id, $book_id) { $this->requireLogin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $_SESSION['error'] = "Неверный метод запроса"; $this->redirect("/series/{$series_id}/edit"); } if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect("/series/{$series_id}/edit"); } $user_id = $_SESSION['user_id']; $seriesModel = new Series($this->pdo); $bookModel = new Book($this->pdo); if (!$seriesModel->userOwnsSeries($series_id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой серии"; $this->redirect('/series'); } // Проверяем, что книга принадлежит пользователю if (!$bookModel->userOwnsBook($book_id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой книге"; $this->redirect("/series/{$series_id}/edit"); } // Удаляем книгу из серии if ($bookModel->removeFromSeries($book_id)) { $_SESSION['success'] = "Книга удалена из серии"; } else { $_SESSION['error'] = "Ошибка при удалении книги из серии"; } $this->redirect("/series/{$series_id}/edit"); } public function updateBookOrder($series_id) { $this->requireLogin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $_SESSION['error'] = "Неверный метод запроса"; $this->redirect("/series/{$series_id}/edit"); } if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect("/series/{$series_id}/edit"); } $user_id = $_SESSION['user_id']; $seriesModel = new Series($this->pdo); $bookModel = new Book($this->pdo); if (!$seriesModel->userOwnsSeries($series_id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой серии"; $this->redirect('/series'); } $order_data = $_POST['order'] ?? []; if (empty($order_data)) { $_SESSION['error'] = "Нет данных для обновления"; $this->redirect("/series/{$series_id}/edit"); } // Обновляем порядок книг if ($bookModel->reorderSeriesBooks($series_id, $order_data)) { $_SESSION['success'] = "Порядок книг обновлен"; } else { $_SESSION['error'] = "Ошибка при обновлении порядка книг"; } $this->redirect("/series/{$series_id}/edit"); } } ?> // ./controllers/UserController.php 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']); $error = ''; $cover_error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect('/books/create'); } $title = trim($_POST['title'] ?? ''); if (empty($title)) { $_SESSION['error'] = "Название книги обязательно"; $this->redirect('/books/create'); } $bookModel = new Book($this->pdo); $data = [ 'title' => $title, 'description' => trim($_POST['description'] ?? ''), 'genre' => trim($_POST['genre'] ?? ''), 'user_id' => $_SESSION['user_id'], 'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null, 'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null, 'published' => isset($_POST['published']) ? 1 : 0 ]; if ($bookModel->create($data)) { $new_book_id = $this->pdo->lastInsertId(); // Обработка загрузки обложки if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) { $cover_result = handleCoverUpload($_FILES['cover_image'], $new_book_id); if ($cover_result['success']) { $bookModel->updateCover($new_book_id, $cover_result['filename']); } else { $cover_error = $cover_result['error']; // Сохраняем ошибку в сессии, чтобы показать после редиректа $_SESSION['cover_error'] = $cover_error; } } $_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : ""); $this->redirect("/books/{$new_book_id}/edit"); } else { $_SESSION['error'] = "Ошибка при создании книги"; } } $this->render('books/create', [ 'series' => $series, 'error' => $error, 'cover_error' => $cover_error, 'page_title' => 'Создание новой книги' ]); } public function edit($id) { $this->requireLogin(); $bookModel = new Book($this->pdo); $book = $bookModel->findById($id); if (!$book || $book['user_id'] != $_SESSION['user_id']) { $_SESSION['error'] = "Книга не найдена или у вас нет доступа"; $this->redirect('/books'); } $seriesModel = new Series($this->pdo); $series = $seriesModel->findByUser($_SESSION['user_id']); $error = ''; $cover_error = ''; if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $error = "Ошибка безопасности"; } else { $title = trim($_POST['title'] ?? ''); if (empty($title)) { $error = "Название книги обязательно"; } else { $data = [ 'title' => $title, 'description' => trim($_POST['description'] ?? ''), 'genre' => trim($_POST['genre'] ?? ''), 'user_id' => $_SESSION['user_id'], 'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null, 'sort_order_in_series' => !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null, 'published' => isset($_POST['published']) ? 1 : 0 ]; // Обработка обложки if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) { $cover_result = handleCoverUpload($_FILES['cover_image'], $id); if ($cover_result['success']) { $bookModel->updateCover($id, $cover_result['filename']); } else { $cover_error = $cover_result['error']; } } // Удаление обложки if (isset($_POST['delete_cover']) && $_POST['delete_cover'] == '1') { $bookModel->deleteCover($id); } // Обновление книги $success = $bookModel->update($id, $data); if ($success) { $success_message = "Книга успешно обновлена"; $_SESSION['success'] = $success_message; $this->redirect("/books/{$id}/edit"); } else { $error = "Ошибка при обновлении книги"; } } } } // Получаем статистику по главам для отображения в шаблоне $chapterModel = new Chapter($this->pdo); $chapters = $chapterModel->findByBook($id); $this->render('books/edit', [ 'book' => $book, 'series' => $series, 'chapters' => $chapters, 'error' => $error, 'cover_error' => $cover_error, 'page_title' => 'Редактирование книги' ]); } public function delete($id) { $this->requireLogin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $_SESSION['error'] = "Неверный метод запроса"; $this->redirect('/books'); } if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect('/books'); } $user_id = $_SESSION['user_id']; $bookModel = new Book($this->pdo); if (!$bookModel->userOwnsBook($id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой книге"; $this->redirect('/books'); } if ($bookModel->delete($id, $user_id)) { $_SESSION['success'] = "Книга успешно удалена"; } else { $_SESSION['error'] = "Ошибка при удалении книги"; } $this->redirect('/books'); } public function deleteAll() { $this->requireLogin(); $user_id = $_SESSION['user_id']; if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect('/books'); } $bookModel = new Book($this->pdo); // Получаем все книги пользователя $books = $bookModel->findByUser($user_id); if (empty($books)) { $_SESSION['info'] = "У вас нет книг для удаления"; $this->redirect('/books'); } try { $this->pdo->beginTransaction(); $deleted_count = 0; $deleted_covers = 0; foreach ($books as $book) { // Удаляем обложку если она есть if (!empty($book['cover_image'])) { $cover_path = COVERS_PATH . $book['cover_image']; if (file_exists($cover_path) && unlink($cover_path)) { $deleted_covers++; } } // Удаляем главы книги $stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id = ?"); $stmt->execute([$book['id']]); // Удаляем саму книгу $stmt = $this->pdo->prepare("DELETE FROM books WHERE id = ? AND user_id = ?"); $stmt->execute([$book['id'], $user_id]); $deleted_count++; } $this->pdo->commit(); $message = "Все книги успешно удалены ($deleted_count книг"; if ($deleted_covers > 0) { $message .= ", удалено $deleted_covers обложек"; } $message .= ")"; $_SESSION['success'] = $message; } catch (Exception $e) { $this->pdo->rollBack(); error_log("Ошибка при массовом удалении: " . $e->getMessage()); $_SESSION['error'] = "Произошла ошибка при удалении книг: " . $e->getMessage(); } $this->redirect('/books'); } public function viewPublic($share_token) { $bookModel = new Book($this->pdo); $chapterModel = new Chapter($this->pdo); $book = $bookModel->findByShareToken($share_token); if (!$book) { http_response_code(404); $this->render('errors/404'); return; } $chapters = $chapterModel->getPublishedChapters($book['id']); // Получаем информацию об авторе $stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?"); $stmt->execute([$book['user_id']]); $author = $stmt->fetch(PDO::FETCH_ASSOC); $this->render('books/view_public', [ 'book' => $book, 'chapters' => $chapters, 'author' => $author, 'page_title' => $book['title'] ]); } public function viewAll($id) { $bookModel = new Book($this->pdo); $chapterModel = new Chapter($this->pdo); $book = $bookModel->findById($id); if (!$book) { http_response_code(404); $this->render('errors/404'); return; } $chapters = $chapterModel->findByBook($book['id']); // Получаем информацию об авторе $stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?"); $stmt->execute([$book['user_id']]); $author = $stmt->fetch(PDO::FETCH_ASSOC); $this->render('books/view_public', [ 'book' => $book, 'chapters' => $chapters, 'author' => $author, 'page_title' => $book['title'] ]); } public function regenerateToken($id) { $this->requireLogin(); if ($_SERVER['REQUEST_METHOD'] !== 'POST') { $_SESSION['error'] = "Неверный метод запроса"; $this->redirect("/books/{$id}/edit"); } if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { $_SESSION['error'] = "Ошибка безопасности"; $this->redirect("/books/{$id}/edit"); } $user_id = $_SESSION['user_id']; $bookModel = new Book($this->pdo); if (!$bookModel->userOwnsBook($id, $user_id)) { $_SESSION['error'] = "У вас нет доступа к этой книге"; $this->redirect('/books'); } $new_token = $bookModel->generateNewShareToken($id); if ($new_token) { $_SESSION['success'] = "Ссылка успешно обновлена"; } else { $_SESSION['error'] = "Ошибка при обновлении ссылки"; } $this->redirect("/books/{$id}/edit"); } } ?> // ./controllers/AdminController.php 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; } public function getPublishedChapters($book_id) { $stmt = $this->pdo->prepare(" SELECT * FROM chapters WHERE book_id = ? AND status = 'published' ORDER BY sort_order, created_at "); $stmt->execute([$book_id]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } // private function getAllChapters($book_id) { // $stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?"); // $stmt->execute([$book_id]); // return $stmt->fetchAll(PDO::FETCH_ASSOC); // } // private function updateChapterContent($chapter_id, $content) { // $word_count = $this->countWords($content); // $stmt = $this->pdo->prepare(" // UPDATE chapters // SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP // WHERE id = ? // "); // return $stmt->execute([$content, $word_count, $chapter_id]); // } } ?> // ./models/Series.php 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 updateCover($book_id, $filename) { $stmt = $this->pdo->prepare("UPDATE books SET cover_image = ? WHERE id = ?"); return $stmt->execute([$filename, $book_id]); } public function deleteCover($book_id) { $book = $this->findById($book_id); $old_filename = $book['cover_image']; if ($old_filename) { $file_path = COVERS_PATH . $old_filename; if (file_exists($file_path)) { unlink($file_path); } } $stmt = $this->pdo->prepare("UPDATE books SET cover_image = NULL WHERE id = ?"); return $stmt->execute([$book_id]); } public function updateSeriesInfo($book_id, $series_id, $sort_order) { $stmt = $this->pdo->prepare("UPDATE books SET series_id = ?, sort_order_in_series = ? WHERE id = ?"); return $stmt->execute([$series_id, $sort_order, $book_id]); } public function removeFromSeries($book_id) { $stmt = $this->pdo->prepare("UPDATE books SET series_id = NULL, sort_order_in_series = NULL WHERE id = ?"); return $stmt->execute([$book_id]); } public function findBySeries($series_id) { $stmt = $this->pdo->prepare(" SELECT b.* FROM books b WHERE b.series_id = ? ORDER BY b.sort_order_in_series, b.created_at "); $stmt->execute([$series_id]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } public function getBookStats($book_id, $only_published_chapters = false) { $sql = " SELECT COUNT(c.id) as chapter_count, COALESCE(SUM(c.word_count), 0) as total_words FROM books b LEFT JOIN chapters c ON b.id = c.book_id WHERE b.id = ? "; if ($only_published_chapters) { $sql .= " AND c.status = 'published'"; } $stmt = $this->pdo->prepare($sql); $stmt->execute([$book_id]); return $stmt->fetch(PDO::FETCH_ASSOC); } public function getBooksNotInSeries($user_id, $series_id = null) { $sql = "SELECT * FROM books WHERE user_id = ? AND (series_id IS NULL OR series_id != ? OR series_id = 0)"; $stmt = $this->pdo->prepare($sql); $stmt->execute([$user_id, $series_id]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } public function reorderSeriesBooks($series_id, $new_order) { try { $this->pdo->beginTransaction(); foreach ($new_order as $order => $book_id) { $stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?"); $stmt->execute([$order + 1, $book_id, $series_id]); } $this->pdo->commit(); return true; } catch (Exception $e) { $this->pdo->rollBack(); error_log("Ошибка при обновлении порядка книг: " . $e->getMessage()); return false; } } private function countWords($text) { $text = strip_tags($text); $text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text); $words = preg_split('/\s+/', $text); $words = array_filter($words); return count($words); } } ?> // ./index.php 'text/css', 'js' => 'application/javascript', 'png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp', 'svg' => 'image/svg+xml', 'ico' => 'image/x-icon', 'json' => 'application/json', 'woff' => 'font/woff', 'woff2' => 'font/woff2', 'ttf' => 'font/ttf', 'eot' => 'application/vnd.ms-fontobject', ]; $extension = strtolower(pathinfo($physicalPath, PATHINFO_EXTENSION)); if (isset($mimeTypes[$extension])) { header('Content-Type: ' . $mimeTypes[$extension]); } // Запрещаем кэширование для разработки, в продакшене можно увеличить время header('Cache-Control: public, max-age=3600'); // Отправляем файл readfile($physicalPath); exit; } } // Простой роутер class Router { private $routes = []; public function add($pattern, $handler) { $this->routes[$pattern] = $handler; } public function handle($uri) { // Убираем базовый URL если есть $basePath = parse_url(SITE_URL, PHP_URL_PATH) ?? ''; $uri = str_replace($basePath, '', $uri); $uri = parse_url($uri, PHP_URL_PATH) ?? '/'; foreach ($this->routes as $pattern => $handler) { if ($this->match($pattern, $uri)) { return $this->callHandler($handler, $this->params); } } // 404 http_response_code(404); include 'views/errors/404.php'; exit; } private function match($pattern, $uri) { $pattern = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern); $pattern = "#^$pattern$#"; if (preg_match($pattern, $uri, $matches)) { $this->params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); return true; } return false; } private function callHandler($handler, $params) { if (is_callable($handler)) { return call_user_func_array($handler, array_values($params)); } if (is_string($handler)) { list($controller, $method) = explode('@', $handler); $controllerFile = "controllers/{$controller}.php"; if (file_exists($controllerFile)) { require_once $controllerFile; $controllerInstance = new $controller(); if (method_exists($controllerInstance, $method)) { return call_user_func_array([$controllerInstance, $method], array_values($params)); } } } throw new Exception("Handler not found"); } } // Инициализация роутера $router = new Router(); // Маршруты $router->add('/', 'DashboardController@index'); $router->add('/dashboard', 'DashboardController@index'); $router->add('/index.php', 'DashboardController@index'); $router->add('/login', 'AuthController@login'); $router->add('/logout', 'AuthController@logout'); $router->add('/register', 'AuthController@register'); // Книги $router->add('/books', 'BookController@index'); $router->add('/book/all/{id}', 'BookController@viewAll'); $router->add('/books/create', 'BookController@create'); $router->add('/books/{id}/edit', 'BookController@edit'); $router->add('/books/{id}/delete', 'BookController@delete'); $router->add('/books/delete-all', 'BookController@deleteAll'); $router->add('/books/{id}/normalize', 'BookController@normalizeContent'); $router->add('/books/{id}/regenerate-token', 'BookController@regenerateToken'); // Главы $router->add('/books/{book_id}/chapters', 'ChapterController@index'); $router->add('/books/{book_id}/chapters/create', 'ChapterController@create'); $router->add('/chapters/{id}/edit', 'ChapterController@edit'); $router->add('/chapters/{id}/delete', 'ChapterController@delete'); $router->add('/chapters/preview', 'ChapterController@preview'); // Серии $router->add('/series', 'SeriesController@index'); $router->add('/series/create', 'SeriesController@create'); $router->add('/series/{id}/edit', 'SeriesController@edit'); $router->add('/series/{id}/delete', 'SeriesController@delete'); $router->add('/series/{id}/add-book', 'SeriesController@addBook'); $router->add('/series/{id}/remove-book/{book_id}', 'SeriesController@removeBook'); $router->add('/series/{id}/update-order', 'SeriesController@updateBookOrder'); // Профиль $router->add('/profile', 'UserController@profile'); $router->add('/profile/update', 'UserController@updateProfile'); // Экспорт с параметром формата //публичный экспорт $router->add('/export/shared/{share_token}/{format}', 'ExportController@exportShared'); $router->add('/export/shared/{share_token}', 'ExportController@exportShared'); // по умолчанию pdf //авторсикй экспорт $router->add('/export/{book_id}/{format}', 'ExportController@export'); $router->add('/export/{book_id}', 'ExportController@export'); // по умолчанию pdf // Публичные страницы $router->add('/book/{share_token}', 'BookController@viewPublic'); $router->add('/author/{id}', 'UserController@viewPublic'); $router->add('/series/{id}/view', 'SeriesController@viewPublic'); // Администрирование $router->add('/admin/users', 'AdminController@users'); $router->add('/admin/add-user', 'AdminController@addUser'); $router->add('/admin/user/{user_id}/toggle-status', 'AdminController@toggleUserStatus'); $router->add('/admin/user/{user_id}/delete', 'AdminController@deleteUser'); // Обработка запроса $requestUri = $_SERVER['REQUEST_URI']; $router->handle($requestUri); // Редирект с корня на dashboard для авторизованных $router->add('/', function() { if (is_logged_in()) { header("Location: " . SITE_URL . "/dashboard"); } else { header("Location: " . SITE_URL . "/login"); } exit; }); ?> // ./includes/functions.php 100) { $filename = substr($filename, 0, 100); } return $filename; } function handleCoverUpload($file, $book_id) { global $pdo; // Проверяем папку для загрузок if (!file_exists(COVERS_PATH)) { mkdir(COVERS_PATH, 0755, true); } $allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; $max_size = 5 * 1024 * 1024; // 5MB // Проверка типа файла $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mime_type, $allowed_types)) { return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения']; } // Проверка размера if ($file['size'] > $max_size) { return ['success' => false, 'error' => 'Размер изображения не должен превышать 5MB']; } // Проверка на ошибки загрузки if ($file['error'] !== UPLOAD_ERR_OK) { return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']]; } // Генерация уникального имени файла $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $filename = 'cover_' . $book_id . '_' . time() . '.' . $extension; $file_path = COVERS_PATH . $filename; // Удаляем старую обложку если есть $bookModel = new Book($pdo); $bookModel->deleteCover($book_id); // Сохраняем новую обложку if (move_uploaded_file($file['tmp_name'], $file_path)) { // Оптимизируем изображение optimizeImage($file_path); return ['success' => true, 'filename' => $filename]; } else { return ['success' => false, 'error' => 'Не удалось сохранить файл']; } } function optimizeImage($file_path) { list($width, $height, $type) = getimagesize($file_path); $max_width = 800; $max_height = 1200; if ($width > $max_width || $height > $max_height) { // Вычисляем новые размеры $ratio = $width / $height; if ($max_width / $max_height > $ratio) { $new_width = $max_height * $ratio; $new_height = $max_height; } else { $new_width = $max_width; $new_height = $max_width / $ratio; } // Создаем новое изображение $new_image = imagecreatetruecolor($new_width, $new_height); // Загружаем исходное изображение в зависимости от типа switch ($type) { case IMAGETYPE_JPEG: $source = imagecreatefromjpeg($file_path); break; case IMAGETYPE_PNG: $source = imagecreatefrompng($file_path); // Сохраняем прозрачность для PNG imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127)); imagealphablending($new_image, false); imagesavealpha($new_image, true); break; case IMAGETYPE_GIF: $source = imagecreatefromgif($file_path); break; default: return; // Не поддерживаемый тип } // Ресайз и сохраняем imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height); switch ($type) { case IMAGETYPE_JPEG: imagejpeg($new_image, $file_path, 85); break; case IMAGETYPE_PNG: imagepng($new_image, $file_path, 8); break; case IMAGETYPE_GIF: imagegif($new_image, $file_path); break; } // Освобождаем память imagedestroy($source); imagedestroy($new_image); } } function handleAvatarUpload($file, $user_id) { global $pdo; // Проверяем папку для загрузок if (!file_exists(AVATARS_PATH)) { mkdir(AVATARS_PATH, 0755, true); } $allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; $max_size = 2 * 1024 * 1024; // 2MB // Проверка типа файла $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mime_type, $allowed_types)) { return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения']; } // Проверка размера if ($file['size'] > $max_size) { return ['success' => false, 'error' => 'Размер изображения не должен превышать 2MB']; } // Проверка на ошибки загрузки if ($file['error'] !== UPLOAD_ERR_OK) { return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']]; } // Проверка реального типа файла по содержимому $allowed_signatures = [ 'image/jpeg' => "\xFF\xD8\xFF", 'image/png' => "\x89\x50\x4E\x47", 'image/gif' => "GIF", 'image/webp' => "RIFF" ]; $file_content = file_get_contents($file['tmp_name']); $signature = substr($file_content, 0, 4); $valid_signature = false; foreach ($allowed_signatures as $type => $sig) { if (strpos($signature, $sig) === 0) { $valid_signature = true; break; } } if (!$valid_signature) { return ['success' => false, 'error' => 'Неверный формат изображения']; } // Генерация уникального имени файла $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $filename = 'avatar_' . $user_id . '_' . time() . '.' . $extension; $file_path = AVATARS_PATH . $filename; // Удаляем старый аватар если есть $userModel = new User($pdo); $user = $userModel->findById($user_id); if (!empty($user['avatar'])) { $old_file_path = AVATARS_PATH . $user['avatar']; if (file_exists($old_file_path)) { unlink($old_file_path); } } // Сохраняем новую аватарку if (move_uploaded_file($file['tmp_name'], $file_path)) { // Оптимизируем изображение optimizeAvatar($file_path); return ['success' => true, 'filename' => $filename]; } else { return ['success' => false, 'error' => 'Не удалось сохранить файл']; } } function optimizeAvatar($file_path) { // Оптимизация аватарки - ресайз до 200x200 list($width, $height, $type) = getimagesize($file_path); $max_size = 200; if ($width > $max_size || $height > $max_size) { // Вычисляем новые размеры $ratio = $width / $height; if ($ratio > 1) { $new_width = $max_size; $new_height = $max_size / $ratio; } else { $new_width = $max_size * $ratio; $new_height = $max_size; } // Создаем новое изображение $new_image = imagecreatetruecolor($new_width, $new_height); // Загружаем исходное изображение в зависимости от типа switch ($type) { case IMAGETYPE_JPEG: $source = imagecreatefromjpeg($file_path); break; case IMAGETYPE_PNG: $source = imagecreatefrompng($file_path); // Сохраняем прозрачность для PNG imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127)); imagealphablending($new_image, false); imagesavealpha($new_image, true); break; case IMAGETYPE_GIF: $source = imagecreatefromgif($file_path); break; case IMAGETYPE_WEBP: $source = imagecreatefromwebp($file_path); break; default: return; // Не поддерживаемый тип } // Ресайз и сохраняем imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height); switch ($type) { case IMAGETYPE_JPEG: imagejpeg($new_image, $file_path, 85); break; case IMAGETYPE_PNG: imagepng($new_image, $file_path, 8); break; case IMAGETYPE_GIF: imagegif($new_image, $file_path); break; case IMAGETYPE_WEBP: imagewebp($new_image, $file_path, 85); break; } // Освобождаем память imagedestroy($source); imagedestroy($new_image); } } function deleteUserAvatar($user_id) { global $pdo; $userModel = new User($pdo); $user = $userModel->findById($user_id); if (!empty($user['avatar'])) { $file_path = AVATARS_PATH . $user['avatar']; if (file_exists($file_path)) { unlink($file_path); } // Обновляем запись в БД $stmt = $pdo->prepare("UPDATE users SET avatar = NULL WHERE id = ?"); return $stmt->execute([$user_id]); } return true; } ?> // ./includes/index.php // ./composer.lock { "_readme": [ "This file locks the dependencies of your project to a known state", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], "content-hash": "493a3be12648bbe702ed126df05ead04", "packages": [ { "name": "cybermonde/odtphp", "version": "v1.7", "source": { "type": "git", "url": "https://github.com/cybermonde/odtphp.git", "reference": "23aba70923ca3c07af15a5600f4072751c1b4a36" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/cybermonde/odtphp/zipball/23aba70923ca3c07af15a5600f4072751c1b4a36", "reference": "23aba70923ca3c07af15a5600f4072751c1b4a36", "shasum": "" }, "require": { "php": ">=5.2.4" }, "type": "library", "autoload": { "classmap": [ "library" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "GPL" ], "description": "ODT document generator", "homepage": "https://github.com/cybermonde/odtphp", "keywords": [ "odt", "php" ], "support": { "issues": "https://github.com/cybermonde/odtphp/issues", "source": "https://github.com/cybermonde/odtphp/tree/v1.7" }, "time": "2015-06-02T07:28:25+00:00" }, { "name": "phpoffice/math", "version": "0.3.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/Math.git", "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/PHPOffice/Math/zipball/fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", "reference": "fc31c8f57a7a81f962cbf389fd89f4d9d06fc99a", "shasum": "" }, "require": { "ext-dom": "*", "ext-xml": "*", "php": "^7.1|^8.0" }, "require-dev": { "phpstan/phpstan": "^0.12.88 || ^1.0.0", "phpunit/phpunit": "^7.0 || ^9.0" }, "type": "library", "autoload": { "psr-4": { "PhpOffice\\Math\\": "src/Math/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { "name": "Progi1984", "homepage": "https://lefevre.dev" } ], "description": "Math - Manipulate Math Formula", "homepage": "https://phpoffice.github.io/Math/", "keywords": [ "MathML", "officemathml", "php" ], "support": { "issues": "https://github.com/PHPOffice/Math/issues", "source": "https://github.com/PHPOffice/Math/tree/0.3.0" }, "time": "2025-05-29T08:31:49+00:00" }, { "name": "phpoffice/phpword", "version": "1.4.0", "source": { "type": "git", "url": "https://github.com/PHPOffice/PHPWord.git", "reference": "6d75328229bc93790b37e93741adf70646cea958" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/6d75328229bc93790b37e93741adf70646cea958", "reference": "6d75328229bc93790b37e93741adf70646cea958", "shasum": "" }, "require": { "ext-dom": "*", "ext-gd": "*", "ext-json": "*", "ext-xml": "*", "ext-zip": "*", "php": "^7.1|^8.0", "phpoffice/math": "^0.3" }, "require-dev": { "dompdf/dompdf": "^2.0 || ^3.0", "ext-libxml": "*", "friendsofphp/php-cs-fixer": "^3.3", "mpdf/mpdf": "^7.0 || ^8.0", "phpmd/phpmd": "^2.13", "phpstan/phpstan": "^0.12.88 || ^1.0.0", "phpstan/phpstan-phpunit": "^1.0 || ^2.0", "phpunit/phpunit": ">=7.0", "symfony/process": "^4.4 || ^5.0", "tecnickcom/tcpdf": "^6.5" }, "suggest": { "dompdf/dompdf": "Allows writing PDF", "ext-xmlwriter": "Allows writing OOXML and ODF", "ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template" }, "type": "library", "autoload": { "psr-4": { "PhpOffice\\PhpWord\\": "src/PhpWord" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "LGPL-3.0-only" ], "authors": [ { "name": "Mark Baker" }, { "name": "Gabriel Bull", "email": "me@gabrielbull.com", "homepage": "http://gabrielbull.com/" }, { "name": "Franck Lefevre", "homepage": "https://rootslabs.net/blog/" }, { "name": "Ivan Lanin", "homepage": "http://ivan.lanin.org" }, { "name": "Roman Syroeshko", "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" }, { "name": "Antoine de Troostembergh" } ], "description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)", "homepage": "https://phpoffice.github.io/PHPWord/", "keywords": [ "ISO IEC 29500", "OOXML", "Office Open XML", "OpenDocument", "OpenXML", "PhpOffice", "PhpWord", "Rich Text Format", "WordprocessingML", "doc", "docx", "html", "odf", "odt", "office", "pdf", "php", "reader", "rtf", "template", "template processor", "word", "writer" ], "support": { "issues": "https://github.com/PHPOffice/PHPWord/issues", "source": "https://github.com/PHPOffice/PHPWord/tree/1.4.0" }, "time": "2025-06-05T10:32:36+00:00" }, { "name": "tecnickcom/tcpdf", "version": "6.10.0", "source": { "type": "git", "url": "https://github.com/tecnickcom/TCPDF.git", "reference": "ca5b6de294512145db96bcbc94e61696599c391d" }, "dist": { "type": "zip", "url": "https://api.github.com/repos/tecnickcom/TCPDF/zipball/ca5b6de294512145db96bcbc94e61696599c391d", "reference": "ca5b6de294512145db96bcbc94e61696599c391d", "shasum": "" }, "require": { "ext-curl": "*", "php": ">=7.1.0" }, "type": "library", "autoload": { "classmap": [ "config", "include", "tcpdf.php", "tcpdf_barcodes_1d.php", "tcpdf_barcodes_2d.php", "include/tcpdf_colors.php", "include/tcpdf_filters.php", "include/tcpdf_font_data.php", "include/tcpdf_fonts.php", "include/tcpdf_images.php", "include/tcpdf_static.php", "include/barcodes/datamatrix.php", "include/barcodes/pdf417.php", "include/barcodes/qrcode.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "LGPL-3.0-or-later" ], "authors": [ { "name": "Nicola Asuni", "email": "info@tecnick.com", "role": "lead" } ], "description": "TCPDF is a PHP class for generating PDF documents and barcodes.", "homepage": "http://www.tcpdf.org/", "keywords": [ "PDFD32000-2008", "TCPDF", "barcodes", "datamatrix", "pdf", "pdf417", "qrcode" ], "support": { "issues": "https://github.com/tecnickcom/TCPDF/issues", "source": "https://github.com/tecnickcom/TCPDF/tree/6.10.0" }, "funding": [ { "url": "https://www.paypal.com/donate/?hosted_button_id=NZUEC5XS8MFBJ", "type": "custom" } ], "time": "2025-05-27T18:02:28+00:00" } ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": {}, "platform-dev": {}, "plugin-api-version": "2.9.0" } // ./README.md # Web Writer **Лицензия:** AGPLv3 **Web Writer** — веб-приложение для написания, хранения и публикации книг и серий книг. Поддерживает Markdown с расширением для диалогов, автосохранение текста, экспорт и управление пользователями. --- ## 🚀 Возможности - **Книги и серии:** создавайте серии и добавляйте книги с главами. - **Редактор книг:** Markdown, автосохранение текста, интерактивное содержание. - **Предпросмотр книг:** - **Автор:** видит все черновики и опубликованные главы. - **Публичный доступ:** только опубликованные главы по ссылке с `shared_token`. - **Обложки и аватары:** добавляйте изображения к книгам и профилям. - **Экспорт:** PDF, DOCX, HTML, TXT. - **Администрирование пользователей:** - Управление аккаунтами, активация/деактивация. - При удалении пользователя удаляются все его книги. - **Публичные ссылки:** делитесь `shared_token` для просмотра опубликованных глав. --- ## ⚙️ Требования - **PHP:** 8.0 и выше - **MySQL** с InnoDB и внешними ключами - **PHP расширения:** `mbstring`, `json`, `PDO` - Веб-сервер с правами на запись в папки `config/` и `uploads/` > Все библиотеки уже включены в `vendor/`. Composer не нужен. --- ## 🛠 Установка 1. Скопируйте файлы на веб-сервер. 2. Проверьте доступность папок `config/` и `uploads/` для записи. 3. Перейдите в браузере на `install.php` и следуйте шагам: **Шаг 1: Настройки базы данных** - Хост БД - Имя базы данных - Пользователь и пароль **Шаг 2: Создание администратора** - Имя пользователя - Пароль - Email (по желанию) - Отображаемое имя (по желанию) 4. После успешной установки файл `config/config.php` будет сгенерирован автоматически. 5. Перейдите на главную страницу приложения (`index.php`) и войдите под админом. 6. **Не забудьте удалить или переместить файл install.php!!!** --- ## 📝 Конфигурация Файл `config/config.php` содержит: - Подключение к базе данных: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME` - Пути к файлам: - `UPLOAD_PATH` — корневая папка загрузок - `COVERS_PATH` / `COVERS_URL` — обложки книг - `AVATARS_PATH` / `AVATARS_URL` — аватары пользователей - Адрес сайта: `SITE_URL` - Имя приложения: `APP_NAME` = "Web Writer" --- ## 🛠 Дальнейшее развитие - Планирую вынести работу с сущностями (книги, главы, серии, пользователи) в контроллеры. - Создать единую точку входа для приложения. --- ## ❗ Поддержка Все ошибки и предложения шлите в issue --- ## 📜 Лицензия Приложение распространяется под лицензией [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html). // ./config/index.php // ./config/config.php 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

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

➕ Создать серию
// ./views/series/create.php

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

❌ Отмена

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

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

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

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

// ./views/series/edit.php

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

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

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

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

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

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

$book): ?>
Порядок:
✏️

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

// ./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

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

Ошибка загрузки обложки:
Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
❌ Отмена
// ./views/books/edit.php
Ошибка загрузки обложки:

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

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

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

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

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

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

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

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

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

Название Статус Слов Действия
Редактировать

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

✏️ Добавить первую главу
// ./views/books/view_public.php