Compare commits

...
28 Commits
Author SHA1 Message Date
mirivlad b69a110b97 fix editor for short_story and chapters 2026-05-01 21:53:43 +03:00
mirivlad ce6fffaa57 many fixes in design and add shirt story type for creation book 2026-05-01 20:51:42 +03:00
mirivlad 9294399517 fix txt export (paragraph problems) 2025-12-06 22:29:20 +03:00
mirivlad 18a338c2ce fix delete chapter in chapter list 2025-12-06 22:09:22 +03:00
mirivlad 3af0f18d55 Merge branch 'main' of https://git.mirv.top/mirivlad/web_writer 2025-11-29 15:24:37 +03:00
mirivlad 7fd1fb20c4 add fullscreen for editor 2025-11-29 15:24:11 +03:00
mirivlad 15ab9d1947 fix 2025-11-28 17:20:33 +08:00
mirivlad 5da459132b fix 2025-11-28 17:10:23 +08:00
mirivlad 6738576501 fix2 2025-11-28 17:08:10 +08:00
mirivlad 35ca6223cb fix 2025-11-28 17:07:08 +08:00
mirivlad b9d3426213 Add screenshots 2025-11-28 17:02:59 +08:00
mirivlad c4eae6b45f fix Readme 2025-11-28 16:48:35 +08:00
mirivlad de4d5ac595 Clear unneeded bootsrap themes files. Change Readme 2025-11-28 16:47:47 +08:00
mirivlad cd382c5c7b Add pagination in users administration. 2025-11-28 04:09:35 +08:00
mirivlad 70b75f8426 Add sorting for chapters in book. 2025-11-28 03:47:39 +08:00
mirivlad e1a5403936 fix formatting and add buttons for formating dialogs in selected text 2025-11-28 03:36:46 +08:00
mirivlad 2aa9b37d4e Add autoformater for book dialogues 2025-11-28 02:32:24 +08:00
mirivlad b32c26cb04 Also one small fix for public view 2025-11-27 15:05:40 +08:00
mirivlad 3edfa05f46 Fix public view for book 2025-11-27 15:04:25 +08:00
mirivlad 37676a3ccb Change info for autosave 2025-11-27 14:52:01 +08:00
mirivlad 39cda7814f Change CSS framework from PicoCSS to Bootstrap 5.3 2025-11-27 14:46:51 +08:00
mirivlad e51768622f Fix public access to private book view 2025-11-27 09:27:40 +08:00
mirivlad d850f54666 remove trash files 2025-11-27 08:57:57 +08:00
mirivlad 07b69b7616 remove ckeditor 2025-11-27 08:54:22 +08:00
mirivlad 14434bc2ac finish line! 2025-11-26 17:25:27 +08:00
mirivlad 833d125f64 continue 2025-11-25 17:25:40 +08:00
mirivlad f093791c14 fixes fixes... 2025-11-24 17:24:34 +08:00
mirivlad 24b3788493 Start small MVC changes 2025-11-24 16:26:13 +08:00
121 changed files with 7518 additions and 8435 deletions
+6
View File
@@ -0,0 +1,6 @@
*.log
.env
config/config.php
*.lock
uploads/*
vendor/composer
Regular → Executable
+79 -15
View File
@@ -8,34 +8,97 @@
## 🚀 Возможности
- **Книги и серии:** создавайте серии и добавляйте книги с главами.
- **Редактор книг:** Markdown, автосохранение текста, интерактивное содержание.
- **Книги и серии:** создавайте серии и добавляйте книги с главами.
- **Сортировка книг и глав:** сортируйте книги в сериях, и главы в книгах в том порядке в каком вам нравится.
- **Редактор книг:**
- WYSIWYG редактор Quill.js
- Автосохранение текста
- Автоматическое форматирование книжных диалогов.
- **Темы оформления:** выберите в настройке тему которая вам больше по душе.
- **Предпросмотр книг:**
- **Автор:** видит все черновики и опубликованные главы.
- **Публичный доступ:** только опубликованные главы по ссылке с `shared_token`.
- **Обложки и аватары:** добавляйте изображения к книгам и профилям.
- **Экспорт:** PDF, DOCX, HTML, TXT.
- **Интерактивное содержание** в просмотре и экспорте книг
- **Администрирование пользователей:**
- Управление аккаунтами, активация/деактивация.
- При удалении пользователя удаляются все его книги.
- **Публичные ссылки:** делитесь `shared_token` для просмотра опубликованных глав.
- **Публичные ссылки:**
- Делитесь книгой по `shared_token`. Хотите чтобы её никто больше не увидел? Перегенерируйте токен!
- Давайте ссылку на свою страницу автора, где будет список опубликованных вами книг.
---
## ⚙️ Требования
## ✨ Скриншоты
**Панель управления и смена темы**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/5.jpg" target="_blank"><img src="assets/images/screens/5.jpg" alt="Панель управления" height="200" /></a>
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/3.jpg" target="_blank"><img src="assets/images/screens/3.jpg" alt="Смена темы" height="200" /></a>
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/2.jpg" target="_blank"><img src="assets/images/screens/2.jpg" alt="Панель управления после смены темы" height="200" /></a>
**Редактор текста**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/10.jpg" target="_blank"><img src="assets/images/screens/10.jpg" alt="Редактор текста Quill" height="200" /></a>
**Управление главами книги**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/9.jpg" target="_blank"><img src="assets/images/screens/9.jpg" alt="Управление главами книги" height="200" /></a>
**Управление сериями книг**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/6.jpg" target="_blank"><img src="assets/images/screens/6.jpg" alt="Управление сериями книг" height="200" /></a>
**Просмотр книги**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/11.jpg" target="_blank"><img src="assets/images/screens/11.jpg" alt="Просмотр книги" height="200" /></a>
---
## ⚙️ Требования и используемые технологии
- **PHP:** 8.0 и выше
- **MySQL** с InnoDB и внешними ключами
- **PHP расширения:** `mbstring`, `json`, `PDO`
- Веб-сервер с правами на запись в папки `config/` и `uploads/`
- **Веб-сервер** (Nginx/Apache) с правами на запись в папки `config/` и `uploads/`
> Все библиотеки уже включены в `vendor/`. Composer не нужен.
- Самописный MVC на PHP + MySQL - максимальная простота и скорость
- Bootstrap5 и темы для него для UI
- Javascript - Quill.js (редактор текста), Sortable.js - для Drag'n'Drop сортировки. Стандартный bundle Bootstrap5. Всё остальное - самописный JS
- TCPDF и PhpWord - для экспорта книг
---
## 📁 Структура проекта
```
web_writer/
├── controllers/ # Контроллеры приложения
│ ├── AuthController.php
│ ├── BookController.php
│ ├── ChapterController.php
│ └── ...
├── models/ # Модели данных
│ ├── User.php
│ ├── Book.php
│ ├── Chapter.php
│ └── ...
├── views/ # Шаблоны представлений
├── assets/ # Статические файлы
│ ├── css/
│ ├── js/
│ └── bs # Темы для Bootstrap
├── uploads/ # Загружаемые файлы
│ ├── covers/ # Обложки книг
│ └── avatars/ # Аватары пользователей
├── vendor/ # Внешние библиотеки
├── config/ # Конфигурационные файлы
└── index.php # Точка входа
```
## 🛠 Установка
1. Скопируйте файлы на веб-сервер.
1. Скопируйте файлы на веб-сервер или склонируйте репозиторий.
2. Проверьте доступность папок `config/` и `uploads/` для записи.
3. Перейдите в браузере на `install.php` и следуйте шагам:
@@ -52,7 +115,7 @@
4. После успешной установки файл `config/config.php` будет сгенерирован автоматически.
5. Перейдите на главную страницу приложения (`index.php`) и войдите под админом.
6. **Не забудьте удалить или переместить файл install.php!!!**
6. **Не забудьте удалить или переместить файл install.php!!!** (не обязательно, но желательно)
---
@@ -61,10 +124,7 @@
Файл `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"
@@ -72,11 +132,12 @@
## 🛠 Дальнейшее развитие
- Планирую вынести работу с сущностями (книги, главы, серии, пользователи) в контроллеры.
- Создать единую точку входа для приложения.
- Возможно, добавится поддержка комментариев для публичных книг и рейтинг. Но это не точно :)
---
## ❗ Поддержка
Все ошибки и предложения шлите в issue
@@ -85,4 +146,7 @@
## 📜 Лицензия
Приложение распространяется под лицензией [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html).
Приложение распространяется под лицензией [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html).
-2
View File
@@ -1,2 +0,0 @@
<?php
die();
-153
View File
@@ -1,153 +0,0 @@
<?php
require_once '../config/config.php';
require_login();
// Проверяем права администратора (простая проверка - первый пользователь считается администратором)
if ($_SESSION['user_id'] != 1) {
$_SESSION['error'] = "У вас нет доступа к этой странице";
redirect('../dashboard.php');
}
$userModel = new User($pdo);
$users = $userModel->findAll();
// Обработка действий
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
} else {
$action = $_POST['action'] ?? '';
$user_id = $_POST['user_id'] ?? null;
if ($user_id && $user_id != $_SESSION['user_id']) { // Нельзя изменять себя
switch ($action) {
case 'toggle_active':
$user = $userModel->findById($user_id);
if ($user) {
$new_status = $user['is_active'] ? 0 : 1;
if ($userModel->updateStatus($user_id, $new_status)) {
$_SESSION['success'] = 'Статус пользователя обновлен';
} else {
$_SESSION['error'] = 'Ошибка при обновлении статуса';
}
}
break;
case 'delete':
if ($userModel->delete($user_id)) {
$_SESSION['success'] = 'Пользователь удален';
} else {
$_SESSION['error'] = 'Ошибка при удалении пользователя';
}
break;
}
} else {
$_SESSION['error'] = 'Нельзя изменить собственный аккаунт';
}
redirect('users.php');
}
}
$page_title = "Управление пользователями";
include '../views/header.php';
?>
<div class="container">
<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="../register.php" role="button">➕ Добавить пользователя</a>
</div>
<?php if (empty($users)): ?>
<article style="text-align: center; padding: 2rem;">
<h3>Пользователи не найдены</h3>
<p>Зарегистрируйте первого пользователя</p>
<a href="../register.php" role="button">📝 Добавить пользователя</a>
</article>
<?php else: ?>
<div style="overflow-x: auto;">
<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="/author.php?id=<?= $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" style="display: inline;">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<input type="hidden" name="user_id" value="<?= $user['id'] ?>">
<input type="hidden" name="action" value="toggle_active">
<button type="submit" class="compact-button secondary" title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
<?= $user['is_active'] ? '⏸️' : '▶️' ?>
</button>
</form>
<form method="post" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<input type="hidden" name="user_id" value="<?= $user['id'] ?>">
<input type="hidden" name="action" value="delete">
<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/footer.php'; ?>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Regular → Executable
View File
+10
View File
File diff suppressed because one or more lines are too long
+27 -938
View File
@@ -1,959 +1,48 @@
/* Базовые стили */
h1, h2, h3, h4, h5, h6 {
margin-bottom: 1rem;
}
article, textarea, main.container {
margin-top: 1rem;
margin-bottom: 1rem;
padding-top: 1rem;
padding-bottom: 1rem;
}
article > header, article > footer {
margin-top: 0.1rem;
margin-bottom: 0.1rem;
padding-top: 0.2rem;
padding-bottom: 0.2rem;
}
input:not([type="checkbox"], [type="radio"]), select {
padding: 4px;
height: 2em;
}
/* Убираем конфликтующие стили Pico CSS */
article header, article footer {
margin: 0;
padding: 0;
}
article > header, article > footer {
margin: 0;
padding: 0;
}
/* Уведомления */
.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;
}
/* Кнопки */
.compact-button {
padding: 3px 8px !important;
font-size: 0.85rem;
text-decoration: none;
display: inline-flex !important;
align-items: center;
justify-content: center;
border: 1px solid var(--secondary);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-inverse);
cursor: pointer;
height: 28px !important;
box-sizing: border-box;
line-height: 1 !important;
vertical-align: middle;
}
.compact-button:hover {
opacity: 0.9;
}
.button-group {
display: flex;
gap: 5px;
margin-bottom: 1rem;
}
.button-group button,
.button-group a[role="button"] {
flex: 1;
padding: 0.5rem;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
box-sizing: border-box;
}
.button-group .delete-btn {
background: #ff4444 !important;
border-color: #ff4444 !important;
color: white !important;
}
.green-btn {
background: #449944 !important;
border-color: #449944 !important;
color: white !important;
}
.green-btn:hover {
background: #44bb44 !important;
border-color: #44bb44 !important;
color: white !important;
}
.profile-buttons {
display: flex;
gap: 10px;
align-items: stretch;
}
.profile-button {
flex: 1;
padding: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
border: 1px solid;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
box-sizing: border-box;
height: 62px;
min-height: 44px;
}
.profile-button.primary {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-inverse);
}
.profile-button.secondary {
background: var(--secondary);
border-color: var(--secondary);
color: var(--secondary-inverse);
}
.adaptive-button {
padding: 8px 12px !important;
font-size: 0.85rem;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--secondary);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-inverse);
cursor: pointer;
box-sizing: border-box;
text-align: center;
white-space: normal;
word-break: break-word;
min-height: 44px;
flex: 1;
min-width: 120px;
margin: 2px;
}
.adaptive-button:hover {
opacity: 0.9;
}
.primary.adaptive-button {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-inverse);
}
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
text-decoration: none;
border: 1px solid;
border-radius: 4px;
cursor: pointer;
box-sizing: border-box;
height: 44px;
min-width: 140px;
white-space: nowrap;
/* Дополнительные стили для тем */
.theme-switcher {
transition: all 0.3s ease;
text-align: center;
}
.action-button.primary {
background: #007bff;
border-color: #007bff;
color: #fff;
}
.action-button.primary:hover {
opacity: 0.9;
}
.action-button.delete {
margin-top: 1rem;
background: #ff4444;
border-color: #ff4444;
color: white;
}
.action-button.delete:hover {
background: #dd3333;
border-color: #dd3333;
color: white;
}
/* Таблицы */
.compact-table {
width: 100%;
font-size: 0.9rem;
border-collapse: collapse;
}
.compact-table th,
.compact-table td {
padding: 6px 8px;
border-bottom: 1px solid #eee;
}
.compact-table th {
background: #f5f5f5;
font-weight: bold;
}
/* Markdown редактор */
#content {
transition: all 0.3s ease;
border: 1px solid #ddd;
border-radius: 4px;
padding: 12px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
min-height: 400px;
color: #111;
background-color: #fff;
resize: vertical;
}
#content:focus {
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
outline: none;
color: #111;
background-color: #fff;
}
/* Кастомный скроллбар для редактора */
#content::-webkit-scrollbar {
width: 8px;
}
#content::-webkit-scrollbar-track {
background: #f1f1f1;
.theme-option {
transition: all 0.2s ease;
border-radius: 4px;
}
#content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
.theme-option:hover {
transform: translateX(5px);
}
#content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
#content {
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
/* Элементы управления редактором */
.editor-controls {
position: sticky !important;
top: 10px !important;
right: 10px !important;
z-index: 100 !important;
display: flex;
gap: 5px;
justify-content: flex-end;
margin-bottom: 10px;
}
.editor-controls button {
width: 40px !important;
height: 40px !important;
border-radius: 50% !important;
border: 1px solid #ddd !important;
background: white !important;
cursor: pointer !important;
font-size: 16px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
box-shadow: 0 2px 5px rgba(0,0,0,0.2) !important;
transition: all 0.3s ease !important;
color: #333333 !important;
}
.editor-controls button:hover {
transform: scale(1.1) !important;
background-color: #f8f9fa !important;
box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important;
}
/* Полноэкранный режим редактора */
#fullscreen-controls {
position: fixed !important;
z-index: 9999 !important;
display: flex !important;
gap: 5px !important;
}
#fullscreen-controls button {
width: 45px !important;
height: 45px !important;
border-radius: 50% !important;
border: 1px solid #ddd !important;
background: white !important;
cursor: pointer !important;
font-size: 18px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
box-shadow: 0 2px 5px rgba(0,0,0,0.3) !important;
transition: all 0.3s ease !important;
color: #333333 !important;
}
#fullscreen-controls button:hover {
transform: scale(1.1) !important;
background-color: #f8f9fa !important;
box-shadow: 0 4px 8px rgba(0,0,0,0.4) !important;
}
/* Стили для отображения контента книг */
.book-content {
line-height: 1.7;
font-family: Georgia, serif;
}
.book-content h1 {
font-size: 2em;
margin-top: 2rem;
margin-bottom: 1rem;
border-bottom: 2px solid #eee;
padding-bottom: 0.5rem;
}
.book-content h2 {
font-size: 1.6em;
margin-top: 1.5rem;
margin-bottom: 1rem;
border-bottom: 1px solid #eee;
padding-bottom: 0.3rem;
}
.book-content h3 {
font-size: 1.3em;
margin-top: 1.2rem;
margin-bottom: 0.8rem;
}
.book-content p {
margin-bottom: 1rem;
text-align: justify;
}
.book-content blockquote {
border-left: 4px solid #007bff;
padding-left: 1.5rem;
margin-left: 0;
margin-right: 0;
color: #555;
font-style: italic;
background: #f8f9fa;
padding: 1rem;
border-radius: 0 5px 5px 0;
}
.book-content code {
background: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9em;
}
.book-content pre {
background: #2d2d2d;
color: #f8f8f2;
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
border-left: 4px solid #007bff;
}
.book-content pre code {
background: none;
padding: 0;
color: inherit;
}
.book-content ul, .book-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.book-content li {
margin-bottom: 0.3rem;
}
.book-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.book-content th, .book-content td {
border: 1px solid #ddd;
padding: 10px 12px;
text-align: left;
}
.book-content th {
background: #f5f5f5;
font-weight: bold;
}
.book-content tr:nth-child(even) {
background: #f9f9f9;
}
.dialogue {
margin-left: 2rem;
font-style: italic;
color: #2c5aa0;
}
/* Обложки и медиа */
.book-cover {
transition: transform 0.3s ease;
}
.book-cover:hover {
transform: scale(1.05);
}
.cover-placeholder {
width: 120px;
height: 160px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
margin: 0 auto 1rem;
}
/* Аватарки и профиль */
.avatar-container {
text-align: center;
margin-bottom: 1.5rem;
}
.avatar {
width: 150px;
height: 150px;
border-radius: 50%;
border: 3px solid #007bff;
object-fit: cover;
}
.avatar-placeholder {
width: 150px;
height: 150px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 3rem;
margin: 0 auto;
}
.author-bio {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin: 1rem 0;
line-height: 1.6;
}
.author-bio h1, .author-bio h2, .author-bio h3 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.author-bio p {
margin-bottom: 1rem;
}
.author-bio ul, .author-bio ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.author-bio blockquote {
border-left: 4px solid #007bff;
padding-left: 1rem;
margin-left: 0;
color: #555;
font-style: italic;
}
/* Статистика */
.author-stats {
display: flex;
justify-content: center;
gap: 2rem;
flex-wrap: wrap;
margin: 1rem 0;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 1.5em;
font-weight: bold;
color: #007bff;
}
.stat-label {
font-size: 0.9em;
color: #666;
}
/* Серии книг */
.series-books article {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
}
.series-books article:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.series-info {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.series-badge {
display: inline-block;
background: #007bff;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
margin-left: 0.5rem;
}
/* Dashboard */
.dashboard-buttons {
display: flex;
gap: 10px;
margin-top: 1rem;
flex-wrap: nowrap;
}
.dashboard-button {
flex: 1;
text-align: center;
padding: 0.75rem 0.5rem;
text-decoration: none;
border: 1px solid var(--secondary);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-inverse);
font-size: 0.9rem;
transition: all 0.3s ease;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.dashboard-button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.dashboard-button.new {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-inverse);
flex: 0.7;
}
.stats-list {
margin-top: 1rem;
}
.stats-list p {
margin: 0.5rem 0;
padding: 0.3rem 0;
border-bottom: 1px solid #f0f0f0;
}
.stats-list p:last-child {
border-bottom: none;
}
.series-stats {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 5px;
border-left: 4px solid #6f42c1;
}
.series-stats p {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.dashboard-section {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.dashboard-item {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
padding: 1rem;
}
.dashboard-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.welcome-message {
text-align: center;
padding: 3rem;
background: #f9f9f9;
border-radius: 8px;
margin-top: 2rem;
}
.welcome-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 1.5rem;
}
.action-buttons {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.action-buttons .compact-button {
flex: 1;
min-width: 80px;
text-align: center;
font-size: 0.8rem;
padding: 0.3rem 0.5rem;
}
/* Улучшения для grid в dashboard */
.grid {
gap: 1rem;
}
.grid article {
margin: 0;
padding: 1.5rem;
}
/* Адаптивность */
/* Адаптивность для мобильных */
@media (max-width: 768px) {
.adaptive-button {
flex: 1 1 calc(50% - 10px);
min-width: calc(50% - 10px);
font-size: 0.8rem;
padding: 10px 8px !important;
}
.action-button {
padding: 0.6rem 1rem;
font-size: 0.85rem;
min-width: 120px;
height: 42px;
}
.book-content {
font-size: 16px;
line-height: 1.6;
}
.book-content h1 {
font-size: 1.6em;
}
.book-content h2 {
font-size: 1.4em;
}
.book-content h3 {
font-size: 1.2em;
}
.book-content pre {
font-size: 14px;
}
.author-books article {
flex-direction: column;
}
.author-books .book-cover {
align-self: center;
}
.avatar, .avatar-placeholder {
width: 120px;
height: 120px;
font-size: 2.5rem;
}
.author-stats {
gap: 1rem;
}
.stat-number {
font-size: 1.3em;
}
.dashboard-buttons {
flex-direction: column;
gap: 8px;
}
.dashboard-button {
flex: none;
width: 100%;
}
.dashboard-button.new {
flex: none;
width: 100%;
}
.welcome-buttons {
flex-direction: column;
align-items: center;
}
.welcome-buttons a {
width: 100%;
max-width: 250px;
}
.action-buttons {
flex-direction: column;
}
.action-buttons .compact-button {
width: 100%;
}
#fullscreen-controls {
top: 10px !important;
right: 10px !important;
}
#fullscreen-controls button {
width: 60px !important;
height: 60px !important;
font-size: 24px !important;
border: 2px solid #ddd !important;
}
.editor-controls button {
width: 50px !important;
height: 50px !important;
font-size: 20px !important;
.theme-preview {
width: 16px;
height: 16px;
}
}
@media (max-width: 480px) {
.adaptive-button {
flex: 1 1 100%;
min-width: 100%;
}
.action-button {
padding: 0.5rem 0.8rem;
font-size: 0.8rem;
min-width: 110px;
height: 40px;
}
.action-buttons-container {
flex-direction: column;
width: 100%;
}
.action-buttons-container .action-button {
width: 100%;
min-width: auto;
}
.avatar, .avatar-placeholder {
width: 100px;
height: 100px;
font-size: 2rem;
}
.author-stats {
flex-direction: column;
gap: 0.5rem;
}
.dashboard-item {
padding: 0.8rem;
}
.dashboard-button {
font-size: 0.85rem;
padding: 0.6rem 0.4rem;
}
.welcome-message {
padding: 2rem 1rem;
}
#fullscreen-controls button {
width: 55px !important;
height: 55px !important;
font-size: 22px !important;
}
}
@media (min-width: 769px) {
#fullscreen-controls {
top: 15px !important;
right: 15px !important;
}
#fullscreen-controls button {
width: 50px !important;
height: 50px !important;
font-size: 20px !important;
}
}
/* Полноэкранные режимы редактора */
#content.mobile-fullscreen {
position: fixed !important;
top: 50px !important;
left: 0 !important;
width: 100vw !important;
height: calc(100vh - 100px) !important;
z-index: 9998 !important;
background-color: white !important;
border: 2px solid #007bff !important;
border-radius: 0 !important;
font-size: 18px !important;
padding: 15px !important;
margin: 0 !important;
box-sizing: border-box !important;
resize: none !important;
box-shadow: none !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
}
#content.desktop-fullscreen {
position: fixed !important;
top: 5vh !important;
left: 5vw !important;
width: 90vw !important;
height: 90vh !important;
z-index: 9998 !important;
background-color: white !important;
border: 2px solid #007bff !important;
border-radius: 8px !important;
font-size: 16px !important;
padding: 20px !important;
margin: 0 !important;
box-sizing: border-box !important;
resize: none !important;
box-shadow: 0 0 20px rgba(0,0,0,0.3) !important;
}
/* Стили для TinyMCE редактора */
.tox-tinymce {
border-radius: 4px !important;
border: 1px solid #ddd !important;
}
.tox-tinymce:focus {
border-color: #007bff !important;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25) !important;
}
/* Адаптивность для TinyMCE */
/* editor styles */
@media (max-width: 768px) {
.tox-tinymce {
border-radius: 0 !important;
.writer-editor-container .ql-container {
font-size: 16px !important; /* Предотвращает масштабирование в iOS */
}
.tox-toolbar {
flex-wrap: wrap !important;
.writer-editor-container .ql-toolbar {
padding: 8px !important;
}
.writer-editor-container .ql-toolbar .ql-formats {
margin-right: 8px !important;
}
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
/* Стили для полноэкранного режима */
.ql-toolbar.ql-snow {
transition: all 0.3s ease;
}
.alert-warning {
background: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
.writer-editor-container.fullscreen {
transition: all 0.3s ease;
}
.ql-editor p {
text-indent: 2em;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Regular → Executable
View File
+86 -87
View File
@@ -1,98 +1,97 @@
// assets/js/autosave.js
document.addEventListener('DOMContentLoaded', function() {
const contentTextarea = document.getElementById('content');
const titleInput = document.getElementById('title');
const statusSelect = document.getElementById('status');
// Проверяем, что это редактирование существующей главы
const urlParams = new URLSearchParams(window.location.search);
const isEditMode = urlParams.has('id');
if (!contentTextarea || !isEditMode) {
console.log('Автосохранение отключено: создание новой главы');
document.addEventListener('DOMContentLoaded', () => {
// Не запускать на странице создания книги
if (window.location.href.includes('/books/create')) {
return;
}
let saveTimeout;
let isSaving = false;
let lastSavedContent = contentTextarea.value;
function showSaveMessage(message) {
let messageEl = document.getElementById('autosave-message');
if (!messageEl) {
messageEl = document.createElement('div');
messageEl.id = 'autosave-message';
messageEl.style.cssText = 'position: fixed; top: 10px; right: 10px; padding: 8px 12px; background: #333; color: white; border-radius: 3px; z-index: 1000; font-size: 0.8rem;';
document.body.appendChild(messageEl);
// Ждём инициализации writerEditor
function initAutosave() {
const writerEditor = window.writerEditor;
if (!writerEditor || !writerEditor.quill) {
// Пробуем снова через 500ms
setTimeout(initAutosave, 500);
return;
}
messageEl.textContent = message;
messageEl.style.display = 'block';
setTimeout(() => {
messageEl.style.display = 'none';
}, 1500);
}
function autoSave() {
if (isSaving) return;
const currentContent = contentTextarea.value;
const currentTitle = titleInput ? titleInput.value : '';
const currentStatus = statusSelect ? statusSelect.value : 'draft';
if (currentContent === lastSavedContent) return;
isSaving = true;
const formData = new FormData();
formData.append('content', currentContent);
formData.append('title', currentTitle);
formData.append('status', currentStatus);
formData.append('autosave', 'true');
formData.append('csrf_token', document.querySelector('input[name="csrf_token"]').value);
fetch(window.location.href, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
lastSavedContent = currentContent;
showSaveMessage('Сохранено: ' + new Date().toLocaleTimeString());
const textarea = writerEditor.textarea;
if (!textarea) return;
let lastSavedContent = textarea.value;
let saveTimeout;
function showMessage(message, isError = false) {
let msgEl = document.getElementById('autosave-message');
if (!msgEl) {
msgEl = document.createElement('div');
msgEl.id = 'autosave-message';
msgEl.style.cssText = `
position: fixed;
top: 70px;
right: 10px;
padding: 8px 12px;
background: ${isError ? '#dc3545' : '#28a745'};
color: white;
border-radius: 3px;
z-index: 10000;
font-size: 0.8rem;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
`;
document.body.appendChild(msgEl);
}
})
.catch(error => {
console.error('Ошибка автосохранения:', error);
})
.finally(() => {
isSaving = false;
});
}
contentTextarea.addEventListener('input', function() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(autoSave, 2000);
});
if (titleInput) {
titleInput.addEventListener('input', function() {
msgEl.textContent = message;
msgEl.style.background = isError ? '#dc3545' : '#28a745';
msgEl.style.display = 'block';
setTimeout(() => msgEl.style.display = 'none', 2000);
}
const autoSave = () => {
const currentContent = textarea.value;
// Не сохранять пустой контент
if (!currentContent || currentContent.trim() === '' || currentContent === '<p><br></p>') {
return;
}
if (currentContent === lastSavedContent) return;
const form = writerEditor.form;
if (!form) return;
const formData = new FormData(form);
formData.append('autosave', 'true');
formData.append('content', currentContent);
showMessage('Сохранение...');
fetch(window.location.href, {
method: 'POST',
body: formData
})
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (data.success) {
lastSavedContent = currentContent;
showMessage('Автосохранено: ' + new Date().toLocaleTimeString());
} else {
throw new Error(data.error || 'Ошибка сервера');
}
})
.catch(err => {
console.error(err);
showMessage('Ошибка автосохранения: ' + err.message, true);
});
};
writerEditor.quill.on('text-change', () => {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(autoSave, 2000);
});
// Периодическая автосохранение
setInterval(autoSave, 30000);
}
if (statusSelect) {
statusSelect.addEventListener('change', autoSave);
}
window.addEventListener('beforeunload', function(e) {
if (contentTextarea.value !== lastSavedContent) {
e.preventDefault();
e.returnValue = 'У вас есть несохраненные изменения. Вы уверены, что хотите уйти?';
}
});
//console.log('Автосохранение включено для редактирования главы');
});
// Запускаем
initAutosave();
});
+435
View File
@@ -0,0 +1,435 @@
// editor.js
// Глобальная функция переключения редактора рассказа
function toggleStoryEditor() {
const checkbox = document.getElementById('is_short_story');
const storyEditorCard = document.getElementById('story-editor-card');
const chaptersCard = document.getElementById('chapters-card');
if (!checkbox || !storyEditorCard) return;
const isChecked = checkbox.checked;
if (isChecked) {
storyEditorCard.style.display = 'block';
chaptersCard.style.display = 'none';
if (!window.writerEditor) {
const textarea = document.getElementById('content');
if (textarea) {
window.writerEditor = new WriterEditor('#book-form', 'quill-editor', 'content', false);
}
}
} else {
storyEditorCard.style.display = 'none';
chaptersCard.style.display = 'block';
if (window.writerEditor) {
window.writerEditor.quill.destroy();
window.writerEditor = null;
}
}
}
class DialogueFormatter {
constructor(quill) {
this.quill = quill;
this.lastKey = null;
this.setupEventListeners();
}
setupEventListeners() {
this.quill.root.addEventListener('keydown', (e) => {
this.lastKey = e.key;
});
this.quill.on('text-change', (delta, oldDelta, source) => {
if (source === 'user' && this.lastKey === ' ') {
this.checkForDialogue();
}
});
}
getLineStart(index) {
let lineStart = index;
while (lineStart > 0) {
const prevChar = this.quill.getText(lineStart - 1, 1);
if (prevChar === '\n') break;
lineStart--;
}
return lineStart;
}
checkForDialogue() {
const selection = this.quill.getSelection();
if (!selection || selection.index < 2) return;
const lineStart = this.getLineStart(selection.index);
const charsFromStart = selection.index - lineStart;
if (charsFromStart !== 2) return;
const text = this.quill.getText(lineStart, 2);
if (text !== '- ') return;
this.quill.updateContents([
{ retain: lineStart },
{ delete: 2 },
{ insert: '— ' }
], 'user');
this.quill.setSelection(lineStart + 2, 0, 'silent');
}
formatSelectionAsDialogue() {
const range = this.quill.getSelection();
if (!range || range.length === 0) return;
const selectedText = this.quill.getText(range.index, range.length);
const formattedText = selectedText.replace(/(^|\n)- /g, '$1— ');
if (formattedText !== selectedText) {
this.quill.deleteText(range.index, range.length, 'user');
this.quill.insertText(range.index, formattedText, 'user');
this.quill.setSelection(range.index, formattedText.length, 'silent');
}
}
unformatSelectionAsDialogue() {
const range = this.quill.getSelection();
if (!range || range.length === 0) return;
const selectedText = this.quill.getText(range.index, range.length);
const unformattedText = selectedText.replace(/(^|\n)— /g, '$1- ');
if (unformattedText !== selectedText) {
this.quill.deleteText(range.index, range.length, 'user');
this.quill.insertText(range.index, unformattedText, 'user');
this.quill.setSelection(range.index, unformattedText.length, 'silent');
}
}
}
class WriterEditor {
constructor(formSelector = '#chapter-form', editorContainerId = 'quill-editor', textareaId = 'content') {
this.form = document.querySelector(formSelector);
this.editorContainer = document.getElementById(editorContainerId);
this.textarea = document.getElementById(textareaId);
this.isFullscreen = false;
this.originalStyles = {};
this.init();
}
init() {
if (!this.editorContainer || !this.textarea || !this.form) return;
// Полный тулбар (одинаковый для глав и рассказов)
const toolbarOptions = [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold','italic','underline','strike'],
[{ 'align': [] }],
[{ 'color': [] }, { 'background': [] }],
['blockquote','code-block'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }],
['dialogue', 'undodialogue'],
[{ 'size': ['small', false, 'large', 'huge'] }],
[{ 'font': [] }],
['link','image','video'],
['clean'],
['fullscreen']
];
this.quill = new Quill(this.editorContainer, {
theme: 'snow',
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
'dialogue': () => {
if (this.dialogueFormatter) {
this.dialogueFormatter.formatSelectionAsDialogue();
}
},
'undodialogue': () => {
if (this.dialogueFormatter) {
this.dialogueFormatter.unformatSelectionAsDialogue();
}
},
'fullscreen': () => {
this.toggleFullscreen();
}
}
},
history: { delay: 1000, maxStack: 100, userOnly: true },
keyboard: {
bindings: {
'list autofill': {
key: ' ',
format: ['list'],
handler: function(range, context) {
if (context.prefix && context.prefix.trim() === '-') {
return true;
}
return Quill.import('modules/keyboard').bindings['list autofill'].handler.call(this, range, context);
}
}
}
}
},
placeholder: 'Введите текст главы...'
});
this.dialogueFormatter = new DialogueFormatter(this.quill);
const rawContent = this.editorContainer.dataset.content || '';
if (rawContent.trim()) this.quill.root.innerHTML = rawContent.trim();
setTimeout(() => this.formatExistingDialogues(), 100);
const sync = () => {
let html = this.quill.root.innerHTML;
html = html.replace(/^(<p><br><\/p>)+/, '').replace(/(<p><br><\/p>)+$/, '');
this.textarea.value = html;
};
this.quill.on('text-change', sync);
const submitHandler = function(e) {
sync();
};
this.form.addEventListener('submit', submitHandler);
// Обработчик изменения ориентации для мобильных устройств
window.addEventListener('orientationchange', () => {
if (this.isFullscreen) {
setTimeout(() => this.adjustFullscreenHeight(), 300);
}
});
// Обработчик изменения размера окна
window.addEventListener('resize', () => {
if (this.isFullscreen) {
this.adjustFullscreenHeight();
}
});
window.quillEditorInstance = this.quill;
window.quillTextarea = this.textarea;
}
addCustomButtonsToToolbar() {
const toolbar = this.quill.container.previousSibling;
if (!toolbar) return;
const dialogueBtn = toolbar.querySelector('.ql-dialogue');
const undoDialogueBtn = toolbar.querySelector('.ql-undodialogue');
const fullscreenBtn = toolbar.querySelector('.ql-fullscreen');
if (dialogueBtn) {
dialogueBtn.innerHTML = '—';
dialogueBtn.title = 'Форматировать диалоги (—)';
dialogueBtn.style.fontWeight = 'bold';
}
if (undoDialogueBtn) {
undoDialogueBtn.innerHTML = '-';
undoDialogueBtn.title = 'Убрать форматирование диалогов (-)';
undoDialogueBtn.style.fontWeight = 'bold';
}
if (fullscreenBtn) {
fullscreenBtn.innerHTML = '⛶';
fullscreenBtn.title = 'Полноэкранный режим';
}
}
toggleFullscreen() {
if (this.isFullscreen) {
this.exitFullscreen();
} else {
this.enterFullscreen();
}
}
enterFullscreen() {
this.isFullscreen = true;
// Сохраняем оригинальные стили
this.originalStyles = {
container: this.editorContainer.style.cssText,
body: document.body.style.cssText,
toolbar: this.quill.container.previousSibling.style.cssText
};
// Получаем тулбар
const toolbar = this.quill.container.previousSibling;
// Применяем стили для полноэкранного режима
document.body.style.overflow = 'hidden';
this.editorContainer.style.position = 'fixed';
this.editorContainer.style.top = '0';
this.editorContainer.style.left = '0';
this.editorContainer.style.width = '100vw';
this.editorContainer.style.zIndex = '9999';
this.editorContainer.style.background = 'white';
this.editorContainer.style.paddingTop = toolbar.offsetHeight + 'px';
// Стили для тулбара
toolbar.style.position = 'fixed';
toolbar.style.top = '0';
toolbar.style.left = '0';
toolbar.style.width = '100%';
toolbar.style.zIndex = '10000';
toolbar.style.background = 'white';
toolbar.style.borderBottom = '1px solid #ccc';
this.adjustFullscreenHeight();
// Добавляем обработчик ESC
this.escapeHandler = (e) => {
if (e.key === 'Escape') {
this.exitFullscreen();
}
};
document.addEventListener('keydown', this.escapeHandler);
// Добавляем кнопку выхода из полноэкранного режима
this.addFullscreenExitButton();
}
exitFullscreen() {
this.isFullscreen = false;
// Восстанавливаем оригинальные стили
this.editorContainer.style.cssText = this.originalStyles.container || '';
document.body.style.cssText = this.originalStyles.body || '';
const toolbar = this.quill.container.previousSibling;
if (toolbar) {
toolbar.style.cssText = this.originalStyles.toolbar || '';
}
// Удаляем обработчик ESC
if (this.escapeHandler) {
document.removeEventListener('keydown', this.escapeHandler);
}
// Удаляем кнопку выхода
this.removeFullscreenExitButton();
}
adjustFullscreenHeight() {
// Корректируем высоту с учетом видимой области и возможной клавиатуры
const visualViewport = window.visualViewport || window;
const height = visualViewport.height || window.innerHeight;
this.editorContainer.style.height = height + 'px';
// Пересчитываем размеры Quill
setTimeout(() => {
this.quill.root.style.height = '100%';
this.quill.root.querySelector('.ql-editor').style.height = '100%';
}, 50);
}
addFullscreenExitButton() {
// Создаем кнопку выхода из полноэкранного режима
this.exitButton = document.createElement('button');
this.exitButton.innerHTML = '✕';
this.exitButton.title = 'Выйти из полноэкранного режима (ESC)';
this.exitButton.style.cssText = `
position: fixed;
top: 10px;
right: 10px;
z-index: 10001;
background: #dc3545;
color: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
`;
this.exitButton.addEventListener('click', () => {
this.exitFullscreen();
});
document.body.appendChild(this.exitButton);
}
removeFullscreenExitButton() {
if (this.exitButton && this.exitButton.parentNode) {
this.exitButton.parentNode.removeChild(this.exitButton);
}
}
formatExistingDialogues() {
const text = this.quill.getText();
const lines = text.split('\n');
let totalOffset = 0;
lines.forEach((line, index) => {
if (line.startsWith('- ')) {
const replacePosition = totalOffset;
this.quill.deleteText(replacePosition, 1, 'silent');
this.quill.insertText(replacePosition, '—', 'silent');
}
totalOffset += line.length + 1;
});
}
addCustomButtonsToToolbar() {
const toolbar = this.quill.container.previousSibling;
if (!toolbar) return;
const dialogueBtn = toolbar.querySelector('.ql-dialogue');
const undoDialogueBtn = toolbar.querySelector('.ql-undodialogue');
const fullscreenBtn = toolbar.querySelector('.ql-fullscreen');
if (dialogueBtn) {
dialogueBtn.innerHTML = '—';
dialogueBtn.title = 'Форматировать диалоги (—)';
dialogueBtn.style.fontWeight = 'bold';
}
if (undoDialogueBtn) {
undoDialogueBtn.innerHTML = '-';
undoDialogueBtn.title = 'Убрать форматирование диалогов (-)';
undoDialogueBtn.style.fontWeight = 'bold';
}
if (fullscreenBtn) {
fullscreenBtn.innerHTML = '⛶';
fullscreenBtn.title = 'Полноэкранный режим';
}
}
}
document.addEventListener('DOMContentLoaded', () => {
// Не создавать редактор на странице создания книги
if (window.location.href.includes('/books/create')) {
return;
}
const quillEditor = document.getElementById('quill-editor');
const content = document.getElementById('content');
if (quillEditor && content && !window.writerEditor) {
// Проверяем ТОЛЬКО нужные формы
const bookForm = document.getElementById('book-form');
const chapterForm = document.getElementById('chapter-form');
if (bookForm) {
window.writerEditor = new WriterEditor('#book-form', 'quill-editor', 'content');
} else if (chapterForm) {
window.writerEditor = new WriterEditor('#chapter-form', 'quill-editor', 'content');
}
}
});
Regular → Executable
View File
-563
View File
@@ -1,563 +0,0 @@
document.addEventListener('DOMContentLoaded', function() {
const contentTextarea = document.getElementById('content');
const previewForm = document.getElementById('preview-form');
if (!contentTextarea) return;
let isFullscreen = false;
let originalStyles = {};
let isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
initEditor();
function initEditor() {
autoResize();
contentTextarea.addEventListener('input', autoResize);
contentTextarea.addEventListener('input', processDialogues);
contentTextarea.addEventListener('keydown', handleTab);
contentTextarea.addEventListener('input', updatePreviewContent);
updatePreviewContent();
addControlButtons();
// На мобильных устройствах добавляем обработчик изменения ориентации
if (isMobile) {
window.addEventListener('orientationchange', function() {
if (isFullscreen) {
setTimeout(adjustForMobileKeyboard, 300);
}
});
// Обработчик для виртуальной клавиатуры
window.addEventListener('resize', function() {
if (isFullscreen && isMobile) {
adjustForMobileKeyboard();
}
});
}
}
function autoResize() {
if (isFullscreen) return;
contentTextarea.style.height = 'auto';
contentTextarea.style.height = contentTextarea.scrollHeight + 'px';
}
function processDialogues() {
const lines = contentTextarea.value.split('\n');
let changed = false;
const processedLines = lines.map(line => {
if (line.trim().startsWith('- ') && line.trim().length > 2) {
const trimmed = line.trim();
const restOfLine = trimmed.substring(2);
if (/^[a-zA-Zа-яА-Я]/.test(restOfLine)) {
changed = true;
return line.replace(trimmed, `— ${restOfLine}`);
}
}
return line;
});
if (changed) {
const cursorPos = contentTextarea.selectionStart;
contentTextarea.value = processedLines.join('\n');
contentTextarea.setSelectionRange(cursorPos, cursorPos);
if (!isFullscreen) autoResize();
}
}
function handleTab(e) {
if (e.key === 'Tab') {
e.preventDefault();
const start = contentTextarea.selectionStart;
const end = contentTextarea.selectionEnd;
contentTextarea.value = contentTextarea.value.substring(0, start) + ' ' + contentTextarea.value.substring(end);
contentTextarea.selectionStart = contentTextarea.selectionEnd = start + 4;
if (!isFullscreen) autoResize();
}
}
function updatePreviewContent() {
if (previewForm) {
document.getElementById('preview-content').value = contentTextarea.value;
}
}
function adjustForMobileKeyboard() {
if (!isMobile || !isFullscreen) return;
// На мобильных устройствах уменьшаем высоту textarea, чтобы клавиатура не перекрывала контент
const viewportHeight = window.innerHeight;
const keyboardHeight = viewportHeight * 0.4; // Предполагаемая высота клавиатуры (40% экрана)
const availableHeight = viewportHeight - keyboardHeight - 80; // 80px для кнопок и отступов
contentTextarea.style.height = availableHeight + 'px';
contentTextarea.style.paddingBottom = '20px';
// Прокручиваем к курсору
setTimeout(() => {
const cursorPos = contentTextarea.selectionStart;
if (cursorPos > 0) {
scrollToCursor();
}
}, 100);
}
function scrollToCursor() {
const textarea = contentTextarea;
const cursorPos = textarea.selectionStart;
// Создаем временный элемент для измерения позиции курсора
const tempDiv = document.createElement('div');
tempDiv.style.cssText = `
position: absolute;
top: -1000px;
left: -1000px;
width: ${textarea.clientWidth}px;
padding: ${textarea.style.padding};
font: ${getComputedStyle(textarea).font};
line-height: ${textarea.style.lineHeight};
white-space: pre-wrap;
word-wrap: break-word;
visibility: hidden;
`;
const textBeforeCursor = textarea.value.substring(0, cursorPos);
tempDiv.textContent = textBeforeCursor;
document.body.appendChild(tempDiv);
const textHeight = tempDiv.offsetHeight;
document.body.removeChild(tempDiv);
// Прокручиваем так, чтобы курсор был виден
const lineHeight = parseInt(getComputedStyle(textarea).lineHeight) || 24;
const visibleHeight = textarea.clientHeight;
const cursorLine = Math.floor(textHeight / lineHeight);
const visibleLines = Math.floor(visibleHeight / lineHeight);
const targetScroll = Math.max(0, (cursorLine - Math.floor(visibleLines / 3)) * lineHeight);
textarea.scrollTop = targetScroll;
}
function addControlButtons() {
const container = contentTextarea.parentElement;
const controlsContainer = document.createElement('div');
controlsContainer.className = 'editor-controls';
const fullscreenBtn = createButton('🔲', 'Полноэкранный режим', toggleFullscreen);
const helpBtn = createButton('❓', 'Справка по Markdown', showHelp);
controlsContainer.appendChild(fullscreenBtn);
controlsContainer.appendChild(helpBtn);
container.insertBefore(controlsContainer, contentTextarea);
function toggleFullscreen() {
if (!isFullscreen) {
enterFullscreen();
} else {
exitFullscreen();
}
}
function enterFullscreen() {
originalStyles = {
position: contentTextarea.style.position,
top: contentTextarea.style.top,
left: contentTextarea.style.left,
width: contentTextarea.style.width,
height: contentTextarea.style.height,
zIndex: contentTextarea.style.zIndex,
backgroundColor: contentTextarea.style.backgroundColor,
border: contentTextarea.style.border,
borderRadius: contentTextarea.style.borderRadius,
fontSize: contentTextarea.style.fontSize,
padding: contentTextarea.style.padding,
margin: contentTextarea.style.margin
};
if (isMobile) {
// Для мобильных - адаптивный режим с учетом клавиатуры
const viewportHeight = window.innerHeight;
const availableHeight = viewportHeight - 100; // Оставляем место для кнопок
Object.assign(contentTextarea.style, {
position: 'fixed',
top: '50px',
left: '0',
width: '100vw',
height: availableHeight + 'px',
zIndex: '9998',
backgroundColor: 'white',
border: '2px solid #007bff',
borderRadius: '0',
fontSize: '18px',
padding: '15px',
margin: '0',
boxSizing: 'border-box',
resize: 'none'
});
// На мобильных устройствах фокусируем textarea сразу
setTimeout(() => {
contentTextarea.focus();
}, 300);
} else {
// Для ПК - классический полноэкранный режим
Object.assign(contentTextarea.style, {
position: 'fixed',
top: '5vh',
left: '5vw',
width: '90vw',
height: '90vh',
zIndex: '9998',
backgroundColor: 'white',
border: '2px solid #007bff',
borderRadius: '8px',
fontSize: '16px',
padding: '20px',
margin: '0',
boxSizing: 'border-box',
resize: 'none'
});
}
controlsContainer.style.display = 'none';
createFullscreenControls();
isFullscreen = true;
document.body.style.overflow = 'hidden';
}
function exitFullscreen() {
Object.assign(contentTextarea.style, originalStyles);
controlsContainer.style.display = 'flex';
removeFullscreenControls();
isFullscreen = false;
document.body.style.overflow = '';
autoResize();
}
function createFullscreenControls() {
const fullscreenControls = document.createElement('div');
fullscreenControls.id = 'fullscreen-controls';
const exitBtn = createButton('❌', 'Выйти из полноэкранного режима', exitFullscreen);
const helpBtnFullscreen = createButton('❓', 'Справка по Markdown', showHelp);
// Для мобильных увеличиваем кнопки и добавляем отступы
const buttonSize = isMobile ? '60px' : '50px';
const fontSize = isMobile ? '24px' : '20px';
const topPosition = isMobile ? '10px' : '15px';
[exitBtn, helpBtnFullscreen].forEach(btn => {
btn.style.cssText = `
width: ${buttonSize};
height: ${buttonSize};
border-radius: 50%;
border: 1px solid #ddd;
background: white;
cursor: pointer;
font-size: ${fontSize};
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.3);
transition: all 0.3s ease;
color: #333333;
touch-action: manipulation;
`;
});
fullscreenControls.appendChild(helpBtnFullscreen);
fullscreenControls.appendChild(exitBtn);
fullscreenControls.style.cssText = `
position: fixed;
top: ${topPosition};
right: 10px;
z-index: 9999;
display: flex;
gap: 5px;
`;
document.body.appendChild(fullscreenControls);
// Предотвращаем всплытие событий от кнопок к textarea
fullscreenControls.addEventListener('touchstart', function(e) {
e.stopPropagation();
});
fullscreenControls.addEventListener('touchend', function(e) {
e.stopPropagation();
});
}
function removeFullscreenControls() {
const fullscreenControls = document.getElementById('fullscreen-controls');
if (fullscreenControls) {
fullscreenControls.remove();
}
}
// Выход по ESC
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && isFullscreen) {
exitFullscreen();
}
});
// На мобильных устройствах добавляем обработчик для выхода по тапу вне textarea
if (isMobile) {
document.addEventListener('touchstart', function(e) {
if (isFullscreen && !contentTextarea.contains(e.target) &&
!document.getElementById('fullscreen-controls')?.contains(e.target)) {
exitFullscreen();
}
});
}
// Обработчик фокуса для мобильных устройств
if (isMobile) {
contentTextarea.addEventListener('focus', function() {
if (isFullscreen) {
setTimeout(adjustForMobileKeyboard, 100);
}
});
}
}
function createButton(icon, title, onClick) {
const button = document.createElement('button');
button.innerHTML = icon;
button.title = title;
button.type = 'button';
const buttonSize = isMobile ? '50px' : '40px';
const fontSize = isMobile ? '20px' : '16px';
button.style.cssText = `
width: ${buttonSize};
height: ${buttonSize};
border-radius: 50%;
border: 1px solid #ddd;
background: white;
cursor: pointer;
font-size: ${fontSize};
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
transition: all 0.3s ease;
color: #333333;
touch-action: manipulation;
`;
button.addEventListener('mouseenter', function() {
this.style.transform = 'scale(1.1)';
this.style.backgroundColor = '#f8f9fa';
this.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
});
button.addEventListener('mouseleave', function() {
this.style.transform = 'scale(1)';
this.style.backgroundColor = 'white';
this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
});
button.addEventListener('click', onClick);
// Для мобильных устройств
button.addEventListener('touchstart', function(e) {
e.stopPropagation();
this.style.transform = 'scale(1.1)';
this.style.backgroundColor = '#f8f9fa';
this.style.boxShadow = '0 4px 8px rgba(0,0,0,0.3)';
});
button.addEventListener('touchend', function(e) {
e.stopPropagation();
this.style.transform = 'scale(1)';
this.style.backgroundColor = 'white';
this.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
onClick();
});
return button;
}
function showHelp() {
const helpContent = `
<div style="font-family: system-ui, sans-serif; line-height: 1.6; color: #333;">
<h1 style="color: #007bff; margin-top: 0; border-bottom: 2px solid #007bff; padding-bottom: 10px;">Справка по Markdown</h1>
<div style="margin-bottom: 20px;">
<h2 style="color: #555;">Основное форматирование</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #007bff;">
<p><strong>Жирный текст:</strong> **текст** или __текст__</p>
<p><em>Наклонный текст:</em> *текст* или _текст_</p>
<p><u>Подчеркнутый текст:</u> &lt;u&gt;текст&lt;/u&gt;</p>
<p><del>Зачеркнутый текст:</del> ~~текст~~</p>
</div>
</div>
<div style="margin-bottom: 20px;">
<h2 style="color: #555;">Заголовки</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #28a745;">
<h1 style="margin: 10px 0; font-size: 1.5em;">Заголовок 1 (# Заголовок)</h1>
<h2 style="margin: 10px 0; font-size: 1.3em;">Заголовок 2 (## Заголовок)</h2>
<h3 style="margin: 10px 0; font-size: 1.1em;">Заголовок 3 (### Заголовок)</h3>
</div>
</div>
<div style="margin-bottom: 20px;">
<h2 style="color: #555;">Цитаты</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #ffc107;">
<blockquote style="margin: 0; padding-left: 15px; border-left: 3px solid #ddd; color: #666;">
> Это цитата
</blockquote>
<blockquote style="margin: 10px 0 0 20px; padding-left: 15px; border-left: 3px solid #ddd; color: #666;">
> > Вложенная цитата
</blockquote>
</div>
</div>
<div style="margin-bottom: 20px;">
<h2 style="color: #555;">Диалоги</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #dc3545;">
<p><strong>Автоматическое преобразование:</strong></p>
<p><code>- Привет!</code> → <em>— Привет!</em></p>
<p style="font-size: 0.9em; color: #666; margin-top: 5px;">
Дефис в начале строки автоматически заменяется на тире с пробелом
</p>
</div>
</div>
<div style="margin-bottom: 20px;">
<h2 style="color: #555;">Списки</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #6f42c1;">
<p><strong>Маркированный список:</strong></p>
<ul style="margin: 10px 0; padding-left: 20px;">
<li>- Элемент списка</li>
<li>- Другой элемент</li>
</ul>
<p><strong>Нумерованный список:</strong></p>
<ol style="margin: 10px 0; padding-left: 20px;">
<li>1. Первый элемент</li>
<li>2. Второй элемент</li>
</ol>
</div>
</div>
<div style="margin-bottom: 20px;">
<h2 style="color: #555;">Код</h2>
<div style="background: #f8f9fa; padding: 15px; border-radius: 5px; border-left: 4px solid #fd7e14;">
<p><strong>Код в строке:</strong></p>
<p><code>\`код в строке\`</code></p>
<p><strong>Блок кода:</strong></p>
<pre style="background: #e9ecef; padding: 10px; border-radius: 3px; overflow-x: auto; margin: 10px 0;">
\`\`\`
блок кода
многострочный
\`\`\`</pre>
</div>
</div>
<div style="background: #e7f3ff; padding: 15px; border-radius: 5px; border-left: 4px solid #007bff; margin-top: 20px;">
<p style="margin: 0; font-size: 0.9em;"><strong>💡 Подсказка:</strong> Используйте кнопку "👁️ Предпросмотр" чтобы увидеть как будет выглядеть готовый текст!</p>
</div>
</div>
`;
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border: 2px solid #007bff;
border-radius: 10px;
padding: 25px;
z-index: 10000;
width: 90%;
max-width: 700px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✕';
closeBtn.title = 'Закрыть справку';
closeBtn.style.cssText = `
position: absolute;
top: 15px;
right: 15px;
background: #ff4444;
color: white;
border: none;
font-size: 18px;
cursor: pointer;
width: 35px;
height: 35px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.3s ease;
`;
closeBtn.addEventListener('mouseenter', function() {
this.style.background = '#cc0000';
});
closeBtn.addEventListener('mouseleave', function() {
this.style.background = '#ff4444';
});
closeBtn.addEventListener('click', function() {
modal.remove();
overlay.remove();
});
modal.innerHTML = helpContent;
modal.appendChild(closeBtn);
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.5);
z-index: 9999;
`;
overlay.addEventListener('click', function() {
modal.remove();
overlay.remove();
});
document.body.appendChild(overlay);
document.body.appendChild(modal);
const closeHandler = function(e) {
if (e.key === 'Escape') {
modal.remove();
overlay.remove();
document.removeEventListener('keydown', closeHandler);
}
};
document.addEventListener('keydown', closeHandler);
}
});
+3
View File
File diff suppressed because one or more lines are too long
+9
View File
File diff suppressed because one or more lines are too long
-192
View File
@@ -1,192 +0,0 @@
<?php
require_once 'config/config.php';
require_once 'includes/parsedown/ParsedownExtra.php';
$Parsedown = new ParsedownExtra();
$author_id = (int)($_GET['id'] ?? 0);
if (!$author_id) {
http_response_code(400);
echo "<h2>Неверный запрос</h2>";
include 'views/footer.php';
exit;
}
$stmt = $pdo->prepare("SELECT id, username, display_name, avatar, bio FROM users WHERE id = ?");
$stmt->execute([$author_id]);
$author = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$author) {
http_response_code(404);
echo "<h2>Автор не найден</h2>";
include 'views/footer.php';
exit;
}
$bookModel = new Book($pdo);
$books = $bookModel->findByUser($author_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;
}
$page_title = ($author['display_name'] ?: $author['username']) . ' — публичная страница';
include 'views/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 #eee; padding-bottom: 1rem;">
<!-- Аватарка автора -->
<div style="margin-bottom: 1rem;">
<?php if (!empty($author['avatar'])): ?>
<img src="<?= AVATARS_URL . e($author['avatar']) ?>"
alt="<?= e($author['display_name'] ?: $author['username']) ?>"
style="width: 150px; height: 150px; border-radius: 50%; border: 3px solid #007bff; object-fit: cover;"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width: 150px; height: 150px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem; margin: 0 auto;">
<?= mb_substr(e($author['display_name'] ?: $author['username']), 0, 1) ?>
</div>
<?php endif; ?>
</div>
<h1 style="margin-bottom: 0.5rem;"><?= e($author['display_name'] ?: $author['username']) ?></h1>
<!-- Биография автора -->
<?php if (!empty($author['bio'])): ?>
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
<?= $Parsedown->text($author['bio']) ?>
</div>
<?php endif; ?>
<!-- Статистика автора -->
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #007bff;"><?= $total_books ?></div>
<div>Книг</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #28a745;"><?= $total_chapters ?></div>
<div>Глав</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #6f42c1;"><?= $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: #f9f9f9; 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: #f8f9fa; 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 #ddd;"
onerror="this.style.display='none'">
</div>
<?php else: ?>
<div style="flex-shrink: 0;">
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
<?php if ($book['genre']): ?>
<p style="color: #666; 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="view_book.php?share_token=<?= e($book['share_token']) ?>" class="adaptive-button">
Читать книгу
</a>
<small style="color: #666;">
Глав: <?= $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 #eee; text-align: center;">
<p style="color: #666;">
Страница автора создана в <?= 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 #e0e0e0;
}
.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, #667eea 0%, #764ba2 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/footer.php'; ?>
-47
View File
@@ -1,47 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
redirect('books.php');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect('books.php');
}
$book_id = $_POST['book_id'] ?? null;
$user_id = $_SESSION['user_id'];
if (!$book_id) {
$_SESSION['error'] = "Не указана книга для удаления";
redirect('books.php');
}
$bookModel = new Book($pdo);
// Проверяем права доступа
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
redirect('books.php');
}
// Получаем информацию о книге перед удалением
$book = $bookModel->findById($book_id);
if (!empty($book['cover_image'])) {
$cover_path = COVERS_PATH . $book['cover_image'];
if (file_exists($cover_path)) {
unlink($cover_path);
}
}
// Удаляем книгу
if ($bookModel->delete($book_id, $user_id)) {
$_SESSION['success'] = "Книга «" . e($book['title']) . "» успешно удалена";
} else {
$_SESSION['error'] = "Ошибка при удалении книги";
}
redirect('books.php');
?>
-45
View File
@@ -1,45 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
redirect('books.php');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect('books.php');
}
$user_id = $_SESSION['user_id'];
$bookModel = new Book($pdo);
// Получаем все книги пользователя
$books = $bookModel->findByUser($user_id);
if (empty($books)) {
$_SESSION['error'] = "У вас нет книг для удаления";
redirect('books.php');
}
$deleted_count = 0;
$error_count = 0;
// Удаляем каждую книгу
foreach ($books as $book) {
if ($bookModel->delete($book['id'], $user_id)) {
$deleted_count++;
} else {
$error_count++;
}
}
if ($error_count === 0) {
$_SESSION['success'] = "Все книги успешно удалены ($deleted_count книг)";
} else {
$_SESSION['error'] = "Удалено $deleted_count книг, не удалось удалить $error_count книг";
}
redirect('books.php');
?>
-413
View File
@@ -1,413 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($pdo);
// Проверяем, редактируем ли существующую книгу
$book_id = $_GET['id'] ?? null;
$book = null;
$is_edit = false;
if ($book_id) {
$book = $bookModel->findById($book_id);
if (!$book || $book['user_id'] != $user_id) {
$_SESSION['error'] = "Книга не найдена или у вас нет доступа";
redirect('books.php');
}
$is_edit = true;
}
// Обработка формы
$cover_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect($is_edit ? "book_edit.php?id=$book_id" : 'book_edit.php');
}
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
$genre = trim($_POST['genre'] ?? '');
$editor_type = $_POST['editor_type'] ?? 'markdown';
if (empty($title)) {
$_SESSION['error'] = "Название книги обязательно";
} else {
$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;
if ($series_id && !$sort_order_in_series) {
$seriesModel = new Series($pdo);
$sort_order_in_series = $seriesModel->getNextSortOrder($series_id);
}
$data = [
'title' => $title,
'description' => $description,
'genre' => $genre,
'user_id' => $user_id,
'series_id' => $series_id,
'sort_order_in_series' => $sort_order_in_series,
'editor_type' => $editor_type
];
$data['published'] = isset($_POST['published']) ? 1 : 0;
// Проверяем, изменился ли тип редактора
$editor_changed = false;
$old_editor_type = null;
if ($is_edit && $book['editor_type'] !== $editor_type) {
$editor_changed = true;
$old_editor_type = $book['editor_type'];
}
// Обработка загрузки обложки
if (isset($_FILES['cover_image']) && $_FILES['cover_image']['error'] === UPLOAD_ERR_OK) {
$cover_result = handleCoverUpload($_FILES['cover_image'], $book_id);
if ($cover_result['success']) {
$bookModel->updateCover($book_id, $cover_result['filename']);
// Обновляем данные книги
$book = $bookModel->findById($book_id);
} else {
$cover_error = $cover_result['error'];
}
}
// Обработка удаления обложки
if (isset($_POST['delete_cover']) && $_POST['delete_cover'] == '1') {
$bookModel->deleteCover($book_id);
$book = $bookModel->findById($book_id);
}
if ($is_edit) {
$success = $bookModel->update($book_id, $data);
// Конвертируем контент глав, если изменился редактор
if ($success && $editor_changed) {
$conversion_success = $bookModel->convertChaptersContent($book_id, $old_editor_type, $editor_type);
if (!$conversion_success) {
$_SESSION['warning'] = "Книга обновлена, но возникли ошибки при конвертации содержания глав";
} else {
$_SESSION['info'] = "Книга обновлена. Содержание глав сконвертировано в новый формат редактора.";
}
}
$message = $success ? "Книга успешно обновлена" : "Ошибка при обновлении книги";
} else {
$success = $bookModel->create($data);
$message = $success ? "Книга успешно создана" : "Ошибка при создании книги";
if ($success) {
$new_book_id = $pdo->lastInsertId();
redirect("book_edit.php?id=$new_book_id");
}
}
if ($success) {
$_SESSION['success'] = $message;
redirect('books.php');
} else {
$_SESSION['error'] = $message;
}
}
}
$page_title = $is_edit ? "Редактирование книги" : "Создание новой книги";
include 'views/header.php';
?>
<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'] ?? $_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($book['genre'] ?? $_POST['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..."
style="width: 100%; margin-bottom: 1.5rem;">
<label for="editor_type" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Режим редактора
</label>
<select id="editor_type" name="editor_type" style="width: 100%; margin-bottom: 1.5rem;" onchange="showEditorWarning(this)">
<option value="markdown" <?= ($book['editor_type'] ?? 'markdown') == 'markdown' ? 'selected' : '' ?>>Markdown редактор</option>
<option value="html" <?= ($book['editor_type'] ?? '') == 'html' ? 'selected' : '' ?>>HTML редактор (TinyMCE)</option>
</select>
<div id="editor_warning" style="display: none; background: #fff3cd; border: 1px solid #ffeaa7; padding: 10px; border-radius: 4px; margin-bottom: 1rem;">
<strong>Внимание:</strong> При смене редактора содержимое всех глав будет автоматически сконвертировано в новый формат.
</div>
<script>
function showEditorWarning(select) {
const warning = document.getElementById('editor_warning');
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
if (select.value !== currentEditor) {
warning.style.display = 'block';
} else {
warning.style.display = 'none';
}
}
// Показать предупреждение при загрузке, если редактор уже отличается
document.addEventListener('DOMContentLoaded', function() {
const currentEditor = '<?= $book['editor_type'] ?? 'markdown' ?>';
const selectedEditor = document.getElementById('editor_type').value;
if (currentEditor !== selectedEditor) {
document.getElementById('editor_warning').style.display = 'block';
}
});
</script>
<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
$seriesModel = new Series($pdo);
$user_series = $seriesModel->findByUser($user_id, false);
foreach ($user_series as $ser):
$selected = ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '';
?>
<option value="<?= $ser['id'] ?>" <?= $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 #ddd;">
<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: #666;">
Разрешены: 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'] ?? $_POST['description'] ?? '') ?></textarea>
<div>
<label for="published">
<input type="checkbox" id="published" name="published" value="1"
<?= !empty($book['published']) || (!empty($_POST['published']) && $_POST['published']) ? 'checked' : '' ?>>
Опубликовать книгу (показывать на публичной странице автора)
</label>
</div>
</div>
<div style="display: flex; gap: 5px; flex-wrap: wrap; align-items: center;">
<button type="submit" class="contrast button">
<?= $is_edit ? '💾 Сохранить изменения' : '📖 Создать книгу' ?>
</button>
</div>
</form>
<?php if ($is_edit): ?>
<form method="post" action="book_normalize_content.php" onsubmit="return confirm('Нормализовать контент всех глав книги? Это действие нельзя отменить.')">
<input type="hidden" name="book_id" value="<?= $book_id ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="button secondary">🔄 Нормализовать контент глав</button>
<p style="margin-top: 0.5rem; font-size: 0.8em; color: #666;">
Если контент глав отображается неправильно после смены редактора, можно нормализовать его структуру.
</p>
</form>
<?php endif; ?>
<?php if ($is_edit): ?>
<form method="post" action="book_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
<input type="hidden" name="book_id" value="<?= $book['id'] ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить книгу">
🗑️ Удалить главу
</button>
</form>
<?php endif ?>
<?php if ($is_edit): ?>
<div style="margin-top: 2rem; padding: 1rem; background: #f8f9fa; 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 . '/view_book.php?share_token=' . $book['share_token']) ?>"
readonly
style="flex: 1; padding: 8px; border: 1px solid #ddd; border-radius: 4px; background: white; width:80%;">
<br>
<button type="button" onclick="copyShareLink()" class="compact-button secondary" style="width: 15%;">
📋 Копировать
</button>
<form method="post" action="book_regenerate_token.php" style="display: inline; margin-top: 1.5em;">
<input type="hidden" name="book_id" value="<?= $book_id ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button secondary" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')" >
🔄 Обновить
</button>
</form>
<p style="margin-top: -1rem; font-size: 0.8em; color: #666; width: 100%;">
<strong>Примечание:</strong> В публичном просмотре отображаются только главы со статусом "Опубликована"
</p>
</div>
</div>
<script>
function copyShareLink() {
const shareLink = document.getElementById('share-link');
shareLink.select();
shareLink.setSelectionRange(0, 99999);
document.execCommand('copy');
// Показать уведомление
const button = event.target;
const originalText = button.innerHTML;
button.innerHTML = '✅ Скопировано';
setTimeout(() => {
button.innerHTML = originalText;
}, 2000);
}
</script>
<?php endif; ?>
<?php if ($is_edit): ?>
<div style="margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
<h3>Экспорт книги</h3>
<p style="margin-bottom: 0.5rem;">Экспортируйте книгу в различные форматы:</p>
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
<a href="export_book.php?book_id=<?= $book_id ?>&format=pdf" class="adaptive-button secondary" target="_blank">
📄 PDF
</a>
<a href="export_book.php?book_id=<?= $book_id ?>&format=docx" class="adaptive-button secondary" target="_blank">
📝 DOCX
</a>
<a href="export_book.php?book_id=<?= $book_id ?>&format=html" class="adaptive-button secondary" target="_blank">
🌐 HTML
</a>
<a href="export_book.php?book_id=<?= $book_id ?>&format=txt" class="adaptive-button secondary" target="_blank">
📄 TXT
</a>
</div>
<p style="margin-top: 0.5rem; font-size: 0.9em; color: #666;">
<strong>Примечание:</strong> Экспортируются все главы книги (включая черновики)
</p>
</div>
<?php endif; ?>
<?php if ($is_edit): ?>
<div style="margin-top: 3rem;">
<h2>Главы этой книги</h2>
<a href="chapters.php?book_id=<?= $book_id ?>" class="compact-button secondary">
📑 Все главы
</a>
&nbsp;
<a href="chapter_edit.php?book_id=<?= $book_id ?>" role="button" class="compact-button secondary">
✏️ Добавить главу
</a>
<?php
// Получаем главы книги
$stmt = $pdo->prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY sort_order, created_at");
$stmt->execute([$book_id]);
$chapters = $stmt->fetchAll();
if ($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 #eee;">
<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="chapter_edit.php?id=<?= $chapter['id'] ?>" role="button" class="compact-button secondary" style="text-decoration: none;">
Редактировать
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px;">
<p style="margin-bottom: 1rem;">В этой книге пока нет глав.</p>
<a href="chapter_edit.php?book_id=<?= $book_id ?>" role="button" class="compact-button secondary" >
✏️ Добавить первую главу
</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php include 'views/footer.php'; ?>
-41
View File
@@ -1,41 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
redirect('books.php');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect('books.php');
}
$book_id = $_POST['book_id'] ?? null;
$user_id = $_SESSION['user_id'];
if (!$book_id) {
$_SESSION['error'] = "Не указана книга";
redirect('books.php');
}
$bookModel = new Book($pdo);
// Проверяем права доступа
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
redirect('books.php');
}
// Генерируем новый токен
$new_token = $bookModel->generateNewShareToken($book_id);
if ($new_token) {
$_SESSION['success'] = "Публичная ссылка обновлена";
} else {
$_SESSION['error'] = "Ошибка при обновлении ссылки";
}
redirect("book_edit.php?id=$book_id");
?>
-156
View File
@@ -1,156 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($pdo);
$books = $bookModel->findByUser($user_id);
$page_title = "Мои книги";
include 'views/header.php';
?>
<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($books) ?></h2>
<div style="display: flex; gap: 10px; align-items: center;">
<a href="book_edit.php" class="action-button primary">➕ Новая книга</a>
<?php if (!empty($books)): ?>
<button type="button" onclick="showDeleteConfirmation()" class="action-button delete">
🗑️ Удалить все книги
</button>
<?php endif; ?>
</div>
</div>
<?php if (empty($books)): ?>
<article style="text-align: center; padding: 2rem;">
<h3>У вас пока нет книг</h3>
<p>Создайте свою первую книгу и начните писать!</p>
<a href="book_edit.php" role="button">📖 Создать первую книгу</a>
</article>
<?php else: ?>
<div class="flex">
<?php foreach ($books as $book): ?>
<article>
<?php if ($book['cover_image']): ?>
<div style="text-align: center; margin-bottom: 1rem; float: left; margin-right: 2em;">
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>"
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;">
</div>
<?php endif; ?>
<header>
<h3><?= e($book['title']) ?>
<?php if ($book['series_id']): ?>
<?php
$series_stmt = $pdo->prepare("SELECT title FROM series WHERE id = ?");
$series_stmt->execute([$book['series_id']]);
$series_title = $series_stmt->fetch()['title'] ?? '';
?>
<?php if ($series_title): ?>
<div style="margin: 0.3rem 0;">
<small style="color: #007bff;">
📚 Серия: <?= e($series_title) ?>
<?php if ($book['sort_order_in_series']): ?>
(Книга <?= $book['sort_order_in_series'] ?>)
<?php endif; ?>
</small>
</div>
<?php endif; ?>
<?php endif; ?>
<div style="display: flex; gap: 3px; float:right;">
<a href="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary" title="Редактировать книгу">
✏️
</a>
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" class="compact-button secondary" title="Просмотреть книгу" target="_blank">
👁️
</a>
<a href="chapters.php?book_id=<?= $book['id'] ?>" class="compact-button secondary" title="Просмотр глав">
📑
</a>
<a href="export_book.php?book_id=<?= $book['id'] ?>&format=pdf" class="compact-button secondary" title="Экспорт в PDF" target="_blank">
📄
</a>
<form method="post" action="book_delete.php" style="display: inline; margin-top: -0.1em;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
<input type="hidden" name="book_id" value="<?= $book['id'] ?>">
<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>
</h3>
<?php if ($book['genre']): ?>
<small style="color: #666; margin-top: 0.1em;"><?= e($book['genre']) ?></small>
<?php endif; ?>
</header>
<?php if ($book['description']): ?>
<p><?= e(mb_strimwidth($book['description'], 0, 150, '...')) ?></p>
<?php endif; ?>
<footer style="margin-top:1em; padding-top:2em;">
<div>
<small>
Глав: <?= $book['chapter_count'] ?> |
Слов: <?= $book['total_words'] ?>
</small>
</div>
</footer>
</article>
<?php endforeach; ?>
</div>
<!-- Подтверждение удаления всех книг -->
<dialog id="deleteAllDialog" style="border-radius: 8px; padding: 20px; max-width: 500px; background-color: #fff;">
<h3 style="margin-top: 0;">Удалить все книги?</h3>
<p>Это действие удалит все ваши книги и все связанные с ними главы. Это действие нельзя отменить.</p>
<form method="post" action="book_delete_all.php" style="display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px;">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="button" onclick="closeDeleteDialog()" class="secondary" style="flex: 1;">
❌ Отмена
</button>
<button type="submit" class="contrast" style="flex: 1; background: #ff4444; border-color: #ff4444; color: white;">
🗑️ Удалить все
</button>
</form>
</dialog>
<script>
function showDeleteConfirmation() {
const dialog = document.getElementById('deleteAllDialog');
dialog.showModal();
}
function closeDeleteDialog() {
const dialog = document.getElementById('deleteAllDialog');
dialog.close();
}
// Закрытие диалога по клику вне его области
document.getElementById('deleteAllDialog').addEventListener('click', function(event) {
if (event.target === this) {
closeDeleteDialog();
}
});
</script>
<?php endif; ?>
<?php include 'views/footer.php'; ?>
-43
View File
@@ -1,43 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
redirect('books.php');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect('books.php');
}
$chapter_id = $_POST['chapter_id'] ?? null;
$user_id = $_SESSION['user_id'];
if (!$chapter_id) {
$_SESSION['error'] = "Не указана глава для удаления";
redirect('books.php');
}
$chapterModel = new Chapter($pdo);
// Проверяем права доступа
if (!$chapterModel->userOwnsChapter($chapter_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой главе";
redirect('books.php');
}
$chapter = $chapterModel->findById($chapter_id);
$book_id = $chapter['book_id'];
// Удаляем главу
if ($chapterModel->delete($chapter_id)) {
$_SESSION['success'] = "Глава успешно удалена";
} else {
$_SESSION['error'] = "Ошибка при удалении главы";
}
redirect("chapters.php?book_id=$book_id");
?>
-386
View File
@@ -1,386 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$chapterModel = new Chapter($pdo);
$bookModel = new Book($pdo);
// Получаем book_id из GET или из существующей главы
$chapter_id = $_GET['id'] ?? null;
$book_id = $_GET['book_id'] ?? null;
$chapter = null;
$is_edit = false;
// Если редактируем существующую главу
if ($chapter_id) {
$chapter = $chapterModel->findById($chapter_id);
if (!$chapter || $chapter['user_id'] != $user_id) {
$_SESSION['error'] = "Глава не найдена или у вас нет доступа";
redirect('books.php');
}
$book_id = $chapter['book_id'];
$is_edit = true;
}
if (!$book_id) {
$_SESSION['error'] = "Не указана книга";
redirect('books.php');
}
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
redirect('books.php');
}
// Получаем информацию о книге
$book = $bookModel->findById($book_id);
// Обработка формы
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect($is_edit ? "chapter_edit.php?id=$chapter_id" : "chapter_edit.php?book_id=$book_id");
}
// Обработка автосохранения
if (isset($_POST['autosave']) && $_POST['autosave'] === 'true') {
// Автосохранение работает только для существующих глав
// Если это не редактирование, игнорируем автосохранение
if (!$is_edit) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Автосохранение недоступно для новых глав']);
exit;
}
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$status = $_POST['status'] ?? 'draft';
if (empty($title)) {
header('Content-Type: application/json');
echo json_encode(['success' => false, 'message' => 'Название главы обязательно']);
exit;
}
$data = [
'title' => $title,
'content' => $content,
'status' => $status,
'book_id' => $book_id
];
$success = $chapterModel->update($chapter_id, $data);
header('Content-Type: application/json');
echo json_encode(['success' => $success]);
exit;
}
// Обычная обработка формы (не автосохранение)
$title = trim($_POST['title'] ?? '');
$content = trim($_POST['content'] ?? '');
$status = $_POST['status'] ?? 'draft';
if (empty($title)) {
$_SESSION['error'] = "Название главы обязательно";
} else {
$data = [
'title' => $title,
'content' => $content,
'status' => $status,
'book_id' => $book_id
];
if ($is_edit) {
$success = $chapterModel->update($chapter_id, $data);
$message = $success ? "Глава успешно обновлена" : "Ошибка при обновлении главы";
} else {
$success = $chapterModel->create($data);
$message = $success ? "Глава успешно создана" : "Ошибка при создании главы";
if ($success) {
$new_chapter_id = $pdo->lastInsertId();
redirect("chapter_edit.php?id=$new_chapter_id");
}
}
if ($success) {
$_SESSION['success'] = $message;
redirect("book_edit.php?id=$book_id");
} else {
$_SESSION['error'] = $message;
}
}
}
$page_title = $is_edit ? "Редактирование главы" : "Создание новой главы";
include 'views/header.php';
?>
<?php if ($is_edit): ?>
<div style="margin-top: 1rem;">
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<?php
// Получаем все главы книги для навигации
$chapters = $chapterModel->findByBook($book_id);
$current_index = null;
// Находим индекс текущей главы
foreach ($chapters as $index => $chap) {
if ($chap['id'] == $chapter_id) {
$current_index = $index;
break;
}
}
if ($current_index !== null && $current_index > 0):
$prev_chapter = $chapters[$current_index - 1];
?>
<a href="chapter_edit.php?id=<?= $prev_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
⬅️ Предыдущая: <?= e(mb_strimwidth($prev_chapter['title'], 0, 30, '...')) ?>
</a>
<?php endif; ?>
<?php if ($current_index !== null && $current_index < count($chapters) - 1):
$next_chapter = $chapters[$current_index + 1];
?>
<a href="chapter_edit.php?id=<?= $next_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
Следующая: <?= e(mb_strimwidth($next_chapter['title'], 0, 30, '...')) ?> ➡️
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<h1><?= $is_edit ? "Редактирование главы" : "Создание новой главы" ?></h1>
<p><strong>Книга:</strong> <?= e($book['title']) ?></p>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<form method="post" id="main-form">
<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($chapter['title'] ?? $_POST['title'] ?? '') ?>"
placeholder="Введите название главы"
style="width: 100%; margin-bottom: 1.5rem;"
required>
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Статус
</label>
<select id="status" name="status" style="width: 100%; margin-bottom: 1.5rem;">
<option value="draft" <?= ($chapter['status'] ?? 'draft') == 'draft' ? 'selected' : '' ?>>Черновик</option>
<option value="published" <?= ($chapter['status'] ?? '') == 'published' ? 'selected' : '' ?>>Опубликована</option>
</select>
<label for="content" style="display: block; margin-bottom: 0; font-weight: bold;">
Содержание главы
<?php if (isset($book['editor_type'])): ?>
<small style="color: #666; font-weight: normal;">
(Режим: <?= $book['editor_type'] == 'markdown' ? 'Markdown' : 'HTML' ?>)
</small>
<?php endif; ?>
</label>
<?php if (($book['editor_type'] ?? 'markdown') === 'html'): ?>
<!-- HTML редактор (TinyMCE) -->
<textarea name="content" id="content" style="width: 100%; min-height: 500px;">
<?= e($chapter['content'] ?? $_POST['content'] ?? '') ?>
</textarea>
<!-- Подключаем TinyMCE -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/tinymce/6.8.6/tinymce.min.js" referrerpolicy="origin"></script>
<script>
tinymce.init({
selector: '#content',
plugins: 'advlist autolink lists link image charmap preview anchor pagebreak searchreplace wordcount visualblocks visualchars code fullscreen insertdatetime media table emoticons',
toolbar: 'undo redo | blocks | bold italic underline strikethrough | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | link image media | code preview fullscreen',
menubar: 'edit view insert format tools table',
height: 500,
language: 'ru',
branding: false,
promotion: false,
image_advtab: true,
// Важные настройки для сохранения структуры
forced_root_block: 'p', // Используем <p> вместо <div>
force_br_newlines: false, // Не использовать <br> вместо абзацев
force_p_newlines: true, // Всегда создавать новые абзацы при Enter
convert_newlines_to_brs: false, // Не конвертировать переносы в <br>
remove_trailing_brs: true, // Убирать лишние <br> в конце
// Настройки форматирования
formats: {
// Сохраняем семантическое форматирование
bold: { inline: 'strong' },
italic: { inline: 'em' },
underline: { inline: 'u', exact: true },
strikethrough: { inline: 'del' }
},
// Настройки контента
content_style: `
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
line-height: 1.6;
margin: 0;
padding: 10px;
}
p {
margin: 0 0 1em 0;
}
h1, h2, h3, h4, h5, h6 {
margin: 1em 0 0.5em 0;
}
`,
// Настройки для чистого HTML
valid_elements: '*[*]', // Разрешаем все элементы (можно ограничить при необходимости)
valid_children: '+body[p,div,h1,h2,h3,h4,h5,h6,blockquote,pre,ul,ol,li,table]',
// Автосохранение
setup: function (editor) {
editor.on('init', function () {
// Нормализуем контент при инициализации
var content = editor.getContent();
if (content && !content.match(/<p[^>]*>/) && content.trim().length > 0) {
// Если нет тегов абзацев, оборачиваем в <p>
editor.setContent('<p>' + content.replace(/\n/g, '</p><p>') + '</p>');
}
});
editor.on('keydown', function (e) {
clearTimeout(window.tinymceSaveTimeout);
window.tinymceSaveTimeout = setTimeout(function() {
if (typeof autoSave === 'function') {
autoSave();
}
}, 2000);
});
// Обработка вставки текста
editor.on('paste', function (e) {
// Нормализуем вставленный текст
setTimeout(function() {
var content = editor.getContent();
// Убеждаемся, что контент имеет правильную структуру абзацев
editor.setContent(content);
}, 100);
});
}
});
</script>
<?php else: ?>
<!-- Markdown редактор (существующий) -->
<textarea name="content" id="content"
placeholder="Начните писать вашу главу здесь..."
rows="15"
style="width: 100%; font-family: monospace;"><?= e($chapter['content'] ?? $_POST['content'] ?? '') ?></textarea>
<script src="/assets/js/markdown-editor.js"></script>
<?php if ($is_edit): ?>
<script src="/assets/js/autosave.js"></script>
<?php endif; ?>
<?php endif; ?>
</div>
</form>
<div class="button-group">
<button type="submit" form="main-form" class="contrast">
<?= $is_edit ? '💾 Сохранить изменения' : '📝 Создать главу' ?>
</button>
<a href="book_edit.php?id=<?= $book_id ?>" role="button" class="secondary">
❌ Отмена
</a>
<button type="button" class="green-btn" id="preview-button">
👁️ Предпросмотр
</button>
</div>
<!-- Форма для предпросмотра -->
<form method="post" action="preview.php" target="_blank" id="preview-form" style="display: none;">
<input type="hidden" name="content" id="preview-content">
<input type="hidden" name="title" id="preview-title" value="<?= e($chapter['title'] ?? 'Новая глава') ?>">
<input type="hidden" name="editor_type" id="preview-editor-type" value="<?= e($book['editor_type'] ?? 'markdown') ?>">
</form>
<?php if ($is_edit): ?>
<div class="button-group">
<a href="chapter_edit.php?book_id=<?= $book_id ?>" role="button">
➕ Новая глава
</a>
<form method="post" action="chapter_delete.php" style="flex: 1;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');">
<input type="hidden" name="chapter_id" value="<?= $chapter_id ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="secondary delete-btn">
🗑️ Удалить
</button>
</form>
</div>
<?php endif; ?>
<?php if ($is_edit): ?>
<div style="margin-top: 3rem;">
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<?php
// Получаем все главы книги для навигации
$chapters = $chapterModel->findByBook($book_id);
$current_index = null;
// Находим индекс текущей главы
foreach ($chapters as $index => $chap) {
if ($chap['id'] == $chapter_id) {
$current_index = $index;
break;
}
}
if ($current_index !== null && $current_index > 0):
$prev_chapter = $chapters[$current_index - 1];
?>
<a href="chapter_edit.php?id=<?= $prev_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
⬅️ Предыдущая: <?= e(mb_strimwidth($prev_chapter['title'], 0, 30, '...')) ?>
</a>
<?php endif; ?>
<?php if ($current_index !== null && $current_index < count($chapters) - 1):
$next_chapter = $chapters[$current_index + 1];
?>
<a href="chapter_edit.php?id=<?= $next_chapter['id'] ?>" role="button" class="secondary" style="padding: 2px 4px;">
Следующая: <?= e(mb_strimwidth($next_chapter['title'], 0, 30, '...')) ?> ➡️
</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<script>
// Обработчик для кнопки предпросмотра
document.getElementById('preview-button').addEventListener('click', function() {
// Обновляем содержимое для предпросмотра
document.getElementById('preview-content').value = document.getElementById('content').value;
document.getElementById('preview-title').value = document.getElementById('title').value || 'Новая глава';
// Отправляем форму предпросмотра
document.getElementById('preview-form').submit();
});
</script>
<?php include 'views/footer.php'; ?>
-120
View File
@@ -1,120 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$book_id = $_GET['book_id'] ?? null;
if (!$book_id) {
$_SESSION['error'] = "Не указана книга";
redirect('books.php');
}
$bookModel = new Book($pdo);
$chapterModel = new Chapter($pdo);
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой книге";
redirect('books.php');
}
// Получаем информацию о книге и главах
$book = $bookModel->findById($book_id);
$chapters = $chapterModel->findByBook($book_id);
$page_title = "Главы книги: " . e($book['title']);
include 'views/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;">
<a href="chapter_edit.php?book_id=<?= $book_id ?>" class="adaptive-button">➕ Новая глава</a>
<a href="book_edit.php?id=<?= $book_id ?>" class="adaptive-button secondary">✏️ Редактировать книгу</a>
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" class="adaptive-button secondary" target="_blank">👁️ Просмотреть книгу</a>
<a href="books.php" class="adaptive-button secondary">📚 Все книги</a>
</div>
</div>
<?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 (empty($chapters)): ?>
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px; margin-top: 1rem;">
<h3>В этой книге пока нет глав</h3>
<p>Создайте первую главу для вашей книги</p>
<a href="chapter_edit.php?book_id=<?= $book_id ?>" 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['description']): ?>
<br><small style="color: #666;"><?= e(mb_strimwidth($chapter['description'], 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="chapter_edit.php?id=<?= $chapter['id'] ?>" class="compact-button secondary" title="Редактировать">
✏️
</a>
<form method="post" action="chapter_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');">
<input type="hidden" name="chapter_id" value="<?= $chapter['id'] ?>">
<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: #f5f5f5; 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/footer.php'; ?>
View File
+252
View File
@@ -0,0 +1,252 @@
<?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);
// Параметры пагинации
$current_page = isset($_GET['page']) ? (int)$_GET['page'] : 1;
$per_page = isset($_GET['per_page']) ? (int)$_GET['per_page'] : 10;
// Валидация параметров
$current_page = max(1, $current_page);
$allowed_per_page = [5, 10, 50, 100];
if (!in_array($per_page, $allowed_per_page)) {
$per_page = 10;
}
// Получаем общее количество пользователей
$total_users = $userModel->getTotalUsersCount();
// Вычисляем общее количество страниц
$total_pages = ceil($total_users / $per_page);
// Корректируем текущую страницу, если она выходит за пределы
if ($current_page > $total_pages && $total_pages > 0) {
$current_page = $total_pages;
}
// Вычисляем смещение
$offset = ($current_page - 1) * $per_page;
// Получаем пользователей для текущей страницы
$users = $userModel->getUsersPaginated($offset, $per_page);
// Генерируем пагинацию
$pagination = $this->generatePagination($current_page, $total_pages);
$this->render('admin/users', [
'users' => $users,
'page_title' => 'Управление пользователями',
'pagination' => $pagination,
'current_page' => $current_page,
'total_pages' => $total_pages,
'per_page' => $per_page,
'total_users' => $total_users,
'allowed_per_page' => $allowed_per_page
]);
}
private function generatePagination($current_page, $total_pages) {
if ($total_pages <= 1) {
return [];
}
$pagination = [];
$max_visible_pages = 5; // Количество видимых страниц до и после текущей
// Всегда добавляем первую страницу
$pagination[] = [
'page' => 1,
'label' => '1',
'active' => (1 == $current_page),
'type' => 'page'
];
// Определяем диапазон видимых страниц
$start_page = max(2, $current_page - $max_visible_pages);
$end_page = min($total_pages - 1, $current_page + $max_visible_pages);
// Добавляем многоточие после первой страницы, если нужно
if ($start_page > 2) {
$pagination[] = [
'page' => null,
'label' => '...',
'active' => false,
'type' => 'ellipsis'
];
}
// Добавляем видимые страницы
for ($i = $start_page; $i <= $end_page; $i++) {
$pagination[] = [
'page' => $i,
'label' => $i,
'active' => ($i == $current_page),
'type' => 'page'
];
}
// Добавляем многоточие перед последней страницей, если нужно
if ($end_page < $total_pages - 1) {
$pagination[] = [
'page' => null,
'label' => '...',
'active' => false,
'type' => 'ellipsis'
];
}
// Добавляем последнюю страницу, если она не первая
if ($total_pages > 1) {
$pagination[] = [
'page' => $total_pages,
'label' => $total_pages,
'active' => ($total_pages == $current_page),
'type' => 'page'
];
}
return $pagination;
}
public function toggleUserStatus($user_id) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Неверный метод запроса или токен безопасности";
$this->redirect('/admin/users');
return;
}
// Сохраняем параметры пагинации
$page = $_GET['page'] ?? 1;
$per_page = $_GET['per_page'] ?? 10;
if ($user_id == $_SESSION['user_id']) {
$_SESSION['error'] = "Нельзя изменить статус собственного аккаунта";
$this->redirect("/admin/users?page=$page&per_page=$per_page");
return;
}
$userModel = new User($this->pdo);
$user = $userModel->findById($user_id);
if (!$user) {
$_SESSION['error'] = "Пользователь не найден";
$this->redirect("/admin/users?page=$page&per_page=$per_page");
return;
}
$newStatus = $user['is_active'] ? 0 : 1;
if ($userModel->updateStatus($user_id, $newStatus)) {
$_SESSION['success'] = "Статус пользователя обновлен";
} else {
$_SESSION['error'] = "Ошибка при обновлении статуса";
}
$this->redirect("/admin/users?page=$page&per_page=$per_page");
}
public function deleteUser($user_id) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Неверный метод запроса или токен безопасности";
$this->redirect('/admin/users');
return;
}
// Сохраняем параметры пагинации
$page = $_GET['page'] ?? 1;
$per_page = $_GET['per_page'] ?? 10;
if ($user_id == $_SESSION['user_id']) {
$_SESSION['error'] = "Нельзя удалить собственный аккаунт";
$this->redirect("/admin/users?page=$page&per_page=$per_page");
return;
}
$userModel = new User($this->pdo);
$user = $userModel->findById($user_id);
if (!$user) {
$_SESSION['error'] = "Пользователь не найден";
$this->redirect("/admin/users?page=$page&per_page=$per_page");
return;
}
if ($userModel->delete($user_id)) {
$_SESSION['success'] = "Пользователь успешно удален";
} else {
$_SESSION['error'] = "Ошибка при удалении пользователя";
}
$this->redirect("/admin/users?page=$page&per_page=$per_page");
}
public function addUser() {
$error = '';
$success = '';
// Сохраняем параметры пагинации для возврата
$return_page = $_GET['page'] ?? 1;
$return_per_page = $_GET['per_page'] ?? 10;
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)) {
$_SESSION['success'] = 'Пользователь успешно создан';
$this->redirect("/admin/users?page=1&per_page=$return_per_page");
return;
} else {
$error = 'Ошибка при создании пользователя';
}
}
}
}
}
$this->render('admin/add_user', [
'error' => $error,
'success' => $success,
'page_title' => 'Добавление пользователя',
'return_page' => $return_page,
'return_per_page' => $return_per_page
]);
}
}
?>
+146
View File
@@ -0,0 +1,146 @@
<?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'] ?? '';
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
// Проверка Rate Limit
if (!checkRateLimit($this->pdo, $ip)) {
$error = 'Слишком много попыток входа. Попробуйте позже.';
} elseif (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 = 'Ваш аккаунт деактивирован или ожидает активации администратором.';
logLoginAttempt($this->pdo, $ip, $username);
} else {
// Успешный вход - очищаем попытки
clearLoginAttempts($this->pdo, $ip);
// Обновляем сессию
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 = 'Неверное имя пользователя или пароль';
logLoginAttempt($this->pdo, $ip, $username);
}
}
}
}
$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' => 'Регистрация'
]);
}
}
?>
+50
View File
@@ -0,0 +1,50 @@
<?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;
}
}
?>
+416
View File
@@ -0,0 +1,416 @@
<?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');
}
$is_short_story = isset($_POST['is_short_story']) ? 1 : 0;
$content = $is_short_story ? ($_POST['content'] ?? '') : null;
// Валидация: для рассказа проверяем, что есть хоть какое-то содержание
if ($is_short_story && empty(trim(strip_tags($content)))) {
$_SESSION['error'] = "Для рассказа необходимо заполнить содержание";
$_SESSION['form_data'] = $_POST;
$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,
'is_short_story' => $is_short_story,
'content' => $content
];
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;
}
}
// Если это рассказ с содержанием - редиректим на редактирование
// Если рассказ без содержания - тоже на редактирование (чтобы добавить текст)
if ($is_short_story) {
$_SESSION['success'] = "Рассказ успешно создан! Теперь можно добавить содержание.";
$this->redirect("/books/{$new_book_id}/edit");
} else {
$_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : "");
$this->redirect("/books/{$new_book_id}/chapters/create");
}
} 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 (!empty($_POST['autosave']) && $_POST['autosave'] === 'true') {
header('Content-Type: application/json');
// Подавляем ошибки для автосейва
error_reporting(0);
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
echo json_encode(['success' => false, 'error' => 'Ошибка безопасности']);
exit;
}
$book = $bookModel->findById($id);
if (!$book || $book['user_id'] != $_SESSION['user_id']) {
echo json_encode(['success' => false, 'error' => 'Книга не найдена']);
exit;
}
$is_short_story = isset($_POST['is_short_story']) ? 1 : 0;
$content = $is_short_story ? ($_POST['content'] ?? '') : null;
// Для autosave обновляем только title и user_id из сессии, остальное получаем из базы
$title = trim($_POST['title'] ?? $book['title']);
$description = trim($_POST['description'] ?? ($book['description'] ?? ''));
$genre = trim($_POST['genre'] ?? ($book['genre'] ?? ''));
$user_id = $_SESSION['user_id'];
$series_id = !empty($_POST['series_id']) ? (int)$_POST['series_id'] : $book['series_id'];
$sort_order_in_series = !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : $book['sort_order_in_series'];
$published = isset($_POST['published']) ? 1 : $book['published'];
$data = [
'title' => $title,
'description' => $description,
'genre' => $genre,
'user_id' => $user_id,
'series_id' => $series_id,
'sort_order_in_series' => $sort_order_in_series,
'published' => $published,
'is_short_story' => $is_short_story,
'content' => $content
];
$success = $bookModel->update($id, $data);
header('Content-Type: application/json');
if ($success) {
echo json_encode(['success' => true]);
} else {
echo json_encode(['success' => false, 'error' => 'Ошибка при сохранении']);
}
exit;
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$title = trim($_POST['title'] ?? '');
if (empty($title)) {
$error = "Название книги обязательно";
} else {
$is_short_story = isset($_POST['is_short_story']) ? 1 : 0;
$content = $is_short_story ? ($_POST['content'] ?? '') : null;
$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,
'is_short_story' => $is_short_story,
'content' => $content
];
// Обработка обложки
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;
}
// Если это рассказ - не загружаем главы
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->getPublishedChapters($book['id']);
$is_short_story = false;
}
// Получаем информацию об авторе
$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,
'is_short_story' => $is_short_story,
'page_title' => $book['title']
]);
}
public function viewAll($id) {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo);
$book = $bookModel->findByUserBook($id, $user_id);
if (!$book) {
http_response_code(404);
$this->render('errors/404');
return;
}
// Если это рассказ - не загружаем главы
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->findByBook($book['id']);
$is_short_story = false;
}
// Получаем информацию об авторе
$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,
'is_short_story' => $is_short_story,
'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");
}
}
?>
+279
View File
@@ -0,0 +1,279 @@
<?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)
]);
}
public function updateOrder($book_id) {
$this->requireLogin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
return $this->jsonResponse(['success' => false, 'error' => 'Неверный метод запроса']);
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
return $this->jsonResponse(['success' => false, 'error' => 'Ошибка безопасности']);
}
$user_id = $_SESSION['user_id'];
$chapterModel = new Chapter($this->pdo);
$bookModel = new Book($this->pdo);
// Проверяем права доступа к книге
if (!$bookModel->userOwnsBook($book_id, $user_id)) {
return $this->jsonResponse(['success' => false, 'error' => 'У вас нет доступа к этой книге']);
}
$order_data = $_POST['order'] ?? [];
if (empty($order_data)) {
return $this->jsonResponse(['success' => false, 'error' => 'Нет данных для обновления']);
}
// Обновляем порядок глав
if ($chapterModel->updateChaptersOrder($book_id, $order_data)) {
return $this->jsonResponse(['success' => true]);
} else {
return $this->jsonResponse(['success' => false, 'error' => 'Ошибка при обновлении порядка глав']);
}
}
function cleanChapterContent($content) {
// Удаляем лишние пробелы в начале и конце
$content = trim($content);
// Удаляем пустые абзацы и параграфы, содержащие только пробелы
$content = preg_replace('/<p[^>]*>\s*(?:<br\s*\/?>|&nbsp;)?\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;
}
}
?>
+52
View File
@@ -0,0 +1,52 @@
<?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' => 'Панель управления'
]);
}
}
?>
+792
View File
@@ -0,0 +1,792 @@
<?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');
}
// Для автора - все главы или контент рассказа
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->findByBook($book_id);
$is_short_story = false;
}
// Получаем информацию об авторе
$author_name = $this->getAuthorName($book['user_id']);
$this->handleExport($book, $chapters, false, $author_name, $format, $is_short_story);
}
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('/');
}
// Для публичного доступа - только опубликованные главы или контент рассказа
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->getPublishedChapters($book['id']);
$is_short_story = false;
}
// Получаем информацию об авторе
$author_name = $this->getAuthorName($book['user_id']);
$this->handleExport($book, $chapters, true, $author_name, $format, $is_short_story);
}
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, $is_short_story = false) {
switch ($format) {
case 'pdf':
$this->exportPDF($book, $chapters, $is_public, $author_name, $is_short_story);
break;
case 'docx':
$this->exportDOCX($book, $chapters, $is_public, $author_name, $is_short_story);
break;
case 'html':
$this->exportHTML($book, $chapters, $is_public, $author_name, $is_short_story);
break;
case 'txt':
$this->exportTXT($book, $chapters, $is_public, $author_name, $is_short_story);
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, $is_short_story = false) {
$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);
}
// Если это рассказ - сразу выводим контент
if ($is_short_story && !empty($book['content'])) {
$pdf->SetFont('dejavusans', '', 11);
$pdf->writeHTML($book['content'], true, false, true, false, '');
$pdf->Ln(8);
}
// Интерактивное оглавление (только для обычных книг)
if (!$is_short_story) {
$chapterLinks = [];
if (!empty($chapters)) {
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, $is_short_story = false) {
$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 ($is_short_story && !empty($book['content'])) {
$paragraphs = $this->htmlToParagraphs($book['content']);
foreach ($paragraphs as $paragraph) {
if (!empty(trim($paragraph))) {
$section->addText($paragraph);
$section->addTextBreak(1);
}
}
}
// Интерактивное оглавление (только для обычных книг)
if (!$is_short_story && !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);
}
// Разделитель
if (!$is_short_story) {
$section->addPageBreak();
}
// Главы с закладками (только для обычных книг)
if (!$is_short_story) {
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, $is_short_story = false) {
$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 ($is_short_story && !empty($book['content'])) {
$html .= '<div class="chapter-content">' . $book['content'] . '</div>';
}
// Интерактивное оглавление (только для обычных книг)
if (!$is_short_story && !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, $is_short_story = false) {
$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 = $this->htmlToPlainText($book['description']);
$descriptionLines = explode("\n", $descriptionText);
foreach ($descriptionLines as $line) {
if (trim($line) !== '') {
$content .= wordwrap(trim($line), 144) . "\n";
}
}
$content .= "\n";
}
// Если это рассказ - выводим контент сразу
if ($is_short_story && !empty($book['content'])) {
$content .= "СОДЕРЖАНИЕ РАССКАЗА:\n";
$content .= str_repeat("-", 60) . "\n\n";
$plainText = $this->htmlToPlainText($book['content']);
$paragraphs = explode("\n", $plainText);
foreach ($paragraphs as $paragraph) {
$trimmed = trim($paragraph);
if (!empty($trimmed)) {
$wrapped = wordwrap($trimmed, 144);
$content .= $wrapped . "\n\n";
}
}
}
// Оглавление (только для обычных книг)
if (!$is_short_story && !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";
// Главы (только для обычных книг)
if (!$is_short_story) {
foreach ($chapters as $index => $chapter) {
$content .= $chapter['title'] . "\n";
$content .= str_repeat("-", 60) . "\n\n";
// Используем новый метод для преобразования HTML в текст с сохранением абзацев
$plainText = $this->htmlToPlainText($chapter['content']);
$paragraphs = explode("\n", $plainText);
foreach ($paragraphs as $paragraph) {
$trimmed = trim($paragraph);
if (!empty($trimmed)) {
// Оборачиваем текст с учетом ширины строки
$wrapped = wordwrap($trimmed, 144);
$content .= $wrapped . "\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;
}
private function htmlToPlainText($html) {
if (empty($html)) {
return '';
}
// Заменяем HTML-сущности
$text = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
// Сохраняем структуру абзацев
// Заменяем теги <p> и <div> на переносы строк
$text = preg_replace('/<(p|div)[^>]*>/i', "\n", $text);
$text = str_replace(['</p>', '</div>'], "\n", $text);
// Заменяем <br> и <br/> на переносы строк
$text = preg_replace('/<br\s*\/?>/i', "\n", $text);
// Заменяем HTML списки на текстовые
$text = preg_replace('/<li[^>]*>/i', "• ", $text);
$text = str_replace('</li>', "\n", $text);
$text = str_replace(['<ul>', '</ul>', '<ol>', '</ol>'], "\n", $text);
// Заменяем заголовки
$text = preg_replace('/<h[1-6][^>]*>/i', "\n", $text);
$text = preg_replace('/<\/h[1-6]>/i', "\n\n", $text);
// Удаляем все остальные теги
$text = strip_tags($text);
// Удаляем лишние пробелы
$text = preg_replace('/[ \t]+/', ' ', $text);
// Удаляем лишние переносы строк (более 2 подряд)
$text = preg_replace('/\n{3,}/', "\n\n", $text);
// Удаляем пробелы в начале и конце строк
$lines = explode("\n", $text);
$lines = array_map('trim', $lines);
// Фильтруем пустые строки
$lines = array_filter($lines, function($line) {
return $line !== '';
});
// Объединяем обратно
$text = implode("\n", $lines);
return trim($text);
}
// Функция для разбивки 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;
}
}
?>
+313
View File
@@ -0,0 +1,313 @@
<?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,
'book_model' => $bookModel,
'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");
}
}
?>
+116
View File
@@ -0,0 +1,116 @@
<?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,
'book_model'=>$bookModel,
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
]);
}
}
?>
-191
View File
@@ -1,191 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($pdo);
$seriesModel = new Series($pdo);
$books = $bookModel->findByUser($user_id);
$series = $seriesModel->findByUser($user_id);
// Статистика по книгам
$total_chapters = 0;
$total_words = 0;
foreach ($books as $book) {
$total_chapters += $book['chapter_count'];
$total_words += $book['total_words'];
}
// Статистика по сериям
$series_stats = [
'total_series' => count($series),
'series_with_books' => 0,
'total_books_in_series' => 0
];
foreach ($series as $ser) {
$series_books = $seriesModel->getBooksInSeries($ser['id']);
$series_stats['total_books_in_series'] += count($series_books);
if (count($series_books) > 0) {
$series_stats['series_with_books']++;
}
}
$page_title = "Панель управления";
include 'views/header.php';
?>
<h1>Добро пожаловать, <?= e($_SESSION['display_name']) ?>!</h1>
<div style="margin-bottom: 1rem;">
<a href="profile.php" class="adaptive-button secondary">✏️ Редактировать профиль</a>
</div>
<div class="grid">
<article>
<h2>📚 Мои книги</h2>
<p>Управляйте вашими книгами и главами</p>
<div class="dashboard-buttons">
<a href="books.php" role="button" class="dashboard-button">
Мои книги (<?= count($books) ?>)
</a>
<a href="book_edit.php" role="button" class="dashboard-button new">
➕ Новая книга
</a>
</div>
</article>
<article>
<h2>📊 Статистика</h2>
<div class="stats-list">
<p><strong>Книг:</strong> <?= count($books) ?></p>
<p><strong>Глав:</strong> <?= $total_chapters ?></p>
<p><strong>Всего слов:</strong> <?= $total_words ?></p>
<?php if ($total_words > 0): ?>
<p><strong>Средняя глава:</strong> <?= round($total_words / max(1, $total_chapters)) ?> слов</p>
<?php endif; ?>
</div>
</article>
<article>
<h2>📖 Мои серии</h2>
<p>Управляйте сериями книг</p>
<div class="dashboard-buttons">
<a href="series.php" role="button" class="dashboard-button">
Мои серии (<?= $series_stats['total_series'] ?>)
</a>
<a href="series_edit.php" role="button" class="dashboard-button new">
➕ Новая серия
</a>
</div>
<?php if ($series_stats['total_series'] > 0): ?>
<div class="series-stats">
<p><strong>Книг в сериях:</strong> <?= $series_stats['total_books_in_series'] ?></p>
<p><strong>Заполненных серий:</strong> <?= $series_stats['series_with_books'] ?></p>
</div>
<?php endif; ?>
</article>
</div>
<?php if (!empty($books)): ?>
<div class="dashboard-section">
<h2>Недавние книги</h2>
<div class="grid">
<?php foreach (array_slice($books, 0, 3) as $book): ?>
<article class="dashboard-item">
<h4>
<?= e($book['title']) ?>
<?php if ($book['series_id']): ?>
<?php
$series_stmt = $pdo->prepare("SELECT title FROM series WHERE id = ?");
$series_stmt->execute([$book['series_id']]);
$series_title = $series_stmt->fetch()['title'] ?? '';
?>
<?php if ($series_title): ?>
<br><small style="color: #007bff;">📚 <?= e($series_title) ?></small>
<?php endif; ?>
<?php endif; ?>
</h4>
<p>Глав: <?= $book['chapter_count'] ?> | Слов: <?= $book['total_words'] ?></p>
<div class="action-buttons">
<a href="book_edit.php?id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
Редактировать
</a>
<a href="chapters.php?book_id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
Главы
</a>
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" role="button" class="compact-button secondary" target="_blank">
Просмотр
</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php if (count($books) > 3): ?>
<div style="text-align: center; margin-top: 1rem;">
<a href="books.php" role="button" class="secondary">📚 Показать все книги (<?= count($books) ?>)</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($series)): ?>
<div class="dashboard-section">
<h2>Недавние серии</h2>
<div class="grid">
<?php foreach (array_slice($series, 0, 3) as $ser): ?>
<article class="dashboard-item">
<h4><?= e($ser['title']) ?></h4>
<?php
$books_in_series = $seriesModel->getBooksInSeries($ser['id']);
$series_words = 0;
$series_chapters = 0;
foreach ($books_in_series as $book) {
$book_stats = $bookModel->getBookStats($book['id']);
$series_words += $book_stats['total_words'] ?? 0;
$series_chapters += $book_stats['chapter_count'] ?? 0;
}
?>
<p>Книг: <?= count($books_in_series) ?> | Глав: <?= $series_chapters ?> | Слов: <?= $series_words ?></p>
<div class="action-buttons">
<a href="series_edit.php?id=<?= $ser['id'] ?>" role="button" class="compact-button secondary">
Редактировать
</a>
<a href="view_series.php?id=<?= $ser['id'] ?>" role="button" class="compact-button secondary" target="_blank">
Просмотр
</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php if (count($series) > 3): ?>
<div style="text-align: center; margin-top: 1rem;">
<a href="series.php" role="button" class="secondary">📖 Показать все серии (<?= count($series) ?>)</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (empty($books) && empty($series)): ?>
<div class="welcome-message">
<h3>Добро пожаловать в <?= e(APP_NAME) ?>!</h3>
<p>Начните создавать свои литературные произведения</p>
<div class="welcome-buttons">
<a href="book_edit.php" role="button" class="contrast">📖 Создать первую книгу</a>
<a href="series_edit.php" role="button" class="secondary">📚 Создать первую серию</a>
</div>
<div style="margin-top: 1.5rem;">
<a href="profile.php" role="button" class="secondary">✏️ Настроить профиль</a>
</div>
</div>
<?php endif; ?>
<?php include 'views/footer.php'; ?>
-869
View File
@@ -1,869 +0,0 @@
<?php
require_once 'config/config.php';
require_once 'vendor/autoload.php';
require_once 'includes/parsedown/ParsedownExtra.php';
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
use TCPDF;
// Проверяем авторизацию или share_token
$user_id = $_SESSION['user_id'] ?? null;
$share_token = $_GET['share_token'] ?? null;
$book_id = $_GET['book_id'] ?? null;
$format = $_GET['format'] ?? 'pdf';
if (!$user_id && !$share_token) {
$_SESSION['error'] = "Доступ запрещен";
redirect('login.php');
}
$bookModel = new Book($pdo);
$chapterModel = new Chapter($pdo);
$Parsedown = new ParsedownExtra();
// Получаем книгу
if ($share_token) {
$book = $bookModel->findByShareToken($share_token);
// Для публичного доступа - только опубликованные главы
$chapters = $bookModel->getPublishedChapters($book['id']);
$is_public = true;
} elseif ($book_id && $user_id) {
$book = $bookModel->findById($book_id);
if (!$book || $book['user_id'] != $user_id) {
$_SESSION['error'] = "Доступ запрещен";
redirect('books.php');
}
// Для автора - все главы
$chapters = $chapterModel->findByBook($book_id);
$is_public = false;
} else {
$_SESSION['error'] = "Книга не найдена";
redirect('books.php');
}
if (!$book) {
$_SESSION['error'] = "Книга не найдена";
redirect('books.php');
}
// Получаем информацию об авторе
$author_info = "Неизвестный автор";
if ($book) {
$stmt = $pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
$stmt->execute([$book['user_id']]);
$author_info = $stmt->fetch(PDO::FETCH_ASSOC);
if ($author_info['display_name'] !=""){
$author_name = $author_info['display_name'];
}else{
$author_name = $author_info['username'] ;
}
}
// Функция для преобразования Markdown в чистый текст с форматированием абзацев
function markdownToPlainText($markdown) {
// Обрабатываем диалоги (заменяем - на —)
$markdown = preg_replace('/^- (.+)$/m', "— $1", $markdown);
// Убираем Markdown разметку, но сохраняем переносы строк
$text = $markdown;
// Убираем заголовки
$text = preg_replace('/^#+\s+/m', '', $text);
// Убираем жирный и курсив
$text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
$text = preg_replace('/\*(.*?)\*/', '$1', $text);
$text = preg_replace('/__(.*?)__/', '$1', $text);
$text = preg_replace('/_(.*?)_/', '$1', $text);
// Убираем зачеркивание
$text = preg_replace('/~~(.*?)~~/', '$1', $text);
// Убираем код (встроенный)
$text = preg_replace('/`(.*?)`/', '$1', $text);
// Убираем блоки кода (сохраняем содержимое)
$text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
// Убираем ссылки
$text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
// Обрабатываем списки - заменяем маркеры на *
$text = preg_replace('/^[\*\-+]\s+/m', '* ', $text);
$text = preg_replace('/^\d+\.\s+/m', '* ', $text);
// Обрабатываем цитаты
$text = preg_replace('/^>\s+/m', '', $text);
return $text;
}
// Функция для разбивки Markdown на абзацы с сохранением структуры
function markdownToParagraphs($markdown) {
// Нормализуем переносы строк
$text = str_replace(["\r\n", "\r"], "\n", $markdown);
// Обрабатываем диалоги (заменяем - на —)
$text = preg_replace('/^- (.+)$/m', "— $1", $text);
// Разбиваем на строки
$lines = explode("\n", $text);
$paragraphs = [];
$currentParagraph = '';
foreach ($lines as $line) {
$trimmedLine = trim($line);
// Пустая строка - конец абзаца
if (empty($trimmedLine)) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
}
continue;
}
// Диалог (начинается с —) всегда начинает новый абзац
if (str_starts_with($trimmedLine, '—')) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
$currentParagraph = $trimmedLine;
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
continue;
}
// Заголовки (начинаются с #) всегда начинают новый абзац
if (str_starts_with($trimmedLine, '#')) {
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
$currentParagraph = preg_replace('/^#+\s+/', '', $trimmedLine);
$paragraphs[] = $currentParagraph;
$currentParagraph = '';
continue;
}
// Обычный текст - добавляем к текущему абзацу
if (!empty($currentParagraph)) {
$currentParagraph .= ' ' . $trimmedLine;
} else {
$currentParagraph = $trimmedLine;
}
}
// Добавляем последний абзац
if (!empty($currentParagraph)) {
$paragraphs[] = $currentParagraph;
}
return $paragraphs;
}
// Функция для очистки Markdown разметки
function cleanMarkdown($markdown) {
$text = $markdown;
// Убираем заголовки
$text = preg_replace('/^#+\s+/m', '', $text);
// Убираем жирный и курсив
$text = preg_replace('/\*\*(.*?)\*\*/', '$1', $text);
$text = preg_replace('/\*(.*?)\*/', '$1', $text);
$text = preg_replace('/__(.*?)__/', '$1', $text);
$text = preg_replace('/_(.*?)_/', '$1', $text);
// Убираем зачеркивание
$text = preg_replace('/~~(.*?)~~/', '$1', $text);
// Убираем код
$text = preg_replace('/`(.*?)`/', '$1', $text);
$text = preg_replace('/```.*?\n(.*?)```/s', '$1', $text);
// Убираем ссылки
$text = preg_replace('/\[(.*?)\]\(.*?\)/', '$1', $text);
// Обрабатываем списки - убираем маркеры
$text = preg_replace('/^[\*\-+]\s+/m', '', $text);
$text = preg_replace('/^\d+\.\s+/m', '', $text);
// Обрабатываем цитаты
$text = preg_replace('/^>\s+/m', '', $text);
return $text;
}
// Функция для форматирования текста с сохранением абзацев и диалогов
function formatPlainText($text) {
$lines = explode("\n", $text);
$formatted = [];
$in_paragraph = false;
foreach ($lines as $line) {
$line = trim($line);
if (empty($line)) {
if ($in_paragraph) {
$formatted[] = ''; // Пустая строка для разделения абзацев
$in_paragraph = false;
}
continue;
}
// Диалоги начинаются с —
if (str_starts_with($line, '—')) {
if ($in_paragraph) {
$formatted[] = ''; // Разделяем абзацы перед диалогом
}
$formatted[] = $line;
$formatted[] = ''; // Пустая строка после диалога
$in_paragraph = false;
} else {
// Обычный текст
$formatted[] = $line;
$in_paragraph = true;
}
}
return implode("\n", array_filter($formatted, function($line) {
return $line !== '' || !empty($line);
}));
}
// Обработка экспорта
switch ($format) {
case 'pdf':
exportPDF($book, $chapters, $is_public, $author_name);
break;
case 'docx':
exportDOCX($book, $chapters, $is_public, $author_name);
break;
case 'html':
exportHTML($book, $chapters, $is_public, $author_name);
break;
case 'txt':
exportTXT($book, $chapters, $is_public, $author_name);
break;
default:
$_SESSION['error'] = "Неверный формат экспорта";
redirect($share_token ? "view_book.php?share_token=$share_token" : "book_edit.php?id=$book_id");
}
function exportPDF($book, $chapters, $is_public, $author_name) {
global $Parsedown;
$pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
// Устанавливаем метаданные документа
$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);
if ($book['editor_type'] == 'markdown') {
$htmlContent = $Parsedown->text($chapter['content']);
} else {
$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) {
global $Parsedown;
$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'])) {
if ($book['editor_type'] == 'markdown') {
$descriptionParagraphs = markdownToParagraphs($book['description']);
} else {
$descriptionParagraphs = 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);
// Получаем очищенный текст и разбиваем на абзацы в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$cleanContent = cleanMarkdown($chapter['content']);
$paragraphs = markdownToParagraphs($cleanContent);
} else {
$cleanContent = strip_tags($chapter['content']);
$paragraphs = 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;
}
// Новая функция для разбивки 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 exportHTML($book, $chapters, $is_public, $author_name) {
global $Parsedown;
$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">';
if ($book['editor_type'] == 'markdown') {
$html .= nl2br(htmlspecialchars($book['description']));
} else {
$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>';
// Обрабатываем контент в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$htmlContent = $Parsedown->text($chapter['content']);
} else {
$htmlContent = $chapter['content'];
}
$html .= '<div class="chapter-content">' . $htmlContent . '</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";
// Обрабатываем описание в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$descriptionText = cleanMarkdown($book['description']);
} else {
$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";
// Получаем очищенный текст в зависимости от типа редактора
if ($book['editor_type'] == 'markdown') {
$cleanContent = cleanMarkdown($chapter['content']);
$paragraphs = markdownToParagraphs($cleanContent);
} else {
$cleanContent = strip_tags($chapter['content']);
$paragraphs = 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 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;
}
?>
+375 -354
View File
@@ -1,355 +1,376 @@
<?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;
}
<?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;
}
function checkRateLimit($pdo, $ip, $max_attempts = 5, $timeframe = 60) {
$stmt = $pdo->prepare("
SELECT COUNT(*) FROM login_attempts
WHERE ip_address = ? AND attempted_at > NOW() - INTERVAL ? SECOND
");
$stmt->execute([$ip, $timeframe]);
$count = $stmt->fetchColumn();
return $count < $max_attempts;
}
function logLoginAttempt($pdo, $ip, $username) {
$stmt = $pdo->prepare("INSERT INTO login_attempts (ip_address, username) VALUES (?, ?)");
return $stmt->execute([$ip, $username]);
}
function clearLoginAttempts($pdo, $ip) {
$stmt = $pdo->prepare("DELETE FROM login_attempts WHERE ip_address = ?");
return $stmt->execute([$ip]);
}
?>
Regular → Executable
View File
File diff suppressed because it is too large Load Diff
-18
View File
@@ -1,18 +0,0 @@
<?php
require_once 'Parsedown.php';
class ParsedownExtra extends Parsedown {
protected function blockQuote($Line) {
if (preg_match('/^�\s+/', $Line['text'])) {
return array(
'element' => array(
'name' => 'div',
'attributes' => array('class' => 'dialogue'),
'text' => ltrim($Line['text'], '� ')
)
);
}
return parent::blockQuote($Line);
}
}
View File
+184 -5
View File
@@ -1,9 +1,188 @@
<?php
require_once 'config/config.php';
// index.php - единая точка входа
require_once __DIR__ . '/../config/config.php';
if (is_logged_in()) {
redirect('dashboard.php');
} else {
redirect('login.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 $params = [];
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('/books/{id}/chapters/update-order', 'ChapterController@updateOrder');
// Серии
$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;
});
?>
+25 -19
View File
@@ -61,7 +61,8 @@ CREATE TABLE `books` (
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`share_token` varchar(32) DEFAULT NULL,
`published` tinyint(1) NOT NULL DEFAULT 0,
`editor_type` ENUM('markdown', 'html') DEFAULT 'markdown',
`is_short_story` tinyint(1) NOT NULL DEFAULT 0,
`content` text DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `share_token` (`share_token`),
KEY `user_id` (`user_id`),
@@ -199,27 +200,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
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();
require_once __DIR__ . '/../public/includes/functions.php';
// Настройки базы данных
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}');
}
// динамическое определение SITE_URL
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
define('SITE_URL', $protocol . $host);
// Настройки приложения
define('APP_NAME', 'Web Writer');
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('UPLOAD_PATH', __DIR__ . '/../public/uploads/');
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
define('COVERS_URL', SITE_URL . '/uploads/covers/');
define('AVATARS_PATH', UPLOAD_PATH . 'avatars/');
@@ -233,23 +234,28 @@ 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());
$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;
// Добавляем константы для новых путей
define('CONTROLLERS_PATH', __DIR__ . '/../public/controllers/');
define('VIEWS_PATH', __DIR__ . '/../public/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;
}
});
?>
EOT;
}
?>
-100
View File
@@ -1,100 +0,0 @@
<?php
require_once 'config/config.php';
// Если пользователь уже авторизован, перенаправляем на dashboard
if (is_logged_in()) {
redirect('dashboard.php');
}
$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($pdo);
$user = $userModel->findByUsername($username);
if ($user && $userModel->verifyPassword($password, $user['password_hash'])) {
if (!$user['is_active']) {
$error = 'Ваш аккаунт деактивирован или ожидает активации администратором.';
} else {
// Успешный вход
$_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']) . '!';
redirect('dashboard.php');
}
} else {
$error = 'Неверное имя пользователя или пароль';
}
}
}
}
$page_title = 'Вход в систему';
include 'views/header.php';
?>
<div class="container">
<h1>Вход в систему</h1>
<?php if ($error): ?>
<div class="alert alert-error">
<?= e($error) ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success">
<?= e($_SESSION['success']) ?>
<?php unset($_SESSION['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: 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="register.php">Зарегистрируйтесь здесь</a></p>
</div>
</div>
<?php include 'views/footer.php'; ?>
-20
View File
@@ -1,20 +0,0 @@
<?php
require_once 'config/config.php';
// Очищаем все данные сессии
$_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();
// Редирект на страницу входа
redirect('login.php');
?>
+93 -324
View File
@@ -1,6 +1,5 @@
<?php
// models/Book.php
require_once __DIR__ . '/../includes/parsedown/ParsedownExtra.php';
class Book {
private $pdo;
@@ -13,6 +12,11 @@ class Book {
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByUserBook($id, $user_id) {
$stmt = $this->pdo->prepare("SELECT * FROM books WHERE id = ? AND user_id=?");
$stmt->execute([$id, $user_id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByShareToken($share_token) {
$stmt = $this->pdo->prepare("SELECT * FROM books WHERE share_token = ?");
@@ -24,7 +28,11 @@ class Book {
$sql = "
SELECT b.*,
COUNT(c.id) as chapter_count,
COALESCE(SUM(c.word_count), 0) as total_words
COALESCE(
CASE WHEN b.is_short_story = 1
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
ELSE COALESCE(SUM(c.word_count), 0) END, 0
) as total_words
FROM books b
LEFT JOIN chapters c ON b.id = c.book_id
WHERE b.user_id = ?
@@ -41,11 +49,12 @@ class Book {
public function create($data) {
$share_token = bin2hex(random_bytes(16));
$published = isset($data['published']) ? (int)$data['published'] : 0;
$editor_type = $data['editor_type'] ?? 'markdown';
$is_short_story = isset($data['is_short_story']) ? (int)$data['is_short_story'] : 0;
$content = $is_short_story ? ($data['content'] ?? null) : null;
$stmt = $this->pdo->prepare("
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, editor_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, is_short_story, content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
");
return $stmt->execute([
$data['title'],
@@ -56,27 +65,34 @@ class Book {
$data['sort_order_in_series'] ?? null,
$share_token,
$published,
$editor_type
$is_short_story,
$content
]);
}
}
public function update($id, $data) {
$published = isset($data['published']) ? (int)$data['published'] : 0;
$editor_type = $data['editor_type'] ?? 'markdown';
$is_short_story = isset($data['is_short_story']) ? (int)$data['is_short_story'] : 0;
$content = $is_short_story ? ($data['content'] ?? null) : null;
// Преобразуем пустые строки в 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 = ?, editor_type = ?
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?, is_short_story = ?, content = ?
WHERE id = ? AND user_id = ?
");
return $stmt->execute([
$data['title'],
$data['description'] ?? null,
$data['genre'] ?? null,
$data['series_id'] ?? null,
$data['sort_order_in_series'] ?? null,
$series_id, // Теперь это либо integer, либо NULL
$sort_order_in_series, // Теперь это либо integer, либо NULL
$published,
$editor_type,
$is_short_story,
$content,
$id,
$data['user_id']
]);
@@ -102,6 +118,39 @@ class Book {
return false;
}
}
public function deleteAllByUser($user_id) {
try {
$this->pdo->beginTransaction();
// Получаем ID всех книг пользователя
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE user_id = ?");
$stmt->execute([$user_id]);
$book_ids = $stmt->fetchAll(PDO::FETCH_COLUMN);
if (empty($book_ids)) {
$this->pdo->commit();
return 0;
}
// Удаляем главы всех книг пользователя (одним запросом)
$placeholders = implode(',', array_fill(0, count($book_ids), '?'));
$stmt = $this->pdo->prepare("DELETE FROM chapters WHERE book_id IN ($placeholders)");
$stmt->execute($book_ids);
// Удаляем все книги пользователя (одним запросом)
$stmt = $this->pdo->prepare("DELETE FROM books WHERE user_id = ?");
$stmt->execute([$user_id]);
$deleted_count = $stmt->rowCount();
$this->pdo->commit();
return $deleted_count;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function userOwnsBook($book_id, $user_id) {
$stmt = $this->pdo->prepare("SELECT id FROM books WHERE id = ? AND user_id = ?");
@@ -116,15 +165,7 @@ class Book {
return $success ? $new_token : false;
}
public function getPublishedChapters($book_id) {
$stmt = $this->pdo->prepare("
SELECT * FROM chapters
WHERE book_id = ? AND status = 'published'
ORDER BY sort_order, created_at
");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function updateCover($book_id, $filename) {
$stmt = $this->pdo->prepare("UPDATE books SET cover_image = ? WHERE id = ?");
@@ -168,6 +209,35 @@ class Book {
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getBookStats($book_id, $only_published_chapters = false) {
$sql = "
SELECT
b.is_short_story,
(SELECT COUNT(*) FROM chapters c WHERE c.book_id = b.id " . ($only_published_chapters ? "AND c.status = 'published'" : "") . ") as chapter_count,
CASE
WHEN b.is_short_story = 1 AND b.content IS NOT NULL THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
WHEN b.is_short_story = 0 THEN (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b.id " . ($only_published_chapters ? "AND status = 'published'" : "") . ")
ELSE 0
END as total_words
FROM books b
WHERE b.id = ?
";
$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();
@@ -181,71 +251,11 @@ class Book {
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Ошибка при обновлении порядка книг: " . $e->getMessage());
return false;
}
}
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 convertChaptersContent($book_id, $from_editor, $to_editor) {
try {
$this->pdo->beginTransaction();
$chapters = $this->getAllChapters($book_id);
foreach ($chapters as $chapter) {
$converted_content = $this->convertContent(
$chapter['content'],
$from_editor,
$to_editor
);
$this->updateChapterContent($chapter['id'], $converted_content);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Error converting chapters: " . $e->getMessage());
return false;
}
}
private function getAllChapters($book_id) {
$stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
private function updateChapterContent($chapter_id, $content) {
$word_count = $this->countWords($content);
$stmt = $this->pdo->prepare("
UPDATE chapters
SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
");
return $stmt->execute([$content, $word_count, $chapter_id]);
}
private function countWords($text) {
$text = strip_tags($text);
$text = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $text);
@@ -253,247 +263,6 @@ class Book {
$words = array_filter($words);
return count($words);
}
private function convertContent($content, $from_editor, $to_editor) {
if ($from_editor === $to_editor) {
return $content;
}
try {
if ($from_editor === 'markdown' && $to_editor === 'html') {
// Markdown to HTML с улучшенной обработкой абзацев
return $this->markdownToHtmlWithParagraphs($content);
} elseif ($from_editor === 'html' && $to_editor === 'markdown') {
// HTML to Markdown
return $this->htmlToMarkdown($content);
}
} catch (Exception $e) {
error_log("Error converting content from {$from_editor} to {$to_editor}: " . $e->getMessage());
return $content;
}
return $content;
}
private function markdownToHtmlWithParagraphs($markdown) {
$parsedown = new ParsedownExtra();
// Включаем разметку строк для лучшей обработки абзацев
$parsedown->setBreaksEnabled(true);
// Обрабатываем Markdown
$html = $parsedown->text($markdown);
// Дополнительная обработка для обеспечения правильной структуры абзацев
$html = $this->ensureParagraphStructure($html);
return $html;
}
private function ensureParagraphStructure($html) {
// Если HTML не содержит тегов абзацев или div'ов, оборачиваем в <p>
if (!preg_match('/<(p|div|h[1-6]|blockquote|pre|ul|ol|li)/i', $html)) {
// Разбиваем на строки и оборачиваем каждую непустую строку в <p>
$lines = explode("\n", trim($html));
$wrappedLines = [];
foreach ($lines as $line) {
$line = trim($line);
if (!empty($line)) {
// Пропускаем уже обернутые строки
if (!preg_match('/^<[^>]+>/', $line) || preg_match('/^<(p|div|h[1-6])/i', $line)) {
$wrappedLines[] = $line;
} else {
$wrappedLines[] = "<p>{$line}</p>";
}
}
}
$html = implode("\n", $wrappedLines);
}
// Убеждаемся, что теги правильно закрыты
$html = $this->balanceTags($html);
return $html;
}
private function balanceTags($html) {
// Простая балансировка тегов - в реальном проекте лучше использовать DOMDocument
$tags = [
'p' => 0,
'div' => 0,
'span' => 0,
'strong' => 0,
'em' => 0,
];
// Счетчик открывающих и закрывающих тегов
foreach ($tags as $tag => &$count) {
$open = substr_count($html, "<{$tag}>") + substr_count($html, "<{$tag} ");
$close = substr_count($html, "</{$tag}>");
$count = $open - $close;
}
// Добавляем недостающие закрывающие теги
foreach ($tags as $tag => $count) {
if ($count > 0) {
$html .= str_repeat("</{$tag}>", $count);
}
}
return $html;
}
private function htmlToMarkdown($html) {
// Сначала нормализуем HTML структуру
$html = $this->normalizeHtml($html);
// Базовая конвертация HTML в Markdown
$markdown = $html;
// Обрабатываем абзацы - заменяем на двойные переносы строк
$markdown = preg_replace('/<p[^>]*>(.*?)<\/p>/is', "$1\n\n", $markdown);
// Обрабатываем разрывы строк
$markdown = preg_replace('/<br[^>]*>\s*<\/br[^>]*>/i', "\n", $markdown);
$markdown = preg_replace('/<br[^>]*>/i', " \n", $markdown); // Два пробела для Markdown разрыва
// Заголовки
$markdown = preg_replace('/<h1[^>]*>(.*?)<\/h1>/is', "# $1\n\n", $markdown);
$markdown = preg_replace('/<h2[^>]*>(.*?)<\/h2>/is', "## $1\n\n", $markdown);
$markdown = preg_replace('/<h3[^>]*>(.*?)<\/h3>/is', "### $1\n\n", $markdown);
$markdown = preg_replace('/<h4[^>]*>(.*?)<\/h4>/is', "#### $1\n\n", $markdown);
$markdown = preg_replace('/<h5[^>]*>(.*?)<\/h5>/is', "##### $1\n\n", $markdown);
$markdown = preg_replace('/<h6[^>]*>(.*?)<\/h6>/is', "###### $1\n\n", $markdown);
// Жирный текст
$markdown = preg_replace('/<strong[^>]*>(.*?)<\/strong>/is', '**$1**', $markdown);
$markdown = preg_replace('/<b[^>]*>(.*?)<\/b>/is', '**$1**', $markdown);
// Курсив
$markdown = preg_replace('/<em[^>]*>(.*?)<\/em>/is', '*$1*', $markdown);
$markdown = preg_replace('/<i[^>]*>(.*?)<\/i>/is', '*$1*', $markdown);
// Подчеркивание (не стандартно в Markdown, но обрабатываем)
$markdown = preg_replace('/<u[^>]*>(.*?)<\/u>/is', '<u>$1</u>', $markdown);
// Зачеркивание
$markdown = preg_replace('/<s[^>]*>(.*?)<\/s>/is', '~~$1~~', $markdown);
$markdown = preg_replace('/<strike[^>]*>(.*?)<\/strike>/is', '~~$1~~', $markdown);
$markdown = preg_replace('/<del[^>]*>(.*?)<\/del>/is', '~~$1~~', $markdown);
// Списки
$markdown = preg_replace('/<li[^>]*>(.*?)<\/li>/is', '- $1', $markdown);
$markdown = preg_replace('/<ul[^>]*>(.*?)<\/ul>/is', "$1\n", $markdown);
$markdown = preg_replace('/<ol[^>]*>(.*?)<\/ol>/is', "$1\n", $markdown);
// Блочные цитаты
$markdown = preg_replace('/<blockquote[^>]*>(.*?)<\/blockquote>/is', "> $1\n", $markdown);
// Код
$markdown = preg_replace('/<code[^>]*>(.*?)<\/code>/is', '`$1`', $markdown);
$markdown = preg_replace('/<pre[^>]*>(.*?)<\/pre>/is', "```\n$1\n```", $markdown);
// Ссылки
$markdown = preg_replace('/<a[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>/is', '[$2]($1)', $markdown);
// Изображения
$markdown = preg_replace('/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/is', '![$2]($1)', $markdown);
// Удаляем все остальные HTML-теги
$markdown = strip_tags($markdown);
// Чистим лишние пробелы и переносы
$markdown = preg_replace('/\n\s*\n\s*\n/', "\n\n", $markdown);
$markdown = preg_replace('/^\s+|\s+$/m', '', $markdown); // Trim каждой строки
$markdown = trim($markdown);
return $markdown;
}
private function normalizeHtml($html) {
// Нормализуем HTML структуру перед конвертацией
$html = preg_replace('/<div[^>]*>(.*?)<\/div>/is', "<p>$1</p>", $html);
// Убираем лишние пробелы
$html = preg_replace('/\s+/', ' ', $html);
// Восстанавливаем структуру абзацев
$html = preg_replace('/([^>])\s*<\/(p|div)>\s*([^<])/', "$1</$2>\n\n$3", $html);
return $html;
}
public function normalizeBookContent($book_id) {
try {
$chapters = $this->getAllChapters($book_id);
$book = $this->findById($book_id);
foreach ($chapters as $chapter) {
$normalized_content = '';
if ($book['editor_type'] == 'html') {
// Нормализуем HTML контент
$normalized_content = $this->normalizeHtmlContent($chapter['content']);
} else {
// Нормализуем Markdown контент
$normalized_content = $this->normalizeMarkdownContent($chapter['content']);
}
if ($normalized_content !== $chapter['content']) {
$this->updateChapterContent($chapter['id'], $normalized_content);
}
}
return true;
} catch (Exception $e) {
error_log("Error normalizing book content: " . $e->getMessage());
return false;
}
}
private function normalizeHtmlContent($html) {
// Простая нормализация HTML - оборачиваем текст без тегов в <p>
if (!preg_match('/<[^>]+>/', $html) && trim($html) !== '') {
// Если нет HTML тегов, оборачиваем в <p>
$lines = explode("\n", trim($html));
$wrapped = array_map(function($line) {
$line = trim($line);
return $line ? "<p>{$line}</p>" : '';
}, $lines);
return implode("\n", array_filter($wrapped));
}
return $html;
}
private function normalizeMarkdownContent($markdown) {
// Нормализация Markdown - убеждаемся, что есть пустые строки между абзацами
$lines = explode("\n", $markdown);
$normalized = [];
$inParagraph = false;
foreach ($lines as $line) {
$trimmed = trim($line);
if (empty($trimmed)) {
// Пустая строка - конец абзаца
if ($inParagraph) {
$normalized[] = '';
$inParagraph = false;
}
} else {
// Непустая строка
if (!$inParagraph && !empty($normalized) && end($normalized) !== '') {
// Добавляем пустую строку перед новым абзацем
$normalized[] = '';
}
$normalized[] = $line;
$inParagraph = true;
}
}
return implode("\n", $normalized);
}
}
?>
+40 -12
View File
@@ -20,33 +20,31 @@ class Chapter {
}
public function findByBook($book_id) {
$stmt = $this->pdo->prepare("
SELECT * FROM chapters
WHERE book_id = ?
ORDER BY sort_order, created_at
");
$stmt = $this->pdo->prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY sort_order ASC, id ASC");
$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();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$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 (?, ?, ?, ?, ?, ?)
INSERT INTO chapters (book_id, title, content, word_count, sort_order, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$word_count = $this->countWords($data['content']);
return $stmt->execute([
$data['book_id'],
$data['title'],
$data['content'],
$next_order,
$word_count,
$next_order,
$data['status'] ?? 'draft'
]);
}
@@ -97,5 +95,35 @@ class Chapter {
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);
}
public function updateChaptersOrder($book_id, $chapter_ids) {
try {
$this->pdo->beginTransaction();
// Обновляем порядок для каждой главы
foreach ($chapter_ids as $index => $chapter_id) {
$stmt = $this->pdo->prepare("UPDATE chapters SET sort_order = ? WHERE id = ? AND book_id = ?");
$stmt->execute([$index + 1, $chapter_id, $book_id]);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
error_log("Error updating chapters order: " . $e->getMessage());
return false;
}
}
}
?>
Regular → Executable
+27 -15
View File
@@ -8,16 +8,21 @@ class Series {
$this->pdo = $pdo;
}
public function findById($id) {
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
(
SELECT COALESCE(SUM(
CASE
WHEN b2.is_short_story = 1
THEN CHAR_LENGTH(b2.content) - CHAR_LENGTH(REPLACE(b2.content, ' ', '')) + 1
ELSE (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b2.id)
END
), 0)
FROM books b2
WHERE b2.series_id = s.id AND b2.published = 1
), 0) as total_words
) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id AND b.published = 1
WHERE s.id = ?
@@ -25,18 +30,19 @@ class Series {
");
$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 = ?
COALESCE(SUM(
CASE
WHEN b.is_short_story = 1
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
ELSE (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b.id)
END
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
@@ -45,7 +51,7 @@ class Series {
ORDER BY s.created_at DESC
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id, $user_id]);
$stmt->execute([$user_id]);
} else {
$sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC";
$stmt = $this->pdo->prepare($sql);
@@ -128,8 +134,14 @@ class Series {
$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
COALESCE(SUM(
CASE WHEN b.is_short_story = 1 THEN 0 ELSE stats.chapter_count END
), 0) as chapter_count,
COALESCE(SUM(
CASE WHEN b.is_short_story = 1
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
ELSE COALESCE(stats.total_words, 0) END
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
LEFT JOIN (
+23 -5
View File
@@ -66,17 +66,17 @@ class User {
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($params);
}
public function delete($id) {
$stmt = $this->pdo->prepare("DELETE FROM users WHERE id = ?");
return $stmt->execute([$id]);
}
public function updateStatus($id, $is_active) {
$stmt = $this->pdo->prepare("UPDATE users SET is_active = ? WHERE id = ?");
return $stmt->execute([$is_active, $id]);
}
public function delete($id) {
$stmt = $this->pdo->prepare("DELETE FROM users WHERE id = ?");
return $stmt->execute([$id]);
}
public function updateLastLogin($id) {
$stmt = $this->pdo->prepare("UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?");
return $stmt->execute([$id]);
@@ -115,5 +115,23 @@ class User {
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($params);
}
public function getTotalUsersCount() {
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM users");
$stmt->execute();
return $stmt->fetchColumn();
}
public function getUsersPaginated($offset, $limit) {
$stmt = $this->pdo->prepare("
SELECT * FROM users
ORDER BY id DESC
LIMIT :limit OFFSET :offset
");
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
}
?>
Regular → Executable
View File
-112
View File
@@ -1,112 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
require_once 'includes/parsedown/ParsedownExtra.php';
$Parsedown = new ParsedownExtra();;
$content = $_POST['content'] ?? '';
$title = $_POST['title'] ?? 'Предпросмотр';
$editor_type = $_POST['editor_type'] ?? 'markdown'; // Новое поле
// Обрабатываем контент в зависимости от типа редактора
if ($editor_type == 'markdown') {
$html_content = $Parsedown->text($content);
} else {
$html_content = $content;
}
$page_title = "Предпросмотр: " . e($title);
?>
<!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="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.10/css/pico.min.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 #007bff; padding-bottom: 0.3em; }
h2 { border-bottom: 1px solid #eaecef; padding-bottom: 0.3em; }
p {
margin-bottom: 1em;
}
code {
background: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
pre {
background: #f5f5f5;
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
border-left: 4px solid #007bff;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 4px solid #ddd;
padding-left: 1rem;
margin-left: 0;
color: #666;
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;
}
table {
border-collapse: collapse;
width: 100%;
margin: 1rem 0;
}
table, th, td {
border: 1px solid #ddd;
}
th, td {
padding: 8px 12px;
text-align: left;
}
th {
background: #f5f5f5;
}
</style>
</head>
<body>
<header>
<h1><?= e($title) ?></h1>
<hr>
</header>
<main class="content">
<?= $html_content ?>
</main>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #ddd;">
<small>Сгенерировано <?= date('d.m.Y H:i') ?> | Markdown Preview</small>
</footer>
</body>
</html>
-195
View File
@@ -1,195 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$userModel = new User($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 = "Ошибка при обновлении профиля";
}
}
}
$page_title = "Мой профиль";
include 'views/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: #666;">
Поддерживается Markdown форматирование
</small>
</div>
<div class="profile-buttons">
<button type="submit" class="profile-button primary">
💾 Сохранить изменения
</button>
<a href="dashboard.php" 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 #007bff;"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width: 200px; height: 200px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 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: #666;">
Разрешены: 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: #f8f9fa; border-radius: 5px;">
<p style="margin: 0; font-size: 0.9em; color: #666;">
<strong>Примечание:</strong> Аватарка отображается на вашей публичной странице автора
</p>
</div>
<?php endif; ?>
</article>
</div>
<article>
<h3>Информация об аккаунте</h3>
<p><a href="author.php?id=<?= $_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/footer.php'; ?>
-180
View File
@@ -1,180 +0,0 @@
<?php
require_once 'config/config.php';
// Если пользователь уже авторизован И он не администратор (ID != 1), перенаправляем на dashboard
if (is_logged_in() && $_SESSION['user_id'] != 1) {
redirect('dashboard.php');
}
$error = '';
$success = '';
// Проверяем, является ли текущий пользователь администратором
$is_admin = is_logged_in() && $_SESSION['user_id'] == 1;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности";
} else {
$username = trim($_POST['username'] ?? '');
$display_name = trim($_POST['display_name'] ?? '');
$email = trim($_POST['email'] ?? '');
$password = $_POST['password'] ?? '';
$confirm_password = $_POST['confirm_password'] ?? '';
// Валидация
if (empty($username) || empty($password)) {
$error = 'Имя пользователя и пароль обязательны для заполнения';
} elseif ($password !== $confirm_password) {
$error = 'Пароли не совпадают';
} elseif (strlen($password) < 6) {
$error = 'Пароль должен содержать не менее 6 символов';
} elseif (!preg_match('/^[a-zA-Z0-9_]+$/', $username)) {
$error = 'Имя пользователя может содержать только латинские буквы, цифры и символ подчеркивания';
} else {
$userModel = new User($pdo);
// Проверяем, не занят ли username
if ($userModel->findByUsername($username)) {
$error = 'Это имя пользователя уже занято';
} elseif (!empty($email) && $userModel->findByEmail($email)) {
$error = 'Этот email уже используется';
} else {
// Подготавливаем данные для создания пользователя
$user_data = [
'username' => $username,
'display_name' => $display_name ?: $username,
'email' => $email,
'password' => $password
];
// Если пользователя создает администратор - сразу активный
// Если пользователь регистрируется сам - требует активации
if ($is_admin) {
$user_data['is_active'] = 1;
} else {
$user_data['is_active'] = 0;
}
// Создаем пользователя
$success = $userModel->create($user_data);
if ($success) {
if ($is_admin) {
$_SESSION['success'] = 'Пользователь успешно создан и активирован';
redirect('admin/users.php');
} else {
$_SESSION['success'] = 'Регистрация прошла успешно. Ваш аккаунт ожидает активации администратором.';
redirect('login.php');
}
} else {
$error = 'Произошла ошибка при регистрации. Попробуйте еще раз.';
}
}
}
}
}
$page_title = $is_admin ? 'Добавление пользователя' : 'Регистрация';
include 'views/header.php';
?>
<div class="container">
<h1><?= $is_admin ? 'Добавление пользователя' : 'Регистрация' ?></h1>
<?php if ($error): ?>
<div class="alert alert-error">
<?= e($error) ?>
</div>
<?php endif; ?>
<?php if ($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: 1.5rem;">
<label for="confirm_password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Подтверждение пароля *
</label>
<input type="password" id="confirm_password" name="confirm_password"
placeholder="Повторите пароль"
style="width: 100%;"
required
minlength="6">
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="contrast" style="flex: 1;">
<?= $is_admin ? '👥 Добавить пользователя' : '📝 Зарегистрироваться' ?>
</button>
<?php if ($is_admin): ?>
<a href="admin/users.php" class="secondary" style="display: flex; align-items: center; justify-content: center; padding: 0.75rem; text-decoration: none;">
❌ Отмена
</a>
<?php endif; ?>
</div>
</form>
<?php if (!$is_admin): ?>
<div style="text-align: center; margin-top: 1rem;">
<p>Уже есть аккаунт? <a href="login.php">Войдите здесь</a></p>
<?php if (!$is_admin): ?>
<p style="color: #666; font-size: 0.9em; margin-top: 0.5rem;">
<strong>Примечание:</strong> После регистрации ваш аккаунт должен быть активирован администратором.
</p>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<?php include 'views/footer.php'; ?>
-95
View File
@@ -1,95 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($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);
$page_title = "Мои серии книг";
include 'views/header.php';
?>
<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($series) ?></h2>
<a href="series_edit.php" class="action-button primary">➕ Новая серия</a>
</div>
<?php if (empty($series)): ?>
<article style="text-align: center; padding: 2rem;">
<h3>У вас пока нет серий книг</h3>
<p>Создайте свою первую серию для организации книг!</p>
<a href="series_edit.php" role="button">📚 Создать первую серию</a>
</article>
<?php else: ?>
<div class="grid">
<?php foreach ($series as $ser): ?>
<article>
<header>
<h3>
<?= e($ser['title']) ?>
<div style="display: flex; gap: 3px; float:right;">
<a href="series_edit.php?id=<?= $ser['id'] ?>" class="compact-button secondary" title="Редактировать серию">
✏️
</a>
<a href="view_series.php?id=<?= $ser['id'] ?>" class="compact-button secondary" title="Просмотреть серию">
👁️
</a>
<form method="post" action="series_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить серию «<?= e($ser['title']) ?>»? Книги останутся, но будут убраны из серии.');">
<input type="hidden" name="series_id" value="<?= $ser['id'] ?>">
<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>
</h3>
</header>
<?php if ($ser['description']): ?>
<p><?= e(mb_strimwidth($ser['description'], 0, 200, '...')) ?></p>
<?php endif; ?>
<footer>
<div>
<small>
Книг: <?= $ser['book_count'] ?> |
Слов: <?= $ser['total_words'] ?>
</small>
</div>
<div style="margin-top: 0.5rem;">
<a href="view_series.php?id=<?= $ser['id'] ?>" class="adaptive-button secondary">
📖 Смотреть книги
</a>
</div>
</footer>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php include 'views/footer.php'; ?>
-39
View File
@@ -1,39 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
redirect('series.php');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect('series.php');
}
$series_id = $_POST['series_id'] ?? null;
$user_id = $_SESSION['user_id'];
if (!$series_id) {
$_SESSION['error'] = "Не указана серия для удаления";
redirect('series.php');
}
$seriesModel = new Series($pdo);
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой серии";
redirect('series.php');
}
$series = $seriesModel->findById($series_id);
if ($seriesModel->delete($series_id, $user_id)) {
$_SESSION['success'] = "Серия «" . e($series['title']) . "» успешно удалена";
} else {
$_SESSION['error'] = "Ошибка при удалении серии";
}
redirect('series.php');
?>
-179
View File
@@ -1,179 +0,0 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($pdo);
$series_id = $_GET['id'] ?? null;
$series = null;
$is_edit = false;
if ($series_id) {
$series = $seriesModel->findById($series_id);
if (!$series || !$seriesModel->userOwnsSeries($series_id, $user_id)) {
$_SESSION['error'] = "Серия не найдена или у вас нет доступа";
redirect('series.php');
}
$is_edit = true;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect($is_edit ? "series_edit.php?id=$series_id" : 'series_edit.php');
}
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
if (empty($title)) {
$_SESSION['error'] = "Название серии обязательно";
} else {
$data = [
'title' => $title,
'description' => $description,
'user_id' => $user_id
];
if ($is_edit) {
$success = $seriesModel->update($series_id, $data);
$message = $success ? "Серия успешно обновлена" : "Ошибка при обновлении серии";
} else {
$success = $seriesModel->create($data);
$message = $success ? "Серия успешно создана" : "Ошибка при создании серии";
if ($success) {
$new_series_id = $pdo->lastInsertId();
redirect("series_edit.php?id=$new_series_id");
}
}
if ($success) {
$_SESSION['success'] = $message;
redirect('series.php');
} else {
$_SESSION['error'] = $message;
}
}
}
$page_title = $is_edit ? "Редактирование серии" : "Создание новой серии";
include 'views/header.php';
?>
<h1><?= $is_edit ? "Редактирование серии" : "Создание новой серии" ?></h1>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['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($series['title'] ?? $_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($series['description'] ?? $_POST['description'] ?? '') ?></textarea>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="submit" class="contrast">
<?= $is_edit ? '💾 Сохранить изменения' : '📚 Создать серию' ?>
</button>
<a href="series.php" role="button" class="secondary">
❌ Отмена
</a>
</div>
</form>
<?php if ($is_edit): ?>
<div style="margin-top: 3rem;">
<h3>Книги в этой серии</h3>
<?php
$bookModel = new Book($pdo);
$books_in_series = $bookModel->findBySeries($series_id);
// Вычисляем общую статистику
$total_chapters = 0;
$total_words = 0;
foreach ($books_in_series as $book) {
$stats = $bookModel->getBookStats($book['id']);
$total_chapters += $stats['chapter_count'] ?? 0;
$total_words += $stats['total_words'] ?? 0;
}
?>
<?php if (empty($books_in_series)): ?>
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px;">
<p>В этой серии пока нет книг.</p>
<a href="books.php" class="adaptive-button">📚 Добавить книги</a>
</div>
<?php else: ?>
<div style="overflow-x: auto;">
<table class="compact-table">
<thead>
<tr>
<th style="width: 10%;">Порядок</th>
<th style="width: 40%;">Название книги</th>
<th style="width: 20%;">Жанр</th>
<th style="width: 15%;">Статус</th>
<th style="width: 15%;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($books_in_series as $book): ?>
<tr>
<td><?= $book['sort_order_in_series'] ?></td>
<td>
<strong><?= e($book['title']) ?></strong>
<?php if ($book['description']): ?>
<br><small style="color: #666;"><?= e(mb_strimwidth($book['description'], 0, 100, '...')) ?></small>
<?php endif; ?>
</td>
<td><?= e($book['genre']) ?></td>
<td>
<span style="color: <?= $book['published'] ? 'green' : 'orange' ?>">
<?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
</span>
</td>
<td>
<a href="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary">
Редактировать
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div style="margin-top: 1rem; padding: 0.5rem; background: #f5f5f5; border-radius: 3px;">
<strong>Статистика серии:</strong>
Книг: <?= count($books_in_series) ?> |
Глав: <?= $total_chapters ?> |
Слов: <?= $total_words ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php include 'views/footer.php'; ?>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Regular → Executable
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

-276
View File
@@ -1,276 +0,0 @@
<?php
require_once 'config/config.php';
require_once 'includes/parsedown/ParsedownExtra.php';
$Parsedown = new ParsedownExtra();
// Получаем книгу по share_token или id
$share_token = $_GET['share_token'] ?? null;
$book_id = $_GET['id'] ?? null;
$bookModel = new Book($pdo);
$book = null;
if ($share_token) {
$book = $bookModel->findByShareToken($share_token);
} elseif ($book_id) {
$book = $bookModel->findById($book_id);
}
if (!$book) {
http_response_code(404);
$page_title = "Книга не найдена";
include 'views/header.php';
?>
<div class="container">
<article style="text-align: center; padding: 2rem;">
<h1>Книга не найдена</h1>
<p>Запрошенная книга не существует или была удалена.</p>
<a href="index.php" role="button">На главную</a>
</article>
</div>
<?php
include 'views/footer.php';
exit;
}
// Получаем опубликованные главы
$chapters = $bookModel->getPublishedChapters($book['id']);
$total_words = array_sum(array_column($chapters, 'word_count'));
// Получаем информацию об авторе
$stmt = $pdo->prepare("SELECT display_name, username FROM users WHERE id = ?");
$stmt->execute([$book['user_id']]);
$author_info = $stmt->fetch(PDO::FETCH_ASSOC);
$author_name = $author_info['display_name'] ?? $author_info['username'] ?? 'Неизвестный автор';
$page_title = $book['title'];
include 'views/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 #eee; padding-bottom: 1rem;">
<?php if ($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>
<?php if ($book['series_id']): ?>
<?php
$series_stmt = $pdo->prepare("SELECT id, title FROM series WHERE id = ?");
$series_stmt->execute([$book['series_id']]);
$series = $series_stmt->fetch();
?>
<?php if ($series): ?>
<p style="color: #666; margin-bottom: 0.5rem;">
📚 Часть серии:
<a href="view_series.php?id=<?= $series['id'] ?>" style="color: #007bff;">
<?= e($series['title']) ?>
<?php if ($book['sort_order_in_series']): ?>
(Книга <?= $book['sort_order_in_series'] ?>)
<?php endif; ?>
</a>
</p>
<?php endif; ?>
<?php endif; ?>
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;"><?= e($author_name) ?></p>
<?php if ($book['genre']): ?>
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
Жанр: <?= e($book['genre']) ?>
</p>
<?php endif; ?>
<?php if ($book['description']): ?>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 5px; margin: 1rem 0;">
<p style="margin: 0; font-size: 1.1em;"><?= nl2br(e($book['description'])) ?></p>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<span>Глав: <?= count($chapters) ?></span>
<span>Слов: <?= $total_words ?></span>
<?php if (is_logged_in() && $book['user_id'] == $_SESSION['user_id']): ?>
<span>|</span>
<a href="books.php" style="color: #007bff;">Вернуться к редактированию</a>
<?php endif; ?>
</div>
</header>
<!-- Интерактивное оглавление -->
<?php if (!empty($chapters)): ?>
<div style="margin: 2rem 0; padding: 1.5rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;">
<h3 style="margin-top: 0; color: #007bff;">📖 Оглавление</h3>
<a name="start"></a>
<div style="columns: 1;">
<?php foreach ($chapters as $index => $chapter): ?>
<div style="break-inside: avoid; margin-bottom: 0.5rem;">
<a href="#chapter-<?= $chapter['id'] ?>"
style="text-decoration: none; color: #333; display: block; padding: 0.3rem 0;"
onmouseover="this.style.color='#007bff'"
onmouseout="this.style.color='#333'">
<span style="color: #666; font-size: 0.9em;"><?= $index + 1 ?>.</span>
<?= e($chapter['title']) ?>
</a>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div style="margin: 1rem 0; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
<h3 style="margin: 0 0 0.5rem 0;">Экспорт книги</h3>
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=pdf" class="adaptive-button secondary" target="_blank">
📄 PDF
</a>
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=docx" class="adaptive-button secondary" target="_blank">
📝 DOCX
</a>
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=html" class="adaptive-button secondary" target="_blank">
🌐 HTML
</a>
<a href="export_book.php?share_token=<?= $book['share_token'] ?>&format=txt" class="adaptive-button secondary" target="_blank">
📄 TXT
</a>
</div>
<p style="margin-top: 0.5rem; font-size: 0.9em; color: #666;">
<strong>Примечание:</strong> Экспортируются только опубликованные главы
</p>
</div>
<?php if (empty($chapters)): ?>
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
<h3>В этой книге пока нет опубликованных глав</h3>
<p>Автор еще не опубликовал ни одной главы</p>
</div>
<?php else: ?>
<div class="book-content">
<?php foreach ($chapters as $index => $chapter): ?>
<section class="chapter" id="chapter-<?= $chapter['id'] ?>" style="margin-bottom: 3rem; scroll-margin-top: 2rem;">
<h2 style="border-bottom: 1px solid #eee; padding-bottom: 0.5rem;">
<?= e($chapter['title']) ?>
<a href="#start" style="text-decoration: none; color: #666; font-size: 0.8em; margin-left: 1rem;">🔗</a>
</h2>
<div class="chapter-content" style="line-height: 1.6; font-size: 1.1em;">
<?php if ($book['editor_type'] == 'markdown'): ?>
<?= $Parsedown->text($chapter['content']) ?>
<?php else: ?>
<?= $chapter['content'] ?>
<?php endif; ?>
</div>
<div style="margin-top: 1rem; padding-top: 0.5rem; border-top: 1px dashed #eee; color: #666; font-size: 0.9em;">
<small>Обновлено: <?= date('d.m.Y', strtotime($chapter['updated_at'])) ?></small>
<a href="#top" style="float: right; color: #007bff; text-decoration: none;">↑ Наверх</a>
</div>
</section>
<?php endforeach; ?>
</div>
<?php endif; ?>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid #eee; text-align: center;">
<p style="color: #666;">
Книга создана в <?= e(APP_NAME) ?> •
Автор: <?= e($author_name) ?> •
<?= date('d.m.Y', strtotime($book['created_at'])) ?>
</p>
</footer>
</article>
</div>
<style>
.book-content {
line-height: 1.7;
}
.book-content h1, .book-content h2, .book-content h3, .book-content h4, .book-content h5, .book-content h6 {
margin-top: 2rem;
margin-bottom: 1rem;
}
.book-content p {
margin-bottom: 1rem;
text-align: justify;
}
.book-content blockquote {
border-left: 4px solid #007bff;
padding-left: 1rem;
margin-left: 0;
color: #555;
font-style: italic;
}
.book-content code {
background: #f5f5f5;
padding: 2px 4px;
border-radius: 3px;
}
.book-content pre {
background: #f5f5f5;
padding: 1rem;
border-radius: 5px;
overflow-x: auto;
}
.book-content ul, .book-content ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.book-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 1rem;
}
.book-content th, .book-content td {
border: 1px solid #ddd;
padding: 8px 12px;
text-align: left;
}
.book-content th {
background: #f5f5f5;
}
@media (max-width: 768px) {
.book-content {
font-size: 16px;
line-height: 1.6;
}
.book-content h1 {
font-size: 1.6em;
}
.book-content h2 {
font-size: 1.4em;
}
.book-content h3 {
font-size: 1.2em;
}
.book-content pre {
font-size: 14px;
}
div[style*="columns: 2"] {
columns: 1 !important;
}
}
</style>
<?php include 'views/footer.php'; ?>
-161
View File
@@ -1,161 +0,0 @@
<?php
require_once 'config/config.php';
require_once 'includes/parsedown/ParsedownExtra.php';
$Parsedown = new ParsedownExtra();
$series_id = (int)($_GET['id'] ?? 0);
if (!$series_id) {
http_response_code(400);
echo "<h2>Неверный запрос</h2>";
include 'views/footer.php';
exit;
}
$seriesModel = new Series($pdo);
$series = $seriesModel->findById($series_id);
if (!$series) {
http_response_code(404);
echo "<h2>Серия не найдена</h2>";
include 'views/footer.php';
exit;
}
// Получаем только опубликованные книги серии
$books = $seriesModel->getBooksInSeries($series_id, true);
// Получаем информацию об авторе
$stmt = $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($pdo);
$total_words = 0;
$total_chapters = 0;
foreach ($books as $book) {
$book_stats = $bookModel->getBookStats($book['id'], true); // true - только опубликованные главы
$total_words += $book_stats['total_words'] ?? 0;
$total_chapters += $book_stats['chapter_count'] ?? 0;
}
$page_title = $series['title'] . ' — серия книг';
include 'views/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 #eee; padding-bottom: 1rem;">
<h1 style="margin-bottom: 0.5rem;"><?= e($series['title']) ?></h1>
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
Серия книг от
<a href="author.php?id=<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
</p>
<?php if ($series['description']): ?>
<div style="background: #f8f9fa; padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;">
<?= $Parsedown->text($series['description']) ?>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<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: #f9f9f9; 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: #f8f9fa; 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 #ddd;"
onerror="this.style.display='none'">
</div>
<?php else: ?>
<div style="flex-shrink: 0;">
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;">
<?php if ($book['sort_order_in_series']): ?>
<small style="color: #666;">Книга <?= $book['sort_order_in_series'] ?></small><br>
<?php endif; ?>
<?= e($book['title']) ?>
</h3>
<?php if ($book['genre']): ?>
<p style="color: #666; 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="view_book.php?share_token=<?= e($book['share_token']) ?>" class="adaptive-button">
Читать
</a>
<?php
$bookModel = new Book($pdo);
$book_stats = $bookModel->getBookStats($book['id'], true); // true - только опубликованные главы
?>
<small style="color: #666;">
Глав: <?= $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 #eee; text-align: center;">
<p style="color: #666;">
Серия создана в <?= e(APP_NAME) ?> •
Автор: <a href="author.php?id=<?= $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;
}
.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/footer.php'; ?>
+105
View File
@@ -0,0 +1,105 @@
<?php include 'views/layouts/header.php'; ?>
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Добавление пользователя</h1>
<a href="<?= SITE_URL ?>/admin/users?page=<?= $return_page ?? 1 ?>&per_page=<?= $return_per_page ?? 10 ?>" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Назад к пользователям
</a>
</div>
<?php if (isset($error) && $error): ?>
<div class="alert alert-danger">
<?= e($error) ?>
</div>
<?php endif; ?>
<?php if (isset($success) && $success): ?>
<div class="alert alert-success">
<?= e($success) ?>
</div>
<?php endif; ?>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="username" class="form-label">Имя пользователя *</label>
<input type="text" class="form-control" id="username" name="username"
value="<?= e($_POST['username'] ?? '') ?>"
placeholder="Введите имя пользователя"
required
pattern="[a-zA-Z0-9_]+"
title="Только латинские буквы, цифры и символ подчеркивания">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="display_name" class="form-label">Отображаемое имя</label>
<input type="text" class="form-control" id="display_name" name="display_name"
value="<?= e($_POST['display_name'] ?? '') ?>"
placeholder="Введите отображаемое имя">
</div>
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email"
value="<?= e($_POST['email'] ?? '') ?>"
placeholder="Введите email">
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="password" class="form-label">Пароль *</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Введите пароль (минимум 6 символов)"
required
minlength="6">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="password_confirm" class="form-label">Подтверждение пароля *</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
placeholder="Повторите пароль"
required
minlength="6">
</div>
</div>
</div>
<div class="mb-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="is_active" name="is_active" value="1"
<?= isset($_POST['is_active']) ? 'checked' : 'checked' ?>>
<label class="form-check-label" for="is_active">
Активировать пользователя сразу
</label>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Добавить пользователя
</button>
<a href="<?= SITE_URL ?>/admin/users" class="btn btn-outline-danger">
<i class="bi bi-x-circle"></i> Отмена
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?>
+205
View File
@@ -0,0 +1,205 @@
<?php include 'views/layouts/header.php'; ?>
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h2">Управление пользователями</h1>
<p class="text-muted mb-0">
Всего пользователей: <?= $total_users ?>
<?php if ($total_users > 0): ?>
| Показано <?= (($current_page - 1) * $per_page) + 1 ?>-<?= min($current_page * $per_page, $total_users) ?>
<?php endif; ?>
</p>
</div>
<a href="<?= SITE_URL ?>/admin/add-user" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Добавить пользователя
</a>
</div>
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success alert-dismissible fade show">
<?= e($_SESSION['success']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['success']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-danger alert-dismissible fade show">
<?= e($_SESSION['error']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<!-- Выбор количества элементов на странице -->
<div class="card mb-4">
<div class="card-body">
<form method="get" class="row g-3 align-items-center">
<div class="col-auto">
<label for="per_page" class="col-form-label">Показывать по:</label>
</div>
<div class="col-auto">
<select name="per_page" id="per_page" class="form-select" onchange="this.form.submit()">
<?php foreach ($allowed_per_page as $value): ?>
<option value="<?= $value ?>" <?= $per_page == $value ? 'selected' : '' ?>>
<?= $value ?> пользователей
</option>
<?php endforeach; ?>
</select>
</div>
<div class="col-auto">
<input type="hidden" name="page" value="1">
<button type="submit" class="btn btn-outline-primary">Применить</button>
</div>
</form>
</div>
</div>
<?php if (empty($users)): ?>
<div class="text-center py-5">
<div class="mb-4">
<i class="bi bi-people fs-1 text-muted"></i>
</div>
<h3 class="h4 text-muted">Пользователи не найдены</h3>
<p class="text-muted mb-4">Зарегистрируйте первого пользователя</p>
<a href="<?= SITE_URL ?>/admin/add-user" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Добавить пользователя
</a>
</div>
<?php else: ?>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>Отображаемое имя</th>
<th>Email</th>
<th>Дата регистрации</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($users as $user): ?>
<tr>
<td><?= $user['id'] ?></td>
<td>
<strong>
<a href="<?= SITE_URL ?>/author/<?= $user['id'] ?>" class="text-decoration-none">
<?= e($user['username']) ?>
</a>
</strong>
<?php if ($user['id'] == $_SESSION['user_id']): ?>
<br><small class="text-muted">(Вы)</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 class="text-muted">Вход: <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small>
<?php endif; ?>
</td>
<td>
<span class="badge <?= $user['is_active'] ? 'bg-success' : 'bg-danger' ?>">
<?= $user['is_active'] ? 'Активен' : 'Неактивен' ?>
</span>
</td>
<td>
<?php if ($user['id'] != $_SESSION['user_id']): ?>
<div class="btn-group btn-group-sm">
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/toggle-status" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="btn btn-outline-<?= $user['is_active'] ? 'warning' : 'success' ?>"
title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
<i class="bi bi-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
</button>
</form>
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/delete"
onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');"
class="d-inline">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="btn btn-outline-danger" title="Удалить">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
<?php else: ?>
<small class="text-muted">Текущий пользователь</small>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<!-- Пагинация -->
<?php if (!empty($pagination)): ?>
<nav aria-label="Навигация по страницам" class="mt-4">
<ul class="pagination justify-content-center">
<!-- Кнопка "Назад" -->
<li class="page-item <?= $current_page <= 1 ? 'disabled' : '' ?>">
<a class="page-link" href="?page=<?= $current_page - 1 ?>&per_page=<?= $per_page ?>" aria-label="Предыдущая">
<span aria-hidden="true">&laquo;</span>
</a>
</li>
<!-- Элементы пагинации -->
<?php foreach ($pagination as $item): ?>
<?php if ($item['type'] === 'ellipsis'): ?>
<li class="page-item disabled">
<span class="page-link"><?= $item['label'] ?></span>
</li>
<?php else: ?>
<li class="page-item <?= $item['active'] ? 'active' : '' ?>">
<a class="page-link" href="?page=<?= $item['page'] ?>&per_page=<?= $per_page ?>">
<?= $item['label'] ?>
</a>
</li>
<?php endif; ?>
<?php endforeach; ?>
<!-- Кнопка "Вперед" -->
<li class="page-item <?= $current_page >= $total_pages ? 'disabled' : '' ?>">
<a class="page-link" href="?page=<?= $current_page + 1 ?>&per_page=<?= $per_page ?>" aria-label="Следующая">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
<!-- Информация о странице -->
<div class="text-center mt-3">
<p class="text-muted">
Страница <?= $current_page ?> из <?= $total_pages ?>
<?php if ($total_pages > 1): ?>
| Перейти:
<form method="get" class="d-inline">
<input type="hidden" name="per_page" value="<?= $per_page ?>">
<input type="number" name="page" min="1" max="<?= $total_pages ?>" value="<?= $current_page ?>"
class="form-control d-inline-block" style="width: 80px;"
onchange="this.form.submit()">
</form>
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<style>
.form-control[type="number"] {
display: inline-block;
width: auto;
}
</style>
<?php include 'views/layouts/footer.php'; ?>
+54
View File
@@ -0,0 +1,54 @@
<?php
// views/auth/login.php
include 'views/layouts/header.php';
?>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow border-0">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-person-check fs-1 text-primary"></i>
<h1 class="h3 mt-2">Вход в систему</h1>
</div>
<?php if (isset($error) && $error): ?>
<div class="alert alert-danger">
<?= e($error) ?>
</div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div class="mb-3">
<label for="username" class="form-label">Имя пользователя</label>
<input type="text" class="form-control" id="username" name="username"
value="<?= e($_POST['username'] ?? '') ?>"
placeholder="Введите имя пользователя" required>
</div>
<div class="mb-4">
<label for="password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Введите пароль" required>
</div>
<button type="submit" class="btn btn-primary w-100 py-2">
<i class="bi bi-box-arrow-in-right"></i> Войти
</button>
</form>
<div class="text-center mt-4">
<p class="text-muted">Нет аккаунта?
<a href="<?= SITE_URL ?>/register" class="text-decoration-none">Зарегистрируйтесь здесь</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?>
+91
View File
@@ -0,0 +1,91 @@
// views/auth/register.php
<?php
// views/auth/register.php
include 'views/layouts/header.php';
?>
<div class="container">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow border-0">
<div class="card-body p-5">
<div class="text-center mb-4">
<i class="bi bi-person-plus fs-1 text-primary"></i>
<h1 class="h3 mt-2">Регистрация</h1>
</div>
<?php if (isset($error) && $error): ?>
<div class="alert alert-danger">
<?= e($error) ?>
</div>
<?php endif; ?>
<?php if (isset($success) && $success): ?>
<div class="alert alert-success">
<?= e($success) ?>
</div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="username" class="form-label">Имя пользователя *</label>
<input type="text" class="form-control" id="username" name="username"
value="<?= e($_POST['username'] ?? '') ?>"
placeholder="Введите имя пользователя" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="display_name" class="form-label">Отображаемое имя</label>
<input type="text" class="form-control" id="display_name" name="display_name"
value="<?= e($_POST['display_name'] ?? '') ?>"
placeholder="Как вас будут видеть другие">
</div>
</div>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email"
value="<?= e($_POST['email'] ?? '') ?>"
placeholder="email@example.com">
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="password" class="form-label">Пароль *</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Не менее 6 символов" required>
</div>
</div>
<div class="col-md-6">
<div class="mb-4">
<label for="password_confirm" class="form-label">Подтверждение пароля *</label>
<input type="password" class="form-control" id="password_confirm" name="password_confirm"
placeholder="Повторите пароль" required>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary w-100 py-2">
<i class="bi bi-person-plus"></i> Зарегистрироваться
</button>
</form>
<div class="text-center mt-4">
<p class="text-muted">Уже есть аккаунт?
<a href="<?= SITE_URL ?>/login" class="text-decoration-none">Войдите здесь</a>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?>

Some files were not shown because too many files have changed in this diff Show More