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 0000000..b165da3 Binary files /dev/null and b/uploads/covers/cover_2_1764062021.jpg differ diff --git a/uploads/covers/cover_3_1764051570.jpg b/uploads/covers/cover_3_1764051570.jpg new file mode 100644 index 0000000..557bb0b Binary files /dev/null and b/uploads/covers/cover_3_1764051570.jpg differ 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. + +