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