Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b69a110b97 | ||
|
|
ce6fffaa57 | ||
|
|
9294399517 | ||
|
|
18a338c2ce | ||
|
|
3af0f18d55 | ||
|
|
7fd1fb20c4 | ||
|
|
15ab9d1947 | ||
|
|
5da459132b | ||
|
|
6738576501 | ||
|
|
35ca6223cb | ||
|
|
b9d3426213 | ||
|
|
c4eae6b45f | ||
|
|
de4d5ac595 | ||
|
|
cd382c5c7b | ||
|
|
70b75f8426 | ||
|
|
e1a5403936 | ||
|
|
2aa9b37d4e | ||
|
|
b32c26cb04 | ||
|
|
3edfa05f46 | ||
|
|
37676a3ccb | ||
|
|
39cda7814f | ||
|
|
e51768622f |
@@ -0,0 +1,6 @@
|
||||
*.log
|
||||
.env
|
||||
config/config.php
|
||||
*.lock
|
||||
uploads/*
|
||||
vendor/composer
|
||||
@@ -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).
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*!
|
||||
* Quill Editor v2.0.3
|
||||
* Quill Editor v2.0.2
|
||||
* https://quilljs.com
|
||||
* Copyright (c) 2017-2024, Slab
|
||||
* Copyright (c) 2014, Jason Chen
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
/* Увеличиваем специфичность для кнопок Quill */
|
||||
.ql-toolbar .ql-picker-label,
|
||||
.ql-toolbar button,
|
||||
.ql-toolbar [role="button"] {
|
||||
all: unset; /* Сбрасываем все стили Pico (background, border, padding и т.д.) */
|
||||
display: inline-block; /* Восстанавливаем базовые стили Quill */
|
||||
cursor: pointer;
|
||||
padding: 0; /* Quill кнопки не имеют padding */
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit; /* Наследуем цвет от Quill */
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
text-decoration: none; /* Убираем подчёркивание, если это <a> */
|
||||
}
|
||||
|
||||
/* Восстанавливаем hover/active стили Quill (если они сломались) */
|
||||
.ql-toolbar button:hover,
|
||||
.ql-toolbar [role="button"]:hover {
|
||||
color: #06c; /* Пример из Quill snow theme; адаптируйте */
|
||||
background: none; /* Без фона */
|
||||
}
|
||||
|
||||
.ql-toolbar button.ql-active,
|
||||
.ql-toolbar [role="button"].ql-active {
|
||||
color: #06c;
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Для иконок (SVG в Quill) */
|
||||
.ql-toolbar .ql-icon {
|
||||
fill: currentColor; /* Убедимся, что иконки наследуют цвет */
|
||||
}
|
||||
|
||||
/* Если Quill использует <span> для кнопок */
|
||||
.ql-toolbar .ql-picker-item,
|
||||
.ql-toolbar .ql-picker-options [role="button"] {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,907 +1,48 @@
|
||||
/* ===== БАЗОВЫЕ СТИЛИ ===== */
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color);
|
||||
font-weight: var(--font-weight);
|
||||
font-size: var(--font-size);
|
||||
font-family: var(--font-family);
|
||||
}
|
||||
article{
|
||||
margin-top: 0em;
|
||||
}
|
||||
article > header {
|
||||
margin-top: calc(var(--block-spacing-vertical) * -1);
|
||||
margin-bottom: calc(var(--block-spacing-vertical)-1);
|
||||
border-bottom: var(--border-width) solid var(--card-border-color);
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-top-left-radius: var(--border-radius);
|
||||
padding-bottom: 0.5em;
|
||||
}
|
||||
article > footer {
|
||||
margin-top: calc(var(--block-spacing-vertical)-1);
|
||||
margin-bottom: calc(var(--block-spacing-vertical) * -1);
|
||||
border-top: var(--border-width) solid var(--card-border-color);
|
||||
border-bottom-right-radius: var(--border-radius);
|
||||
border-bottom-left-radius: var(--border-radius);
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
/* Центрирование контейнера */
|
||||
main.container {
|
||||
margin: 1rem auto;
|
||||
padding: 1rem 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Центрируем основной контент */
|
||||
.container {
|
||||
width: 60%;
|
||||
margin-right: 10rem;
|
||||
margin-left: 10rem;
|
||||
}
|
||||
|
||||
/* Для больших экранов - ограничиваем ширину */
|
||||
@media (min-width: 768px) {
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== КОМПОНЕНТЫ ===== */
|
||||
/* Уведомления */
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
border: 1px solid #ffcdd2;
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: #e8f5e8;
|
||||
color: #2e7d32;
|
||||
border: 1px solid #c8e6c9;
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
border: 1px solid #ffeaa7;
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.button-group button,
|
||||
.button-group a[role="button"] {
|
||||
flex: 1;
|
||||
padding: 0.5rem;
|
||||
height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.compact-button {
|
||||
padding: 3px 8px;
|
||||
font-size: 0.85rem;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: 44px;
|
||||
min-width: 140px;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.3s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Цвета кнопок */
|
||||
.button-group .delete-btn,
|
||||
.action-button.delete {
|
||||
background: #ff4444;
|
||||
border-color: #ff4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.button-group .delete-btn:hover,
|
||||
.action-button.delete:hover {
|
||||
background: #dd3333;
|
||||
border-color: #dd3333;
|
||||
}
|
||||
|
||||
.green-btn {
|
||||
background: #449944;
|
||||
border-color: #449944;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.green-btn:hover {
|
||||
background: #44bb44;
|
||||
border-color: #44bb44;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary-inverse);
|
||||
}
|
||||
|
||||
.secondary-btn {
|
||||
background: var(--secondary);
|
||||
border-color: var(--secondary);
|
||||
color: var(--secondary-inverse);
|
||||
}
|
||||
.red-btn {
|
||||
background: #ff4444;
|
||||
border-color: #ff4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.red-btn:hover {
|
||||
background: #dd3333;
|
||||
border-color: #dd3333;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ===== КНИГИ И КОНТЕНТ ===== */
|
||||
.book-content {
|
||||
line-height: 1.7;
|
||||
font-family: Georgia, serif;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.book-content h1 {
|
||||
font-size: 2em;
|
||||
margin: 2rem 0 1rem;
|
||||
border-bottom: 2px solid #eee;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.book-content h2 {
|
||||
font-size: 1.6em;
|
||||
margin: 1.5rem 0 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.book-content h3 {
|
||||
font-size: 1.3em;
|
||||
margin: 1.2rem 0 0.8rem;
|
||||
}
|
||||
|
||||
.book-content p {
|
||||
margin-bottom: 1rem;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
.book-content blockquote {
|
||||
border-left: 4px solid #007bff;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
.book-content code {
|
||||
background: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.book-content pre {
|
||||
background: #2d2d2d;
|
||||
color: #f8f8f2;
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.book-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.book-content ul, .book-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.book-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.book-content th, .book-content td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
/* Центрируем таблицы в книжном контенте */
|
||||
.book-content table {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* ===== МЕДИА ===== */
|
||||
.book-cover {
|
||||
transition: transform 0.3s ease;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.book-cover:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 120px;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
margin: 0 auto 1rem;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #007bff;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Центрируем статистику */
|
||||
.author-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ===== QUILL РЕДАКТОР ===== */
|
||||
.writer-editor-container {
|
||||
margin: 10px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.writer-editor-container .ql-editor {
|
||||
min-height: 400px;
|
||||
font-family: 'Georgia', serif;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
|
||||
/* ===== DASHBOARD ===== */
|
||||
.dashboard-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-button {
|
||||
text-align: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
/* Дополнительные стили для тем */
|
||||
.theme-switcher {
|
||||
transition: all 0.3s ease;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dashboard-item {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dashboard-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Центрируем welcome сообщение */
|
||||
.welcome-message {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
margin: 2rem auto;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.welcome-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* ===== АДАПТИВНОСТЬ ===== */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dashboard-buttons {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.book-content {
|
||||
font-size: 16px;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.book-content h1 {
|
||||
font-size: 1.6em;
|
||||
}
|
||||
|
||||
.book-content h2 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.avatar, .avatar-placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
min-width: 120px;
|
||||
padding: 0.6rem 1rem;
|
||||
}
|
||||
|
||||
.welcome-message {
|
||||
padding: 2rem 1rem;
|
||||
margin: 1rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.book-content h1 {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
|
||||
.avatar, .avatar-placeholder {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
width: 100%;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.author-stats {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Стили для управления сериями */
|
||||
.books-list {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.book-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: white;
|
||||
.theme-option {
|
||||
transition: all 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.book-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.book-item:hover {
|
||||
background: #f8f9fa;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.book-item.sortable-ghost {
|
||||
opacity: 0.6;
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.book-item.sortable-chosen {
|
||||
background: #e3f2fd;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.book-drag-handle {
|
||||
padding: 0 12px;
|
||||
color: #666;
|
||||
font-size: 1.2rem;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.book-drag-handle:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
flex: 1;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.book-info strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.book-info small {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.book-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
.theme-option:hover {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
/* Адаптивность для мобильных */
|
||||
@media (max-width: 768px) {
|
||||
.book-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.book-drag-handle {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.book-actions {
|
||||
align-self: stretch;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.book-actions .compact-button {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
.theme-preview {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.series-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
}
|
||||
|
||||
.series-card {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.series-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.series-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.series-header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.series-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.series-title a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.series-title a:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.series-meta {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.series-description {
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.series-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.series-stat {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.series-stat-number {
|
||||
font-size: 1.4rem;
|
||||
font-weight: bold;
|
||||
color: #6f42c1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.series-stat-label {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.series-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Пустое состояние */
|
||||
.series-empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed #dee2e6;
|
||||
}
|
||||
|
||||
.series-empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Адаптивность для серий */
|
||||
/* editor styles */
|
||||
@media (max-width: 768px) {
|
||||
.series-grid {
|
||||
grid-template-columns: 1fr;
|
||||
.writer-editor-container .ql-container {
|
||||
font-size: 16px !important; /* Предотвращает масштабирование в iOS */
|
||||
}
|
||||
|
||||
.series-stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
.writer-editor-container .ql-toolbar {
|
||||
padding: 8px !important;
|
||||
}
|
||||
|
||||
.series-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.series-actions .compact-button {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
.writer-editor-container .ql-toolbar .ql-formats {
|
||||
margin-right: 8px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.series-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.series-stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.series-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.series-stat-number {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
/* ===== СТИЛИ ДЛЯ СПИСКА КНИГ ===== */
|
||||
.books-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
}
|
||||
|
||||
.book-card {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
/* Стили для полноэкранного режима */
|
||||
.ql-toolbar.ql-snow {
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.book-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.book-cover-container {
|
||||
position: relative;
|
||||
height: 200px;
|
||||
overflow: hidden;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.book-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.book-card:hover .book-cover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.book-status {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-radius: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.8rem;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.book-status.published {
|
||||
background: rgba(40, 167, 69, 0.9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.book-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.book-title a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.book-title a:hover {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.book-genre {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.book-description {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #555;
|
||||
line-height: 1.4;
|
||||
font-size: 0.9rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.book-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
display: block;
|
||||
font-size: 1.1rem;
|
||||
color: #6f42c1;
|
||||
}
|
||||
|
||||
.book-actions {
|
||||
display:grid;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.book-actions .compact-button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Пустое состояние */
|
||||
.books-empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed #dee2e6;
|
||||
}
|
||||
|
||||
.books-empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Футер со статистикой */
|
||||
.books-stats-footer {
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.books-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.book-stats {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.book-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.book-actions .compact-button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.book-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.book-cover-container {
|
||||
height: 160px;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.writer-editor-container.fullscreen {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.ql-editor p {
|
||||
display: block !important;
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -1,73 +1,97 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const quill = window.quillEditorInstance;
|
||||
const textarea = window.quillTextarea;
|
||||
if (!quill || !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);
|
||||
}
|
||||
msgEl.textContent = message;
|
||||
msgEl.style.background = isError ? '#dc3545' : '#28a745';
|
||||
msgEl.style.display = 'block';
|
||||
setTimeout(() => msgEl.style.display = 'none', 2000);
|
||||
// Не запускать на странице создания книги
|
||||
if (window.location.href.includes('/books/create')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ждём инициализации writerEditor
|
||||
function initAutosave() {
|
||||
const writerEditor = window.writerEditor;
|
||||
if (!writerEditor || !writerEditor.quill) {
|
||||
// Пробуем снова через 500ms
|
||||
setTimeout(initAutosave, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = writerEditor.textarea;
|
||||
if (!textarea) return;
|
||||
|
||||
const autoSave = () => {
|
||||
const currentContent = textarea.value;
|
||||
if (currentContent === lastSavedContent) return;
|
||||
let lastSavedContent = textarea.value;
|
||||
let saveTimeout;
|
||||
|
||||
const form = document.getElementById('chapter-form');
|
||||
const formData = new FormData(form);
|
||||
formData.append('autosave', 'true');
|
||||
|
||||
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 || 'Ошибка сервера');
|
||||
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(err => {
|
||||
console.error(err);
|
||||
showMessage('Ошибка автосохранения: ' + err.message, true);
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
quill.on('text-change', () => {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(autoSave, 2000);
|
||||
});
|
||||
|
||||
// Периодическая автосохранение
|
||||
setInterval(autoSave, 30000);
|
||||
// Периодическая автосохранение
|
||||
setInterval(autoSave, 30000);
|
||||
}
|
||||
|
||||
// Запускаем
|
||||
initAutosave();
|
||||
});
|
||||
|
||||
@@ -1,39 +1,191 @@
|
||||
// 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: [
|
||||
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
|
||||
['bold','italic','underline','strike'],
|
||||
['blockquote','code-block'],
|
||||
[{ 'list': 'ordered' }, { 'list': 'bullet' }],
|
||||
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||
[{ 'size': ['small', false, 'large', 'huge'] }],
|
||||
[{ 'font': [] }],
|
||||
['link','image','video'],
|
||||
['clean']
|
||||
],
|
||||
history: { delay: 1000, maxStack: 100, userOnly: true }
|
||||
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();
|
||||
|
||||
// Синхронизация с textarea
|
||||
setTimeout(() => this.formatExistingDialogues(), 100);
|
||||
|
||||
const sync = () => {
|
||||
let html = this.quill.root.innerHTML;
|
||||
html = html.replace(/^(<p><br><\/p>)+/, '').replace(/(<p><br><\/p>)+$/, '');
|
||||
@@ -41,14 +193,243 @@ class WriterEditor {
|
||||
};
|
||||
|
||||
this.quill.on('text-change', sync);
|
||||
this.form.addEventListener('submit', 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', () => {
|
||||
window.writerEditor = new WriterEditor();
|
||||
});
|
||||
// Не создавать редактор на странице создания книги
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -11,13 +11,112 @@ class AdminController extends BaseController {
|
||||
|
||||
public function users() {
|
||||
$userModel = new User($this->pdo);
|
||||
$users = $userModel->findAll();
|
||||
|
||||
// Параметры пагинации
|
||||
$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' => 'Управление пользователями'
|
||||
'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'] ?? '')) {
|
||||
@@ -25,10 +124,13 @@ class AdminController extends BaseController {
|
||||
$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');
|
||||
$this->redirect("/admin/users?page=$page&per_page=$per_page");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,7 +139,7 @@ class AdminController extends BaseController {
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = "Пользователь не найден";
|
||||
$this->redirect('/admin/users');
|
||||
$this->redirect("/admin/users?page=$page&per_page=$per_page");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +150,7 @@ class AdminController extends BaseController {
|
||||
$_SESSION['error'] = "Ошибка при обновлении статуса";
|
||||
}
|
||||
|
||||
$this->redirect('/admin/users');
|
||||
$this->redirect("/admin/users?page=$page&per_page=$per_page");
|
||||
}
|
||||
|
||||
public function deleteUser($user_id) {
|
||||
@@ -58,9 +160,13 @@ class AdminController extends BaseController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем параметры пагинации
|
||||
$page = $_GET['page'] ?? 1;
|
||||
$per_page = $_GET['per_page'] ?? 10;
|
||||
|
||||
if ($user_id == $_SESSION['user_id']) {
|
||||
$_SESSION['error'] = "Нельзя удалить собственный аккаунт";
|
||||
$this->redirect('/admin/users');
|
||||
$this->redirect("/admin/users?page=$page&per_page=$per_page");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -69,7 +175,7 @@ class AdminController extends BaseController {
|
||||
|
||||
if (!$user) {
|
||||
$_SESSION['error'] = "Пользователь не найден";
|
||||
$this->redirect('/admin/users');
|
||||
$this->redirect("/admin/users?page=$page&per_page=$per_page");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,13 +185,17 @@ class AdminController extends BaseController {
|
||||
$_SESSION['error'] = "Ошибка при удалении пользователя";
|
||||
}
|
||||
|
||||
$this->redirect('/admin/users');
|
||||
$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 = "Ошибка безопасности";
|
||||
@@ -119,9 +229,9 @@ class AdminController extends BaseController {
|
||||
];
|
||||
|
||||
if ($userModel->create($data)) {
|
||||
$success = 'Пользователь успешно создан';
|
||||
// Очищаем поля формы
|
||||
$_POST = [];
|
||||
$_SESSION['success'] = 'Пользователь успешно создан';
|
||||
$this->redirect("/admin/users?page=1&per_page=$return_per_page");
|
||||
return;
|
||||
} else {
|
||||
$error = 'Ошибка при создании пользователя';
|
||||
}
|
||||
@@ -133,7 +243,9 @@ class AdminController extends BaseController {
|
||||
$this->render('admin/add_user', [
|
||||
'error' => $error,
|
||||
'success' => $success,
|
||||
'page_title' => 'Добавление пользователя'
|
||||
'page_title' => 'Добавление пользователя',
|
||||
'return_page' => $return_page,
|
||||
'return_per_page' => $return_per_page
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,12 @@ class AuthController extends BaseController {
|
||||
} else {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = $_POST['password'] ?? '';
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
// Проверка Rate Limit
|
||||
if (!checkRateLimit($this->pdo, $ip)) {
|
||||
$error = 'Слишком много попыток входа. Попробуйте позже.';
|
||||
} elseif (empty($username) || empty($password)) {
|
||||
$error = 'Пожалуйста, введите имя пользователя и пароль';
|
||||
} else {
|
||||
$userModel = new User($this->pdo);
|
||||
@@ -29,8 +33,12 @@ class AuthController extends BaseController {
|
||||
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'];
|
||||
@@ -45,6 +53,7 @@ class AuthController extends BaseController {
|
||||
}
|
||||
} else {
|
||||
$error = 'Неверное имя пользователя или пароль';
|
||||
logLoginAttempt($this->pdo, $ip, $username);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,16 @@ class BookController extends BaseController {
|
||||
$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,
|
||||
@@ -44,12 +54,14 @@ class BookController extends BaseController {
|
||||
'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
|
||||
'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);
|
||||
@@ -61,9 +73,16 @@ class BookController extends BaseController {
|
||||
$_SESSION['cover_error'] = $cover_error;
|
||||
}
|
||||
}
|
||||
|
||||
$_SESSION['success'] = "Книга успешно создана" . ($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'] = "Ошибка при создании книги";
|
||||
}
|
||||
@@ -91,18 +110,73 @@ class BookController extends BaseController {
|
||||
$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 {
|
||||
} 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'] ?? ''),
|
||||
@@ -110,7 +184,9 @@ class BookController extends BaseController {
|
||||
'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
|
||||
'published' => isset($_POST['published']) ? 1 : 0,
|
||||
'is_short_story' => $is_short_story,
|
||||
'content' => $content
|
||||
];
|
||||
|
||||
// Обработка обложки
|
||||
@@ -252,7 +328,15 @@ class BookController extends BaseController {
|
||||
$this->render('errors/404');
|
||||
return;
|
||||
}
|
||||
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
||||
|
||||
// Если это рассказ - не загружаем главы
|
||||
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 = ?");
|
||||
@@ -263,20 +347,32 @@ class BookController extends BaseController {
|
||||
'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->findById($id);
|
||||
$book = $bookModel->findByUserBook($id, $user_id);
|
||||
if (!$book) {
|
||||
http_response_code(404);
|
||||
$this->render('errors/404');
|
||||
return;
|
||||
}
|
||||
$chapters = $chapterModel->findByBook($book['id']);
|
||||
|
||||
// Если это рассказ - не загружаем главы
|
||||
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 = ?");
|
||||
@@ -287,6 +383,7 @@ class BookController extends BaseController {
|
||||
'book' => $book,
|
||||
'chapters' => $chapters,
|
||||
'author' => $author,
|
||||
'is_short_story' => $is_short_story,
|
||||
'page_title' => $book['title']
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -220,7 +220,40 @@ class ChapterController extends BaseController {
|
||||
]);
|
||||
}
|
||||
|
||||
// Добавьте эту функцию в начало файла
|
||||
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);
|
||||
|
||||
@@ -26,13 +26,19 @@ class ExportController extends BaseController {
|
||||
$this->redirect('/books');
|
||||
}
|
||||
|
||||
// Для автора - все главы
|
||||
$chapters = $chapterModel->findByBook($book_id);
|
||||
// Для автора - все главы или контент рассказа
|
||||
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);
|
||||
|
||||
$this->handleExport($book, $chapters, false, $author_name, $format, $is_short_story);
|
||||
}
|
||||
|
||||
public function exportShared($share_token, $format = 'pdf') {
|
||||
@@ -45,13 +51,19 @@ class ExportController extends BaseController {
|
||||
$this->redirect('/');
|
||||
}
|
||||
|
||||
// Для публичного доступа - только опубликованные главы
|
||||
$chapters = $chapterModel->getPublishedChapters($book['id']);
|
||||
// Для публичного доступа - только опубликованные главы или контент рассказа
|
||||
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);
|
||||
|
||||
$this->handleExport($book, $chapters, true, $author_name, $format, $is_short_story);
|
||||
}
|
||||
|
||||
private function getAuthorName($user_id) {
|
||||
@@ -68,21 +80,20 @@ class ExportController extends BaseController {
|
||||
return "Неизвестный автор";
|
||||
}
|
||||
|
||||
private function handleExport($book, $chapters, $is_public, $author_name, $format) {
|
||||
|
||||
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);
|
||||
$this->exportPDF($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||
break;
|
||||
case 'docx':
|
||||
$this->exportDOCX($book, $chapters, $is_public, $author_name);
|
||||
$this->exportDOCX($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||
break;
|
||||
case 'html':
|
||||
$this->exportHTML($book, $chapters, $is_public, $author_name);
|
||||
$this->exportHTML($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||
break;
|
||||
case 'txt':
|
||||
$this->exportTXT($book, $chapters, $is_public, $author_name);
|
||||
$this->exportTXT($book, $chapters, $is_public, $author_name, $is_short_story);
|
||||
break;
|
||||
default:
|
||||
$_SESSION['error'] = "Неверный формат экспорта";
|
||||
@@ -93,8 +104,7 @@ class ExportController extends BaseController {
|
||||
}
|
||||
}
|
||||
|
||||
function exportPDF($book, $chapters, $is_public, $author_name) {
|
||||
|
||||
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);
|
||||
|
||||
@@ -157,8 +167,17 @@ class ExportController extends BaseController {
|
||||
$pdf->Ln(10);
|
||||
}
|
||||
|
||||
// Интерактивное оглавление
|
||||
$chapterLinks = [];
|
||||
// Если это рассказ - сразу выводим контент
|
||||
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');
|
||||
@@ -220,7 +239,7 @@ class ExportController extends BaseController {
|
||||
exit;
|
||||
}
|
||||
|
||||
function exportDOCX($book, $chapters, $is_public, $author_name) {
|
||||
function exportDOCX($book, $chapters, $is_public, $author_name, $is_short_story = false) {
|
||||
|
||||
$phpWord = new PhpWord();
|
||||
|
||||
@@ -271,8 +290,19 @@ class ExportController extends BaseController {
|
||||
$section->addTextBreak(2);
|
||||
}
|
||||
|
||||
// Интерактивное оглавление
|
||||
if (!empty($chapters)) {
|
||||
// Если это рассказ - сразу выводим контент
|
||||
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);
|
||||
|
||||
@@ -286,10 +316,13 @@ class ExportController extends BaseController {
|
||||
}
|
||||
|
||||
// Разделитель
|
||||
$section->addPageBreak();
|
||||
if (!$is_short_story) {
|
||||
$section->addPageBreak();
|
||||
}
|
||||
|
||||
// Главы с закладками
|
||||
foreach ($chapters as $index => $chapter) {
|
||||
// Главы с закладками (только для обычных книг)
|
||||
if (!$is_short_story) {
|
||||
foreach ($chapters as $index => $chapter) {
|
||||
// Добавляем закладку для главы
|
||||
$section->addBookmark("chapter_{$chapter['id']}");
|
||||
|
||||
@@ -332,7 +365,7 @@ class ExportController extends BaseController {
|
||||
exit;
|
||||
}
|
||||
|
||||
function exportHTML($book, $chapters, $is_public, $author_name) {
|
||||
function exportHTML($book, $chapters, $is_public, $author_name, $is_short_story = false) {
|
||||
|
||||
$html = '<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
@@ -514,8 +547,13 @@ class ExportController extends BaseController {
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
// Интерактивное оглавление
|
||||
if (!empty($chapters)) {
|
||||
// Если это рассказ - выводим контент сразу
|
||||
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>';
|
||||
@@ -554,68 +592,143 @@ class ExportController extends BaseController {
|
||||
exit;
|
||||
}
|
||||
|
||||
function exportTXT($book, $chapters, $is_public, $author_name) {
|
||||
$content = "=" . str_repeat("=", 80) . "=\n";
|
||||
$content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n";
|
||||
$content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n";
|
||||
$content .= "=" . str_repeat("=", 80) . "=\n\n";
|
||||
|
||||
if (!empty($book['genre'])) {
|
||||
$content .= "Жанр: " . $book['genre'] . "\n\n";
|
||||
}
|
||||
|
||||
if (!empty($book['description'])) {
|
||||
$content .= "ОПИСАНИЕ:\n";
|
||||
|
||||
// Обрабатываем описание
|
||||
$descriptionText = strip_tags($book['description']);
|
||||
$content .= wordwrap($descriptionText, 144) . "\n\n";
|
||||
}
|
||||
|
||||
// Оглавление
|
||||
if (!empty($chapters)) {
|
||||
$content .= "ОГЛАВЛЕНИЕ:\n";
|
||||
$content .= str_repeat("-", 60) . "\n";
|
||||
foreach ($chapters as $index => $chapter) {
|
||||
$chapter_number = $index + 1;
|
||||
$content .= "{$chapter_number}. {$chapter['title']}\n";
|
||||
}
|
||||
$content .= "\n";
|
||||
}
|
||||
|
||||
$content .= str_repeat("-", 144) . "\n\n";
|
||||
|
||||
foreach ($chapters as $index => $chapter) {
|
||||
$content .= $chapter['title'] . "\n";
|
||||
$content .= str_repeat("-", 60) . "\n\n";
|
||||
|
||||
// Получаем очищенный текст
|
||||
$cleanContent = strip_tags($chapter['content']);
|
||||
$paragraphs = $this->htmlToPlainTextParagraphs($cleanContent);
|
||||
|
||||
foreach ($paragraphs as $paragraph) {
|
||||
if (!empty(trim($paragraph))) {
|
||||
$content .= wordwrap($paragraph, 144) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
if ($index < count($chapters) - 1) {
|
||||
$content .= str_repeat("-", 144) . "\n\n";
|
||||
}
|
||||
}
|
||||
|
||||
$content .= "\n" . str_repeat("=", 144) . "\n";
|
||||
$content .= "Экспортировано из " . APP_NAME . " - " . date('d.m.Y H:i') . "\n";
|
||||
$content .= "Автор: " . $author_name . " | Всего глав: " . count($chapters) . " | Всего слов: " . array_sum(array_column($chapters, 'word_count')) . "\n";
|
||||
$content .= str_repeat("=", 144) . "\n";
|
||||
|
||||
$filename = cleanFilename($book['title']) . '.txt';
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="' . $filename . '"');
|
||||
echo $content;
|
||||
exit;
|
||||
}
|
||||
|
||||
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 теги и нормализуем пробелы
|
||||
|
||||
@@ -185,6 +185,7 @@ class SeriesController extends BaseController {
|
||||
'author' => $author,
|
||||
'total_words' => $total_words,
|
||||
'total_chapters' => $total_chapters,
|
||||
'book_model' => $bookModel,
|
||||
'page_title' => $series['title'] . ' — серия книг'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ class UserController extends BaseController {
|
||||
'total_books' => $total_books,
|
||||
'total_words' => $total_words,
|
||||
'total_chapters' => $total_chapters,
|
||||
'book_model'=>$bookModel,
|
||||
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
?>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
// index.php - единая точка входа
|
||||
require_once 'config/config.php';
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
// Получаем путь к запрашиваемому ресурсу
|
||||
$requestUri = $_SERVER['REQUEST_URI'];
|
||||
@@ -53,6 +53,7 @@ if (pathinfo($physicalPath, PATHINFO_EXTENSION) != 'php') {
|
||||
// Простой роутер
|
||||
class Router {
|
||||
private $routes = [];
|
||||
public $params = [];
|
||||
|
||||
public function add($pattern, $handler) {
|
||||
$this->routes[$pattern] = $handler;
|
||||
@@ -133,6 +134,7 @@ $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');
|
||||
|
||||
@@ -61,6 +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,
|
||||
`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`),
|
||||
@@ -198,32 +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('CONTROLLERS_PATH', __DIR__ . '/../controllers/');
|
||||
define('VIEWS_PATH', __DIR__ . '/../views/');
|
||||
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
|
||||
|
||||
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/');
|
||||
@@ -237,25 +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("Ошибка подключения к базе данных");
|
||||
}
|
||||
|
||||
// Добавляем константы для новых путей
|
||||
define('CONTROLLERS_PATH', __DIR__ . '/../public/controllers/');
|
||||
define('VIEWS_PATH', __DIR__ . '/../public/views/');
|
||||
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
|
||||
|
||||
|
||||
// Автозагрузка моделей
|
||||
spl_autoload_register(function (\$class_name) {
|
||||
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
|
||||
if (file_exists(\$model_file)) {
|
||||
require_once \$model_file;
|
||||
// Автозагрузка контроллеров
|
||||
spl_autoload_register(function ($class_name) {
|
||||
$controller_file = CONTROLLERS_PATH . $class_name . '.php';
|
||||
if (file_exists($controller_file)) {
|
||||
require_once $controller_file;
|
||||
}
|
||||
});
|
||||
?>
|
||||
EOT;
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -12,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 = ?");
|
||||
@@ -23,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 = ?
|
||||
@@ -40,10 +49,12 @@ class Book {
|
||||
public function create($data) {
|
||||
$share_token = bin2hex(random_bytes(16));
|
||||
$published = isset($data['published']) ? (int)$data['published'] : 0;
|
||||
|
||||
$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)
|
||||
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'],
|
||||
@@ -53,12 +64,16 @@ class Book {
|
||||
$data['series_id'] ?? null,
|
||||
$data['sort_order_in_series'] ?? null,
|
||||
$share_token,
|
||||
$published
|
||||
$published,
|
||||
$is_short_story,
|
||||
$content
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function update($id, $data) {
|
||||
$published = isset($data['published']) ? (int)$data['published'] : 0;
|
||||
$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;
|
||||
@@ -66,7 +81,7 @@ class Book {
|
||||
|
||||
$stmt = $this->pdo->prepare("
|
||||
UPDATE books
|
||||
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?
|
||||
SET title = ?, description = ?, genre = ?, series_id = ?, sort_order_in_series = ?, published = ?, is_short_story = ?, content = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
return $stmt->execute([
|
||||
@@ -76,6 +91,8 @@ class Book {
|
||||
$series_id, // Теперь это либо integer, либо NULL
|
||||
$sort_order_in_series, // Теперь это либо integer, либо NULL
|
||||
$published,
|
||||
$is_short_story,
|
||||
$content,
|
||||
$id,
|
||||
$data['user_id']
|
||||
]);
|
||||
@@ -195,17 +212,17 @@ class Book {
|
||||
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
|
||||
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
|
||||
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);
|
||||
|
||||
@@ -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'
|
||||
]);
|
||||
}
|
||||
@@ -108,21 +106,24 @@ class Chapter {
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
// private function getAllChapters($book_id) {
|
||||
// $stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?");
|
||||
// $stmt->execute([$book_id]);
|
||||
// return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
// }
|
||||
|
||||
// private function updateChapterContent($chapter_id, $content) {
|
||||
// $word_count = $this->countWords($content);
|
||||
// $stmt = $this->pdo->prepare("
|
||||
// UPDATE chapters
|
||||
// SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP
|
||||
// WHERE id = ?
|
||||
// ");
|
||||
// return $stmt->execute([$content, $word_count, $chapter_id]);
|
||||
// }
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
?>
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
?>
|
||||
|
Before Width: | Height: | Size: 82 KiB |
@@ -1,10 +1,15 @@
|
||||
<?php include 'views/layouts/header.php'; ?>
|
||||
|
||||
<div class="container">
|
||||
<h1>Добавление пользователя</h1>
|
||||
|
||||
<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-error">
|
||||
<div class="alert alert-danger">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -14,75 +19,87 @@
|
||||
<?= 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 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 style="margin-bottom: 1rem;">
|
||||
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="<?= e($_POST['display_name'] ?? '') ?>"
|
||||
placeholder="Введите отображаемое имя"
|
||||
style="width: 100%;">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Email
|
||||
</label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="<?= e($_POST['email'] ?? '') ?>"
|
||||
placeholder="Введите email"
|
||||
style="width: 100%;">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Пароль *
|
||||
</label>
|
||||
<input type="password" id="password" name="password"
|
||||
placeholder="Введите пароль (минимум 6 символов)"
|
||||
style="width: 100%;"
|
||||
required
|
||||
minlength="6">
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="password_confirm" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Подтверждение пароля *
|
||||
</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm"
|
||||
placeholder="Повторите пароль"
|
||||
style="width: 100%;"
|
||||
required
|
||||
minlength="6">
|
||||
</div>
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="is_active">
|
||||
<input type="checkbox" id="is_active" name="is_active" value="1"
|
||||
<?= isset($_POST['is_active']) ? 'checked' : 'checked' ?>>
|
||||
Активировать пользователя сразу
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button type="submit" class="contrast" style="flex: 1;">
|
||||
👥 Добавить пользователя
|
||||
</button>
|
||||
<a href="<?= SITE_URL ?>/admin/users" class="secondary" style="display: flex; align-items: center; justify-content: center; padding: 0.75rem; text-decoration: none;">
|
||||
❌ Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
@@ -1,96 +1,205 @@
|
||||
<?php include 'views/layouts/header.php'; ?>
|
||||
|
||||
<div class="container" style="margin:0; width: auto;">
|
||||
<h1>Управление пользователями</h1>
|
||||
|
||||
<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">
|
||||
<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-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 style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
||||
<h2 style="margin: 0;">Всего пользователей: <?= count($users) ?></h2>
|
||||
<a href="<?= SITE_URL ?>/admin/add-user" class="action-button primary">➕ Добавить пользователя</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($users)): ?>
|
||||
<article style="text-align: center; padding: 2rem;">
|
||||
<h3>Пользователи не найдены</h3>
|
||||
<p>Зарегистрируйте первого пользователя</p>
|
||||
<a href="<?= SITE_URL ?>/admin/add-user" role="button">📝 Добавить пользователя</a>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x: auto; width:100%;">
|
||||
<table class="compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%;">ID</th>
|
||||
<th style="width: 15%;">Имя пользователя</th>
|
||||
<th style="width: 20%;">Отображаемое имя</th>
|
||||
<th style="width: 20%;">Email</th>
|
||||
<th style="width: 15%;">Дата регистрации</th>
|
||||
<th style="width: 10%;">Статус</th>
|
||||
<th style="width: 15%;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $user): ?>
|
||||
<tr>
|
||||
<td><?= $user['id'] ?></td>
|
||||
<td>
|
||||
<strong><a href="<?= SITE_URL ?>/author/<?= $user['id'] ?>"><?= e($user['username']) ?></a></strong>
|
||||
<?php if ($user['id'] == $_SESSION['user_id']): ?>
|
||||
<br><small style="color: #666;">(Вы)</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= e($user['display_name']) ?></td>
|
||||
<td><?= e($user['email']) ?></td>
|
||||
<td>
|
||||
<small><?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></small>
|
||||
<?php if ($user['last_login']): ?>
|
||||
<br><small style="color: #666;">Вход: <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span style="color: <?= $user['is_active'] ? 'green' : 'red' ?>">
|
||||
<?= $user['is_active'] ? '✅ Активен' : '❌ Неактивен' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($user['id'] != $_SESSION['user_id']): ?>
|
||||
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
|
||||
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/toggle-status" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button secondary" title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
|
||||
<?= $user['is_active'] ? '⏸️' : '▶️' ?>
|
||||
</button>
|
||||
</form>
|
||||
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить">
|
||||
🗑️
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<small style="color: #666;">Текущий пользователь</small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Выбор количества элементов на странице -->
|
||||
<div 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">«</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">»</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'; ?>
|
||||
@@ -4,45 +4,50 @@ include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<h1>Вход в систему</h1>
|
||||
<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-error">
|
||||
<?= e($error) ?>
|
||||
<?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>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" style="max-width: 400px; margin: 0 auto;">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input type="text" id="username" name="username"
|
||||
value="<?= e($_POST['username'] ?? '') ?>"
|
||||
placeholder="Введите имя пользователя"
|
||||
style="width: 100%;"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Пароль
|
||||
</label>
|
||||
<input type="password" id="password" name="password"
|
||||
placeholder="Введите пароль"
|
||||
style="width: 100%;"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="contrast" style="width: 100%;">
|
||||
🔑 Войти
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<p>Нет аккаунта? <a href="<?= SITE_URL ?>/register">Зарегистрируйтесь здесь</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,84 +1,90 @@
|
||||
// views/auth/register.php
|
||||
<?php
|
||||
// views/auth/register.php
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<h1>Регистрация</h1>
|
||||
<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-error">
|
||||
<?= e($error) ?>
|
||||
<?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>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($success) && $success): ?>
|
||||
<div class="alert alert-success">
|
||||
<?= e($success) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" style="max-width: 400px; margin: 0 auto;">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Имя пользователя *
|
||||
</label>
|
||||
<input type="text" id="username" name="username"
|
||||
value="<?= e($_POST['username'] ?? '') ?>"
|
||||
placeholder="Введите имя пользователя"
|
||||
style="width: 100%;"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Отображаемое имя
|
||||
</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="<?= e($_POST['display_name'] ?? '') ?>"
|
||||
placeholder="Как вас будут видеть другие"
|
||||
style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Email
|
||||
</label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="<?= e($_POST['email'] ?? '') ?>"
|
||||
placeholder="email@example.com"
|
||||
style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Пароль *
|
||||
</label>
|
||||
<input type="password" id="password" name="password"
|
||||
placeholder="Не менее 6 символов"
|
||||
style="width: 100%;"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="password_confirm" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Подтверждение пароля *
|
||||
</label>
|
||||
<input type="password" id="password_confirm" name="password_confirm"
|
||||
placeholder="Повторите пароль"
|
||||
style="width: 100%;"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="contrast" style="width: 100%;">
|
||||
📝 Зарегистрироваться
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<p>Уже есть аккаунт? <a href="<?= SITE_URL ?>/login">Войдите здесь</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,86 +2,164 @@
|
||||
// views/books/create.php
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
<h1>Создание новой книги</h1>
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($_SESSION['error']) ?>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($cover_error) && $cover_error): ?>
|
||||
<div class="alert alert-error">
|
||||
Ошибка загрузки обложки: <?= e($cover_error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<div style="max-width: 100%; margin-bottom: 0.5rem;">
|
||||
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Название книги *
|
||||
</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="<?= e($_POST['title'] ?? '') ?>"
|
||||
placeholder="Введите название книги"
|
||||
style="width: 100%; margin-bottom: 1.5rem;"
|
||||
required>
|
||||
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Жанр
|
||||
</label>
|
||||
<input type="text" id="genre" name="genre"
|
||||
value="<?= e($_POST['genre'] ?? '') ?>"
|
||||
placeholder="Например: Фантастика, Роман, Детектив..."
|
||||
style="width: 100%; margin-bottom: 1.5rem;">
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Создание новой книги</h1>
|
||||
<a href="<?= SITE_URL ?>/books" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к книгам
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Серия
|
||||
</label>
|
||||
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
|
||||
<option value="">-- Без серии --</option>
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<option value="<?= $ser['id'] ?>" <?= (($_POST['series_id'] ?? '') == $ser['id']) ? 'selected' : '' ?>>
|
||||
<?= e($ser['title']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Описание книги
|
||||
</label>
|
||||
<textarea id="description" name="description"
|
||||
placeholder="Краткое описание сюжета или аннотация..."
|
||||
rows="6"
|
||||
style="width: 100;"><?= e($_POST['description'] ?? '') ?></textarea>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Обложка книги
|
||||
</label>
|
||||
<input type="file" id="cover_image" name="cover_image"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp">
|
||||
<small style="color: var(--muted-color);">
|
||||
Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
|
||||
</small>
|
||||
</div>
|
||||
<div style="margin-top: 1rem;">
|
||||
<label for="published">
|
||||
<input type="checkbox" id="published" name="published" value="1"
|
||||
<?= (!empty($_POST['published']) && $_POST['published']) ? 'checked' : '' ?>>
|
||||
Опубликовать книгу (показывать на публичной странице автора)
|
||||
</label>
|
||||
<?php if (isset($_SESSION['error'])): ?>
|
||||
<div class="alert alert-danger">
|
||||
<?= e($_SESSION['error']) ?>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-danger">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($cover_error) && $cover_error): ?>
|
||||
<div class="alert alert-danger">
|
||||
Ошибка загрузки обложки: <?= e($cover_error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data" id="book-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Название книги *</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
value="<?= e($_POST['title'] ?? '') ?>"
|
||||
placeholder="Введите название книги" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="genre" class="form-label">Жанр</label>
|
||||
<input type="text" class="form-control" id="genre" name="genre"
|
||||
value="<?= e($_POST['genre'] ?? '') ?>"
|
||||
placeholder="Например: Фантастика, Роман, Детектив...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="series_id" class="form-label">Серия</label>
|
||||
<select class="form-select" id="series_id" name="series_id">
|
||||
<option value="">-- Без серии --</option>
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<option value="<?= $ser['id'] ?>" <?= (($_POST['series_id'] ?? '') == $ser['id']) ? 'selected' : '' ?>>
|
||||
<?= e($ser['title']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Описание книги</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
placeholder="Краткое описание сюжета или аннотация..."
|
||||
rows="4"><?= e($_POST['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cover_image" class="form-label">Обложка книги</label>
|
||||
<input type="file" class="form-control" id="cover_image" name="cover_image"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp">
|
||||
<div class="form-text">
|
||||
Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="published" name="published" value="1"
|
||||
<?= (!empty($_POST['published']) && $_POST['published']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="published">
|
||||
Опубликовать книгу (показывать на публичной странице автора)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="is_short_story" name="is_short_story" value="1"
|
||||
<?= (!empty($_POST['is_short_story']) && $_POST['is_short_story']) ? 'checked' : '' ?> onchange="toggleStoryEditor()">
|
||||
<label class="form-check-label" for="is_short_story">
|
||||
Это рассказ (без разбивки на главы)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4" id="story-editor-card" style="display: none;">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Содержание рассказа</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="quill-editor" style="height: 400px;" data-content="<?= htmlspecialchars($_POST['content'] ?? '', ENT_QUOTES) ?>">
|
||||
<?= $_POST['content'] ?? '' ?>
|
||||
</div>
|
||||
<input type="hidden" name="content" id="content" <?= !empty($_POST['content']) ? 'value="' . htmlspecialchars($_POST['content'], ENT_QUOTES) . '"' : '' ?>>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-journal-plus"></i> Создать книгу
|
||||
</button>
|
||||
<a href="<?= SITE_URL ?>/books" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||
<button type="submit" class="contrast">
|
||||
📖 Создать книгу
|
||||
</button>
|
||||
<a href="<?= SITE_URL ?>/books" role="button" class="secondary">
|
||||
❌ Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script src="<?= SITE_URL ?>/assets/js/editor.js"></script>
|
||||
<script src="<?= SITE_URL ?>/assets/js/autosave.js"></script>
|
||||
<script>
|
||||
function toggleStoryEditor() {
|
||||
var isShortStory = document.getElementById('is_short_story').checked;
|
||||
var storyCard = document.getElementById('story-editor-card');
|
||||
|
||||
if (storyCard) {
|
||||
storyCard.style.display = isShortStory ? 'block' : 'none';
|
||||
|
||||
if (isShortStory && !window.writerEditor) {
|
||||
window.writerEditor = new WriterEditor('#book-form', 'quill-editor', 'content');
|
||||
} else if (!isShortStory && window.writerEditor && window.writerEditor.quill) {
|
||||
try { window.writerEditor.quill.destroy(); } catch(e) {}
|
||||
window.writerEditor = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
toggleStoryEditor();
|
||||
|
||||
window.addEventListener('resize', function() {
|
||||
var isShortStory = document.getElementById('is_short_story').checked;
|
||||
if (isShortStory && window.writerEditor && window.writerEditor.quill) {
|
||||
window.writerEditor.quill.root.style.height = '400px';
|
||||
window.writerEditor.quill.container.style.height = '400px';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
@@ -2,229 +2,211 @@
|
||||
// views/books/edit.php
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
<?php if (isset($_SESSION['cover_error'])): ?>
|
||||
<div class="alert alert-error">
|
||||
Ошибка загрузки обложки: <?= e($_SESSION['cover_error']) ?>
|
||||
<?php unset($_SESSION['cover_error']); ?>
|
||||
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Редактирование книги</h1>
|
||||
<a href="<?= SITE_URL ?>/books" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к книгам
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<h1>Редактирование книги</h1>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<div style="max-width: 100%; margin-bottom: 0.5rem;">
|
||||
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Название книги *
|
||||
</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="<?= e($book['title'] ?? '') ?>"
|
||||
placeholder="Введите название книги"
|
||||
style="width: 100%; margin-bottom: 1.5rem;"
|
||||
required>
|
||||
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Жанр
|
||||
</label>
|
||||
<input type="text" id="genre" name="genre"
|
||||
value="<?= e($book['genre'] ?? '') ?>"
|
||||
placeholder="Например: Фантастика, Роман, Детектив..."
|
||||
style="width: 100%; margin-bottom: 1.5rem;">
|
||||
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Серия
|
||||
</label>
|
||||
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
|
||||
<option value="">-- Без серии --</option>
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<option value="<?= $ser['id'] ?>" <?= ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '' ?>>
|
||||
<?= e($ser['title']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<label for="sort_order_in_series" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Порядок в серии
|
||||
</label>
|
||||
<input type="number" id="sort_order_in_series" name="sort_order_in_series"
|
||||
value="<?= e($book['sort_order_in_series'] ?? '') ?>"
|
||||
placeholder="Номер по порядку в серии"
|
||||
min="1"
|
||||
style="width: 100%; margin-bottom: 1.5rem;">
|
||||
<!-- Обложка -->
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Обложка книги
|
||||
</label>
|
||||
<?php if (!empty($book['cover_image'])): ?>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<p><strong>Текущая обложка:</strong></p>
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="Обложка"
|
||||
style="max-width: 200px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);">
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<label style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="checkbox" name="delete_cover" value="1">
|
||||
Удалить обложку
|
||||
</label>
|
||||
|
||||
<?php if (isset($_SESSION['cover_error'])): ?>
|
||||
<div class="alert alert-danger">
|
||||
Ошибка загрузки обложки: <?= e($_SESSION['cover_error']) ?>
|
||||
<?php unset($_SESSION['cover_error']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" enctype="multipart/form-data" id="book-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<!-- Аккордеон для основной информации (свернут) -->
|
||||
<div class="accordion mb-4" id="bookInfoAccordion">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#bookInfoCollapse">
|
||||
<i class="bi bi-info-circle me-2"></i> Основная информация
|
||||
</button>
|
||||
</h2>
|
||||
<div id="bookInfoCollapse" class="accordion-collapse collapse" data-bs-parent="#bookInfoAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Название книги *</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
value="<?= e($book['title'] ?? '') ?>"
|
||||
placeholder="Введите название книги" required>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="genre" class="form-label">Жанр</label>
|
||||
<input type="text" class="form-control" id="genre" name="genre"
|
||||
value="<?= e($book['genre'] ?? '') ?>"
|
||||
placeholder="Например: Фантастика, Роман, Детектив...">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="mb-3">
|
||||
<label for="series_id" class="form-label">Серия</label>
|
||||
<select class="form-select" id="series_id" name="series_id">
|
||||
<option value="">-- Без серии --</option>
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<option value="<?= $ser['id'] ?>" <?= ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '' ?>>
|
||||
<?= e($ser['title']) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="sort_order_in_series" class="form-label">Порядок в серии</label>
|
||||
<input type="number" class="form-control" id="sort_order_in_series" name="sort_order_in_series"
|
||||
value="<?= e($book['sort_order_in_series'] ?? '') ?>"
|
||||
placeholder="Номер по порядку в серии" min="1">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Описание книги</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
placeholder="Краткое описание сюжета или аннотация..."
|
||||
rows="4"><?= e($book['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cover_image" class="form-label">Обложка книги</label>
|
||||
<?php if (!empty($book['cover_image'])): ?>
|
||||
<div class="mb-2">
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="Обложка"
|
||||
class="img-thumbnail"
|
||||
style="max-height: 150px;">
|
||||
<div class="form-text">Текущая обложка</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" class="form-control" id="cover_image" name="cover_image"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp">
|
||||
<div class="form-text">
|
||||
Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB. Оставьте пустым, чтобы оставить текущую обложку.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="published" name="published" value="1"
|
||||
<?= !empty($book['published']) ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="published">
|
||||
Опубликовать книгу (показывать на публичной странице автора)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="is_short_story" name="is_short_story" value="1"
|
||||
<?= !empty($book['is_short_story']) ? 'checked' : '' ?> onchange="toggleStoryEditor()">
|
||||
<label class="form-check-label" for="is_short_story">
|
||||
Это рассказ (без разбивки на главы)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="file" id="cover_image" name="cover_image"
|
||||
accept="image/jpeg, image/png, image/gif, image/webp"
|
||||
style="height: 2.6rem;">
|
||||
<small style="color: var(--muted-color);">
|
||||
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 5MB.
|
||||
Рекомендуемый размер: 300×450 пикселей.
|
||||
</small>
|
||||
<?php if (!empty($cover_error)): ?>
|
||||
<div style="color: #d32f2f; margin-top: 0.5rem;">
|
||||
❌ <?= e($cover_error) ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Редактор рассказа (всегда видимый) -->
|
||||
<div class="mb-4" id="story-editor-card" style="display: <?= !empty($book['is_short_story']) ? 'block' : 'none' ?>">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success bg-opacity-10">
|
||||
<h5 class="card-title mb-0 text-success">
|
||||
<i class="bi bi-book me-2"></i>Содержание рассказа
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="quill-editor" style="height: 500px;" data-content="<?= htmlspecialchars($book['content'] ?? '', ENT_QUOTES) ?>"></div>
|
||||
<textarea name="content" id="content" style="display: none;"><?= htmlspecialchars($book['content'] ?? '', ENT_QUOTES) ?></textarea>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Описание книги
|
||||
</label>
|
||||
<textarea id="description" name="description"
|
||||
placeholder="Краткое описание сюжета или аннотация..."
|
||||
rows="6"
|
||||
style="width: 100%;"><?= e($book['description'] ?? '') ?></textarea>
|
||||
<div style="margin-top: 1rem;">
|
||||
<label for="published">
|
||||
<input type="checkbox" id="published" name="published" value="1"
|
||||
<?= !empty($book['published']) ? 'checked' : '' ?>>
|
||||
Опубликовать книгу (показывать на публичной странице автора)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||
<button type="submit" class="contrast">
|
||||
💾 Сохранить изменения
|
||||
</button>
|
||||
<!-- <a href="<?= SITE_URL ?>/books" role="button" class="secondary">
|
||||
❌ Отмена
|
||||
</a> -->
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if ($book): ?>
|
||||
<div style="margin-top: 0.5rem; padding: 0rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>Публичная ссылка для чтения</h3>
|
||||
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;">
|
||||
<input type="text"
|
||||
id="share-link"
|
||||
value="<?= e(SITE_URL . '/book/' . $book['share_token']) ?>"
|
||||
readonly
|
||||
style="flex: 1; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: white;">
|
||||
<button type="button" onclick="copyShareLink()" class="adaptive-button">
|
||||
📋 Копировать
|
||||
</button>
|
||||
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/regenerate-token" style="display: inline;">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="adaptive-button secondary" onclick="return confirm('Создать новую ссылку? Старая ссылка перестанет работать.')">
|
||||
🔄 Обновить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<p style="margin-top: 0.5rem; font-size: 0.8em; color: var(--muted-color);">
|
||||
<strong>Примечание:</strong> В публичном просмотре отображаются только главы со статусом "Опубликована"
|
||||
</p>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; padding: 0rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>Экспорт книги</h3>
|
||||
<p style="margin-bottom: 0.5rem;">Экспортируйте книгу в различные форматы:</p>
|
||||
<div style="display: flex; gap: 5px; flex-wrap: wrap;">
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📄 PDF
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📝 DOCX
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
|
||||
🌐 HTML
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📄 TXT
|
||||
</a>
|
||||
</div>
|
||||
<p style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
|
||||
<strong>Примечание:</strong> Экспортируются все главы книги (включая черновики)
|
||||
</p>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<h2>Главы этой книги</h2>
|
||||
<div style="display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 1rem;">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="adaptive-button secondary" role="button">
|
||||
📑 Все главы
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary" role="button">
|
||||
✏️ Добавить главу
|
||||
</a>
|
||||
</div>
|
||||
<?php if (!empty($chapters)): ?>
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="text-align: left; padding: 12px 8px;">Название</th>
|
||||
<th style="text-align: left; padding: 12px 8px;">Статус</th>
|
||||
<th style="text-align: left; padding: 12px 8px;">Слов</th>
|
||||
<th style="text-align: left; padding: 12px 8px;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($chapters as $chapter): ?>
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 12px 8px;"><?= e($chapter['title']) ?></td>
|
||||
<td style="padding: 12px 8px;">
|
||||
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>">
|
||||
<?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td style="padding: 12px 8px;"><?= $chapter['word_count'] ?></td>
|
||||
<td style="padding: 12px 8px;">
|
||||
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary">
|
||||
Редактировать
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="text-align: center; padding: 0.5rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<p style="margin-bottom: 1rem;">В этой книге пока нет глав.</p>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary" role="button">
|
||||
✏️ Добавить первую главу
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Главы книги (всегда видимые) -->
|
||||
<div class="mb-4" id="chapters-card" style="display: <?= !empty($book['is_short_story']) ? 'none' : 'block' ?>">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Главы книги</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($chapters)): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Статус</th>
|
||||
<th>Слов</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($chapters as $chapter): ?>
|
||||
<tr>
|
||||
<td><?= e($chapter['title']) ?></td>
|
||||
<td>
|
||||
<span class="badge <?= $chapter['status'] == 'published' ? 'bg-success' : 'bg-warning' ?>">
|
||||
<?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= $chapter['word_count'] ?></td>
|
||||
<td>
|
||||
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Редактировать
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-file-text fs-1 text-muted"></i>
|
||||
<p class="text-muted mt-2">В этой книге пока нет глав</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-primary">
|
||||
<i class="bi bi-list-ul"></i> Все главы
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Добавить главу
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; text-align: center;">
|
||||
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="adaptive-button red-btn">
|
||||
🗑️ Удалить книгу
|
||||
</div>
|
||||
|
||||
<!-- Кнопка сохранения -->
|
||||
<div class="mb-4">
|
||||
<button type="submit" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-check-circle"></i> Сохранить изменения
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
<script src="<?= SITE_URL ?>/assets/js/editor.js"></script>
|
||||
<script src="<?= SITE_URL ?>/assets/js/autosave.js"></script>
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Копирование ссылки для чтения
|
||||
window.copyShareLink = function() {
|
||||
const shareLink = document.getElementById('share-link');
|
||||
shareLink.select();
|
||||
document.execCommand('copy');
|
||||
const button = event.target;
|
||||
const originalText = '📋 Копировать';
|
||||
button.textContent = '✅ Скопировано';
|
||||
setTimeout(() => {
|
||||
button.textContent = originalText;
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const checkbox = document.getElementById('is_short_story');
|
||||
if (checkbox && checkbox.checked) {
|
||||
toggleStoryEditor();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
@@ -3,120 +3,175 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Мои книги <small style="color: #ccc; font-size:1rem;">(Всего книг: <?= count($books) ?>)</small></h1>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h2">Мои книги</h1>
|
||||
<p class="text-muted mb-0">Всего книг: <?= count($books) ?></p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="<?= SITE_URL ?>/books/create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Новая книга
|
||||
</a>
|
||||
<?php if (!empty($books)): ?>
|
||||
<button type="button" onclick="showDeleteAllConfirmation()" class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash"></i> Удалить все
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (empty($books)): ?>
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-journal-bookmark fs-1 text-muted"></i>
|
||||
</div>
|
||||
<h3 class="h4 text-muted">У вас пока нет книг</h3>
|
||||
<p class="text-muted mb-4">Создайте свою первую книгу и начните писать!</p>
|
||||
<a href="<?= SITE_URL ?>/books/create" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-plus-circle"></i> Создать первую книгу
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-4">
|
||||
<?php foreach ($books as $book): ?>
|
||||
<div class="col-xl-4 col-lg-6">
|
||||
<div class="card h-100 book-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<?php if (!empty($book['cover_image'])): ?>
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
class="rounded me-3"
|
||||
style="width: 60px; height: 80px; object-fit: cover;"
|
||||
onerror="this.style.display='none'">
|
||||
<?php endif; ?>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h5 class="card-title mb-1">
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
<i class="bi bi-file-text text-success me-1"></i>
|
||||
<?php else: ?>
|
||||
<i class="bi bi-book text-primary me-1"></i>
|
||||
<?php endif; ?>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="text-decoration-none">
|
||||
<?= e($book['title']) ?>
|
||||
</a>
|
||||
</h5>
|
||||
<span class="badge <?= $book['published'] ? 'bg-success' : 'bg-warning' ?>">
|
||||
<?= $book['published'] ? 'Опубликована' : 'Черновик' ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php if (!empty($book['genre'])): ?>
|
||||
<p class="text-muted small mb-2"><?= e($book['genre']) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($book['description'])): ?>
|
||||
<p class="card-text text-muted small">
|
||||
<?= e(mb_strimwidth($book['description'], 0, 120, '...')) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row text-center mb-3">
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
<div class="col-6">
|
||||
<div class="border-end">
|
||||
<div class="fw-bold text-success"><?= number_format($book['total_words'] ?? 0) ?></div>
|
||||
<small class="text-muted">слов</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<small class="text-success">(рассказ)</small>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="col-4">
|
||||
<div class="border-end">
|
||||
<div class="fw-bold text-primary"><?= $book['chapter_count'] ?? 0 ?></div>
|
||||
<small class="text-muted">глав</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="border-end">
|
||||
<div class="fw-bold text-success"><?= number_format($book['total_words'] ?? 0) ?></div>
|
||||
<small class="text-muted">слов</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div>
|
||||
<div class="fw-bold text-info">
|
||||
<?= $book['chapter_count'] > 0 ? number_format(($book['total_words'] ?? 0) / $book['chapter_count']) : 0 ?>
|
||||
</div>
|
||||
<small class="text-muted">слов/глава</small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil"></i> Редактировать
|
||||
</a>
|
||||
<div class="btn-group" role="group">
|
||||
<?php if (empty($book['is_short_story'])): ?>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-file-text"></i> Главы
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="btn btn-outline-success btn-sm" target="_blank">
|
||||
<i class="bi bi-eye"></i> Просмотр
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: left; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<a href="<?= SITE_URL ?>/books/create" class="action-button primary" role="button">➕ Новая книга</a>
|
||||
<?php if (!empty($books)): ?>
|
||||
<a href="#" onclick="showDeleteAllConfirmation()" class="action-button delete">🗑️ Удалить все книги</a>
|
||||
<div class="mt-4 p-3 bg-light rounded">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<strong class="text-primary"><?= count($books) ?></strong>
|
||||
<small class="text-muted d-block">Книг</small>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<strong class="text-success"><?= array_sum(array_column($books, 'chapter_count')) ?></strong>
|
||||
<small class="text-muted d-block">Глав</small>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<strong class="text-warning"><?= number_format(array_sum(array_column($books, 'total_words'))) ?></strong>
|
||||
<small class="text-muted d-block">Слов</small>
|
||||
</div>
|
||||
<div class="col-md-3 col-6 mb-2">
|
||||
<strong class="text-info"><?= count(array_filter($books, function($book) { return $book['published']; })) ?></strong>
|
||||
<small class="text-muted d-block">Опубликовано</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (empty($books)): ?>
|
||||
<article style="text-align: center; padding: 2rem;">
|
||||
<h3>У вас пока нет книг</h3>
|
||||
<p>Создайте свою первую книгу и начните писать!</p>
|
||||
<a href="<?= SITE_URL ?>/books/create" role="button">📖 Создать первую книгу</a>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<div class="books-grid">
|
||||
<?php foreach ($books as $book): ?>
|
||||
<article class="book-card">
|
||||
<!-- Обложка книги -->
|
||||
<div class="book-cover-container">
|
||||
<?php if (!empty($book['cover_image'])): ?>
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
class="book-cover"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="cover-placeholder" style="display: none;">
|
||||
📚
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="cover-placeholder">
|
||||
📚
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Статус книги -->
|
||||
<div class="book-status <?= $book['published'] ? 'published' : 'draft' ?>">
|
||||
<?= $book['published'] ? '✅' : '📝' ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Информация о книге -->
|
||||
<div class="book-info">
|
||||
<h3 class="book-title">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit">
|
||||
<?= e($book['title']) ?>
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<?php if (!empty($book['genre'])): ?>
|
||||
<p class="book-genre"><?= e($book['genre']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($book['description'])): ?>
|
||||
<p class="book-description">
|
||||
<?= e(mb_strimwidth($book['description'], 0, 120, '...')) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="book-stats">
|
||||
<span class="stat-item">
|
||||
<strong><?= $book['chapter_count'] ?? 0 ?></strong> глав
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<strong><?= number_format($book['total_words'] ?? 0) ?></strong> слов
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Действия -->
|
||||
<div class="book-actions">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button primary-btn">
|
||||
✏️ Редактировать
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="compact-button secondary-btn">
|
||||
📑 Главы
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="compact-button green-btn" target="_blank">
|
||||
👁️ Просмотр
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- Статистика внизу -->
|
||||
<div class="books-stats-footer">
|
||||
<strong>Общая статистика:</strong>
|
||||
Книг: <?= count($books) ?> |
|
||||
Глав: <?= array_sum(array_column($books, 'chapter_count')) ?> |
|
||||
Слов: <?= number_format(array_sum(array_column($books, 'total_words'))) ?> |
|
||||
Опубликовано: <?= count(array_filter($books, function($book) { return $book['published']; })) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($books)): ?>
|
||||
<script>
|
||||
function showDeleteAllConfirmation() {
|
||||
if (confirm('Вы уверены, что хотите удалить ВСЕ книги? Это действие также удалит все главы и обложки книг. Действие нельзя отменить!')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '<?= SITE_URL ?>/books/delete-all';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= generate_csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
<script>
|
||||
function showDeleteAllConfirmation() {
|
||||
if (confirm('Вы уверены, что хотите удалить ВСЕ книги? Это действие также удалит все главы и обложки книг. Действие нельзя отменить!')) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '<?= SITE_URL ?>/books/delete-all';
|
||||
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = '<?= generate_csrf_token() ?>';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
</script>
|
||||
}
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
@@ -3,159 +3,198 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div class="container" style="padding: 0em; margin: 0em auto; width: 90%;">
|
||||
<article style="margin: 0 auto;">
|
||||
<header style="text-align: center; margin-bottom: 2rem;">
|
||||
<?php if (!empty($book['cover_image'])): ?>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
style="max-width: 200px; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h1 style="margin-bottom: 0.5rem;"><?= e($book['title']) ?></h1>
|
||||
|
||||
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;">
|
||||
Автор: <a href="<?= SITE_URL ?>/author/<?= $book['user_id'] ?>"><?= e($author['display_name']??$author['username']) ?></a>
|
||||
</p>
|
||||
|
||||
<?php if (!empty($book['genre'])): ?>
|
||||
<p style="color: #666; font-style: italic; margin-bottom: 1rem;">
|
||||
<?= e($book['genre']) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($book['description'])): ?>
|
||||
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
|
||||
<?= nl2br(e($book['description'])) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display: block; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
|
||||
<span>Глав: <?= count($chapters) ?></span>
|
||||
<span>Слов: <?= array_sum(array_column($chapters, 'word_count')) ?></span>
|
||||
<p>
|
||||
<?php if (!is_logged_in()): ?>
|
||||
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: center;">
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📄 PDF
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📝 DOCX
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
|
||||
🌐 HTML
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📄 TXT
|
||||
<div class="container py-4">
|
||||
<div class="row">
|
||||
<div class="col-lg-10">
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<header class="text-center mb-4">
|
||||
<?php if (!empty($book['cover_image'])): ?>
|
||||
<div class="mb-4">
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
class="img-fluid rounded shadow"
|
||||
style="max-height: 300px;"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h1 class="display-5 mb-2"><?= e($book['title']) ?></h1>
|
||||
|
||||
<p class="lead text-muted mb-2">
|
||||
Автор: <a href="<?= SITE_URL ?>/author/<?= $book['user_id'] ?>" class="text-decoration-none">
|
||||
<?= e($author['display_name']??$author['username']) ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<?php if (!empty($book['genre'])): ?>
|
||||
<p class="text-muted mb-3">
|
||||
<i class="bi bi-tags"></i> <?= e($book['genre']) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($book['description'])): ?>
|
||||
<div class="text-start bg-light p-4 rounded mb-4 ">
|
||||
<?= nl2br(e($book['description'])) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-center gap-4 flex-wrap mb-4">
|
||||
<?php if (!$is_short_story): ?>
|
||||
<div class="text-center">
|
||||
<div class="h4 text-primary mb-0"><?= count($chapters) ?></div>
|
||||
<small class="text-muted">Глав</small>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="text-center">
|
||||
<div class="h4 text-success mb-0">
|
||||
<?php if ($is_short_story && !empty($book['content'])): ?>
|
||||
<?= number_format(mb_strlen($book['content']) - mb_strlen(str_replace(' ', '', $book['content'])) + 1) ?>
|
||||
<?php elseif (!$is_short_story): ?>
|
||||
<?= array_sum(array_column($chapters, 'word_count')) ?>
|
||||
<?php else: ?>
|
||||
0
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<small class="text-muted">Слов</small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (is_logged_in()): ?>
|
||||
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: center;">
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📄 PDF
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📝 DOCX
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
|
||||
🌐 HTML
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
|
||||
📄 TXT
|
||||
</a>
|
||||
|
||||
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||
<?php if (!is_logged_in()): ?>
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/pdf" class="btn btn-outline-danger" target="_blank">
|
||||
<i class="bi bi-file-pdf"></i> PDF
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/docx" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="bi bi-file-word"></i> DOCX
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/html" class="btn btn-outline-success" target="_blank">
|
||||
<i class="bi bi-file-code"></i> HTML
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/txt" class="btn btn-outline-secondary" target="_blank">
|
||||
<i class="bi bi-file-text"></i> TXT
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="btn btn-outline-danger" target="_blank">
|
||||
<i class="bi bi-file-pdf"></i> PDF
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="btn btn-outline-primary" target="_blank">
|
||||
<i class="bi bi-file-word"></i> DOCX
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="btn btn-outline-success" target="_blank">
|
||||
<i class="bi bi-file-code"></i> HTML
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="btn btn-outline-secondary" target="_blank">
|
||||
<i class="bi bi-file-text"></i> TXT
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if ($is_short_story && !empty($book['content'])): ?>
|
||||
<div class="chapter-content mb-5">
|
||||
<div class="chapter-text" style="line-height: 1.8; font-size: 1.1em;">
|
||||
<?= $book['content'] ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php elseif (empty($chapters)): ?>
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-text fs-1 text-muted"></i>
|
||||
<h3 class="h4 text-muted mt-3">В этой книге пока нет глав</h3>
|
||||
<p class="text-muted">Автор еще не опубликовал содержание книги</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="mb-5">
|
||||
<h3 class="h4 text-center mb-4">
|
||||
<i class="bi bi-list-ul"></i> Оглавление
|
||||
</h3>
|
||||
<div class="list-group">
|
||||
<?php foreach ($chapters as $index => $chapter): ?>
|
||||
<a href="#chapter-<?= $chapter['id'] ?>" class="list-group-item list-group-item-action border-0">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<strong><?= e($chapter['title']) ?></strong>
|
||||
</span>
|
||||
<small class="text-muted"><?= $chapter['word_count'] ?> слов</small>
|
||||
</div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="my-5">
|
||||
|
||||
<?php foreach ($chapters as $index => $chapter): ?>
|
||||
<div class="chapter-content mb-5" id="chapter-<?= $chapter['id'] ?>">
|
||||
<h2 class="border-bottom pb-2 mb-4">
|
||||
<?= e($chapter['title']) ?>
|
||||
</h2>
|
||||
|
||||
<div class="chapter-text" style="line-height: 1.8; font-size: 1.1em;">
|
||||
<?= $chapter['content'] ?>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<a href="#top" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-arrow-up"></i> Наверх
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($index < count($chapters) - 1): ?>
|
||||
<hr class="my-5">
|
||||
<?php endif; ?>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if (empty($chapters)): ?>
|
||||
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>В этой книге пока нет глав</h3>
|
||||
<p>Автор еще не опубликовал содержание книги</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<h3 style="text-align: center; margin-bottom: 2rem; margin-top: 0em;">Оглавление</h3>
|
||||
<div class="chapters-list">
|
||||
<?php foreach ($chapters as $index => $chapter): ?>
|
||||
|
||||
<h6 style="margin-top: 0; margin-bottom: 0em;">
|
||||
<a href="#chapter-<?= $chapter['id'] ?>" style="text-decoration: none;">
|
||||
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?>
|
||||
</a>
|
||||
</h6>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 2rem 0;">
|
||||
|
||||
<?php foreach ($chapters as $index => $chapter): ?>
|
||||
<div class="chapter-content" id="chapter-<?= $chapter['id'] ?>" style="margin-bottom: 3rem;">
|
||||
<h2 style="border-bottom: 2px solid var(--primary); padding-bottom: 0.5rem;">
|
||||
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?>
|
||||
</h2>
|
||||
|
||||
<div style="margin-top: 1.5rem; line-height: 1.6;">
|
||||
<?= $chapter['content'] ?>
|
||||
</div>
|
||||
<footer class="text-center mt-5 pt-4 border-top">
|
||||
<p class="text-muted">
|
||||
Книга создана в <?= e(APP_NAME) ?> • <?= date('Y') ?>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;">
|
||||
<p style="color: var(--muted-color);">
|
||||
Книга создана в <?= e(APP_NAME) ?> •
|
||||
<?= date('Y') ?>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.chapter-content h1, .chapter-content h2, .chapter-content h3 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.chapter-content p {
|
||||
margin-bottom: 1em;
|
||||
.chapter-text p {
|
||||
margin-bottom: 1.5em;
|
||||
text-align: justify;
|
||||
text-indent: 2em;
|
||||
}
|
||||
|
||||
.chapter-content .dialogue {
|
||||
.chapter-text .dialogue {
|
||||
margin-left: 2rem;
|
||||
font-style: italic;
|
||||
color: #2c5aa0;
|
||||
}
|
||||
|
||||
.chapter-content blockquote {
|
||||
border-left: 4px solid var(--primary);
|
||||
.chapter-text blockquote {
|
||||
border-left: 4px solid var(--bs-primary);
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chapter-content code {
|
||||
background: var(--card-background-color);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chapter-content pre {
|
||||
background: var(--card-background-color);
|
||||
background: var(--bs-light);
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
|
||||
.chapter-text pre {
|
||||
background: var(--bs-dark);
|
||||
color: var(--bs-light);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chapter-content ul, .chapter-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
.chapter-text code {
|
||||
background: var(--bs-light);
|
||||
padding: 0.2em 0.4em;
|
||||
border-radius: 0.3rem;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -2,61 +2,72 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Новая глава для: <?= e($book['title']) ?></h1>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($error) ?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Новая глава для: <?= e($book['title']) ?></h1>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к главам
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="chapter-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Название главы *
|
||||
</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="<?= e($_POST['title'] ?? '') ?>"
|
||||
placeholder="Введите название главы"
|
||||
style="width: 100%; margin-bottom: 1.5rem;"
|
||||
required>
|
||||
|
||||
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Содержание главы *
|
||||
</label>
|
||||
<!-- Контейнер Quill -->
|
||||
<div id="quill-editor"
|
||||
class="writer-editor-container"
|
||||
style="height:500px;"
|
||||
data-content="<?= htmlspecialchars($_POST['content'] ?? '', ENT_QUOTES) ?>">
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-danger">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<!-- Скрытый textarea для формы -->
|
||||
<textarea id="content" name="content" style="display:none;"><?= e($_POST['content'] ?? '') ?></textarea>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Статус главы
|
||||
</label>
|
||||
<select id="status" name="status" style="width: 100%;">
|
||||
<option value="draft" <?= (($_POST['status'] ?? 'draft') == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
|
||||
<option value="published" <?= (($_POST['status'] ?? '') == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
|
||||
</select>
|
||||
<small style="color: var(--muted-color);">
|
||||
Опубликованные главы видны в публичном доступе
|
||||
</small>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" id="chapter-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Название главы *</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
value="<?= e($_POST['title'] ?? '') ?>"
|
||||
placeholder="Введите название главы" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">Содержание главы *</label>
|
||||
<!-- Контейнер Quill -->
|
||||
<div id="quill-editor"
|
||||
class="writer-editor-container"
|
||||
style="height:500px;"
|
||||
data-content="<?= htmlspecialchars($_POST['content'] ?? '', ENT_QUOTES) ?>">
|
||||
</div>
|
||||
<!-- Скрытый textarea для формы -->
|
||||
<textarea id="content" name="content" style="display:none;"><?= e($_POST['content'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="status" class="form-label">Статус главы</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="draft" <?= (($_POST['status'] ?? 'draft') == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
|
||||
<option value="published" <?= (($_POST['status'] ?? '') == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Опубликованные главы видны в публичном доступе
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Сохранить главу
|
||||
</button>
|
||||
<button type="button" onclick="previewChapter()" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i> Предпросмотр
|
||||
</button>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-danger">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||
<button type="submit" class="contrast">💾 Сохранить главу</button>
|
||||
<button type="button" onclick="previewChapter()" class="secondary">👁️ Предпросмотр</button>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary">❌ Отмена</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<link href="/assets/css/quill_reset.css" rel="stylesheet">
|
||||
<!-- <link href="/assets/css/quill_reset.css" rel="stylesheet"> -->
|
||||
<script>
|
||||
function previewChapter() {
|
||||
const form = document.getElementById('chapter-form');
|
||||
@@ -94,4 +105,4 @@ function previewChapter() {
|
||||
}
|
||||
</script>
|
||||
<script src="/assets/js/editor.js"></script>
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
@@ -3,78 +3,119 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Редактирование главы: <?= e($chapter['title']) ?></h1>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" id="chapter-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Название главы *
|
||||
</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="<?= e($chapter['title']) ?>"
|
||||
placeholder="Введите название главы"
|
||||
style="width: 100%; margin-bottom: 1.5rem;"
|
||||
required>
|
||||
|
||||
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Содержание главы *
|
||||
</label>
|
||||
<!-- Контейнер Quill -->
|
||||
<div id="quill-editor"
|
||||
class="writer-editor-container"
|
||||
style="height:500px;"
|
||||
data-content="<?= htmlspecialchars($chapter['content'] ?? '', ENT_QUOTES) ?>">
|
||||
</div>
|
||||
|
||||
<!-- Скрытый textarea для формы -->
|
||||
<textarea id="content" name="content" style="display:none;"></textarea>
|
||||
|
||||
|
||||
<div style="margin-top: 1rem;">
|
||||
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Статус главы
|
||||
</label>
|
||||
<select id="status" name="status" style="width: 100%;">
|
||||
<option value="draft" <?= ($chapter['status'] == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
|
||||
<option value="published" <?= ($chapter['status'] == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
|
||||
</select>
|
||||
<small style="color: var(--muted-color);">
|
||||
Опубликованные главы видны в публичном доступе
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
|
||||
<button type="submit" class="contrast">
|
||||
💾 Сохранить изменения
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="previewChapter()" class="secondary">
|
||||
👁️ Предпросмотр
|
||||
</button>
|
||||
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary">
|
||||
❌ Отмена
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Редактирование главы: <?= e($chapter['title']) ?></h1>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к главам
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>Информация о главе</h3>
|
||||
<p><strong>Книга:</strong> <a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit"><?= e($book['title']) ?></a></p>
|
||||
<p><strong>Количество слов:</strong> <?= $chapter['word_count'] ?></p>
|
||||
<p><strong>Создана:</strong> <?= date('d.m.Y H:i', strtotime($chapter['created_at'])) ?></p>
|
||||
<p><strong>Обновлена:</strong> <?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></p>
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-danger">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Содержание главы</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" id="chapter-form">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Название главы *</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
value="<?= e($chapter['title']) ?>"
|
||||
placeholder="Введите название главы" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">Содержание главы *</label>
|
||||
<!-- Контейнер Quill -->
|
||||
<div id="quill-editor"
|
||||
class="writer-editor-container"
|
||||
style="height:500px;"
|
||||
data-content="<?= htmlspecialchars($chapter['content'] ?? '', ENT_QUOTES) ?>">
|
||||
</div>
|
||||
<!-- Скрытый textarea для формы -->
|
||||
<textarea id="content" name="content" style="display:none;"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="status" class="form-label">Статус главы</label>
|
||||
<select class="form-select" id="status" name="status">
|
||||
<option value="draft" <?= ($chapter['status'] == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
|
||||
<option value="published" <?= ($chapter['status'] == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
|
||||
</select>
|
||||
<div class="form-text">
|
||||
Опубликованные главы видны в публичном доступе
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Сохранить изменения
|
||||
</button>
|
||||
|
||||
<button type="button" onclick="previewChapter()" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i> Предпросмотр
|
||||
</button>
|
||||
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-danger">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Информация о главе</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<strong>Книга:</strong><br>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="text-decoration-none">
|
||||
<?= e($book['title']) ?>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Количество слов:</strong><br>
|
||||
<span class="text-primary fw-bold"><?= $chapter['word_count'] ?></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Создана:</strong><br>
|
||||
<small class="text-muted"><?= date('d.m.Y H:i', strtotime($chapter['created_at'])) ?></small>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<strong>Обновлена:</strong><br>
|
||||
<small class="text-muted"><?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></small>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<small>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Автосохранение включено. Изменения сохраняются автоматически каждые 30 сек.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<link href="/assets/css/quill_reset.css" rel="stylesheet">
|
||||
|
||||
<!-- <link href="/assets/css/quill_reset.css" rel="stylesheet"> -->
|
||||
<script>
|
||||
function previewChapter() {
|
||||
const form = document.getElementById('chapter-form');
|
||||
@@ -101,11 +142,6 @@ function previewChapter() {
|
||||
titleInput.value = document.getElementById('title').value || 'Предпросмотр главы';
|
||||
tempForm.appendChild(titleInput);
|
||||
|
||||
const editorTypeInput = document.createElement('input');
|
||||
editorTypeInput.name = 'editor_type';
|
||||
editorTypeInput.value = '<?= $book['editor_type'] ?? 'markdown' ?>';
|
||||
tempForm.appendChild(editorTypeInput);
|
||||
|
||||
document.body.appendChild(tempForm);
|
||||
tempForm.submit();
|
||||
document.body.removeChild(tempForm);
|
||||
|
||||
@@ -3,79 +3,278 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem;">Главы книги: <?= e($book['title']) ?></h1>
|
||||
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content:center;">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button" role="button">➕ Новая глава</a>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="adaptive-button secondary" role="button">✏️ Редактировать книгу</a>
|
||||
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="adaptive-button green-btn" role="button" target="_blank">👁️ Публичный доступ</a>
|
||||
<a href="<?= SITE_URL ?>/book/all/<?= $book['id'] ?>" class="adaptive-button" role="button" target="_blank">👁️ Полный обзор</a>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h2">Главы книги: <?= e($book['title']) ?></h1>
|
||||
<p class="text-muted mb-0">Управление главами вашей книги</p>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Новая глава
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-pencil"></i> Редактировать книгу
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="btn btn-outline-success" target="_blank">
|
||||
<i class="bi bi-eye"></i> Публичный доступ
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/book/all/<?= $book['id'] ?>" class="btn btn-outline-info" target="_blank">
|
||||
<i class="bi bi-list-ul"></i> Полный обзор
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($chapters)): ?>
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-file-text fs-1 text-muted"></i>
|
||||
</div>
|
||||
<h3 class="h4 text-muted">В этой книге пока нет глав</h3>
|
||||
<p class="text-muted mb-4">Создайте первую главу для вашей книги</p>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Создать первую главу
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="card">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Список глав</h5>
|
||||
<div>
|
||||
<small class="text-muted me-3">Перетащите для изменения порядка</small>
|
||||
<button type="button" id="save-order-btn" class="btn btn-success btn-sm" style="display: none;">
|
||||
<i class="bi bi-check-circle"></i> Сохранить порядок
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="chapters-order-form" method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/update-order">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover" id="chapters-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%;"></th>
|
||||
<th style="width: 5%;">№</th>
|
||||
<th style="width: 35%;">Название главы</th>
|
||||
<th style="width: 15%;">Статус</th>
|
||||
<th style="width: 10%;">Слов</th>
|
||||
<th style="width: 20%;">Обновлено</th>
|
||||
<th style="width: 10%;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="chapters-list">
|
||||
<?php foreach ($chapters as $index => $chapter): ?>
|
||||
<tr data-chapter-id="<?= $chapter['id'] ?>" class="chapter-item">
|
||||
<td class="drag-handle text-muted" style="cursor: move;">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
</td>
|
||||
<td class="chapter-order"><?= $index + 1 ?></td>
|
||||
<td>
|
||||
<strong><?= e($chapter['title']) ?></strong>
|
||||
<?php if ($chapter['content']): ?>
|
||||
<br><small class="text-muted"><?= e(mb_strimwidth($chapter['content'], 0, 100, '...')) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge <?= $chapter['status'] == 'published' ? 'bg-success' : 'bg-warning' ?>">
|
||||
<?= $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 class="btn-group btn-group-sm">
|
||||
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="btn btn-outline-primary" title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger delete-chapter-btn"
|
||||
data-chapter-id="<?= $chapter['id'] ?>"
|
||||
data-csrf="<?= generate_csrf_token() ?>"
|
||||
title="Удалить">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<input type="hidden" name="order[]" value="<?= $chapter['id'] ?>">
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 p-3 bg-light rounded">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-4 col-6 mb-2">
|
||||
<strong class="text-primary"><?= count($chapters) ?></strong>
|
||||
<small class="text-muted d-block">Всего глав</small>
|
||||
</div>
|
||||
<div class="col-md-4 col-6 mb-2">
|
||||
<strong class="text-success"><?= array_sum(array_column($chapters, 'word_count')) ?></strong>
|
||||
<small class="text-muted d-block">Всего слов</small>
|
||||
</div>
|
||||
<div class="col-md-4 col-12 mb-2">
|
||||
<strong class="text-info"><?= count(array_filter($chapters, function($ch) { return $ch['status'] == 'published'; })) ?></strong>
|
||||
<small class="text-muted d-block">Опубликовано</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (empty($chapters)): ?>
|
||||
<div style="text-align: center; padding: 2rem; background: var(--card-background-color); border-radius: 5px; margin-top: 1rem;">
|
||||
<h3>В этой книге пока нет глав</h3>
|
||||
<p>Создайте первую главу для вашей книги</p>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button">📝 Создать первую главу</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="overflow-x: auto; margin-top: 1rem;">
|
||||
<table class="compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 5%;">№</th>
|
||||
<th style="width: 40%;">Название главы</th>
|
||||
<th style="width: 15%;">Статус</th>
|
||||
<th style="width: 10%;">Слов</th>
|
||||
<th style="width: 20%;">Обновлено</th>
|
||||
<th style="width: 10%;">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($chapters as $index => $chapter): ?>
|
||||
<tr>
|
||||
<td><?= $index + 1 ?></td>
|
||||
<td>
|
||||
<strong><?= e($chapter['title']) ?></strong>
|
||||
<?php if ($chapter['content']): ?>
|
||||
<br><small style="color: var(--muted-color);"><?= e(mb_strimwidth($chapter['content'], 0, 100, '...')) ?></small>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>">
|
||||
<?= $chapter['status'] == 'published' ? '✅ Опубликована' : '📝 Черновик' ?>
|
||||
</span>
|
||||
</td>
|
||||
<td><?= $chapter['word_count'] ?></td>
|
||||
<td>
|
||||
<small><?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></small>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 3px; flex-wrap: wrap;">
|
||||
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary" title="Редактировать" role="button">
|
||||
✏️
|
||||
</a>
|
||||
<form method="post" action="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить">
|
||||
🗑️
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php if (!empty($chapters)): ?>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const chaptersList = document.getElementById('chapters-list');
|
||||
const saveOrderBtn = document.getElementById('save-order-btn');
|
||||
const orderForm = document.getElementById('chapters-order-form');
|
||||
|
||||
if (chaptersList) {
|
||||
const sortable = new Sortable(chaptersList, {
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'sortable-ghost',
|
||||
chosenClass: 'sortable-chosen',
|
||||
animation: 150,
|
||||
onUpdate: function() {
|
||||
saveOrderBtn.style.display = 'block';
|
||||
updateChapterNumbers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Функция для обновления номеров глав
|
||||
function updateChapterNumbers() {
|
||||
const orderNumbers = document.querySelectorAll('.chapter-order');
|
||||
orderNumbers.forEach((element, index) => {
|
||||
element.textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
// Обработчик сохранения порядка
|
||||
saveOrderBtn.addEventListener('click', function() {
|
||||
const formData = new FormData(orderForm);
|
||||
|
||||
fetch(orderForm.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
saveOrderBtn.innerHTML = '<i class="bi bi-check-circle"></i> Порядок сохранен';
|
||||
saveOrderBtn.classList.remove('btn-success');
|
||||
saveOrderBtn.classList.add('btn-secondary');
|
||||
|
||||
setTimeout(() => {
|
||||
saveOrderBtn.style.display = 'none';
|
||||
saveOrderBtn.innerHTML = '<i class="bi bi-check-circle"></i> Сохранить порядок';
|
||||
saveOrderBtn.classList.remove('btn-secondary');
|
||||
saveOrderBtn.classList.add('btn-success');
|
||||
}, 2000);
|
||||
} else {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при сохранении порядка');
|
||||
});
|
||||
});
|
||||
});
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Обработчик удаления глав
|
||||
document.querySelectorAll('.delete-chapter-btn').forEach(button => {
|
||||
button.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const chapterId = this.getAttribute('data-chapter-id');
|
||||
const csrfToken = this.getAttribute('data-csrf');
|
||||
|
||||
if (confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.')) {
|
||||
const formData = new FormData();
|
||||
formData.append('csrf_token', csrfToken);
|
||||
|
||||
fetch(`/chapters/${chapterId}/delete`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else {
|
||||
return response.json();
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
if (data && data.success) {
|
||||
// Удаляем строку таблицы
|
||||
const row = document.querySelector(`tr[data-chapter-id="${chapterId}"]`);
|
||||
if (row) {
|
||||
row.remove();
|
||||
// Обновляем номера глав
|
||||
updateChapterNumbers();
|
||||
showNotification('Глава успешно удалена', 'success');
|
||||
}
|
||||
} else if (data && data.error) {
|
||||
alert('Ошибка: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Произошла ошибка при удалении главы');
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Функция для обновления номеров глав
|
||||
function updateChapterNumbers() {
|
||||
const orderNumbers = document.querySelectorAll('.chapter-order');
|
||||
orderNumbers.forEach((element, index) => {
|
||||
element.textContent = index + 1;
|
||||
});
|
||||
}
|
||||
|
||||
// Функция для показа уведомлений
|
||||
function showNotification(message, type = 'success') {
|
||||
// Можно использовать библиотеку для уведомлений или простой alert
|
||||
alert(message);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="margin-top: 1rem; padding: 0.5rem; background: var(--card-background-color); border-radius: 3px;">
|
||||
<strong>Статистика:</strong>
|
||||
Всего глав: <?= count($chapters) ?> |
|
||||
Всего слов: <?= array_sum(array_column($chapters, 'word_count')) ?> |
|
||||
Опубликовано: <?= count(array_filter($chapters, function($ch) { return $ch['status'] == 'published'; })) ?>
|
||||
</div>
|
||||
<style>
|
||||
.chapter-item {
|
||||
transition: background-color 0.2s ease;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.chapter-item:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.chapter-item.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.chapter-item.sortable-chosen {
|
||||
background: #e3f2fd;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
@@ -4,104 +4,140 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= e($page_title) ?></title>
|
||||
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/pico.min.css">
|
||||
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||
<style>
|
||||
body {
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background-color: #f8f9fa;
|
||||
line-height: 1.6;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
.content {
|
||||
margin-top: 2rem;
|
||||
.preview-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
.chapter-header {
|
||||
border-bottom: 3px solid var(--bs-primary);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.chapter-content {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.chapter-content h1, .chapter-content h2, .chapter-content h3 {
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
h1 { border-bottom: 2px solid var(--primary); padding-bottom: 0.3em; }
|
||||
h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; }
|
||||
p {
|
||||
.chapter-content h1 {
|
||||
border-bottom: 2px solid var(--bs-primary);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
.chapter-content h2 {
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
.chapter-content p {
|
||||
margin-bottom: 1em;
|
||||
text-align: justify;
|
||||
text-indent: 2em;
|
||||
}
|
||||
code {
|
||||
background: var(--card-background-color);
|
||||
.chapter-content code {
|
||||
background: var(--bs-light);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
}
|
||||
pre {
|
||||
background: var(--card-background-color);
|
||||
.chapter-content pre {
|
||||
background: var(--bs-dark);
|
||||
color: var(--bs-light);
|
||||
padding: 1rem;
|
||||
border-radius: 5px;
|
||||
overflow-x: auto;
|
||||
border-left: 4px solid var(--primary);
|
||||
border-left: 4px solid var(--bs-primary);
|
||||
}
|
||||
pre code {
|
||||
.chapter-content pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid var(--border-color);
|
||||
.chapter-content blockquote {
|
||||
border-left: 4px solid var(--bs-border-color);
|
||||
padding-left: 1rem;
|
||||
margin-left: 0;
|
||||
color: var(--muted-color);
|
||||
color: var(--bs-secondary);
|
||||
font-style: italic;
|
||||
background: var(--bs-light);
|
||||
padding: 1rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
}
|
||||
strong { font-weight: bold; }
|
||||
em { font-style: italic; }
|
||||
u { text-decoration: underline; }
|
||||
del { text-decoration: line-through; }
|
||||
.dialogue {
|
||||
.chapter-content strong { font-weight: bold; }
|
||||
.chapter-content em { font-style: italic; }
|
||||
.chapter-content u { text-decoration: underline; }
|
||||
.chapter-content del { text-decoration: line-through; }
|
||||
.chapter-content .dialogue {
|
||||
margin-left: 2rem;
|
||||
font-style: italic;
|
||||
color: #2c5aa0;
|
||||
}
|
||||
table {
|
||||
.chapter-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
table, th, td {
|
||||
border: 1px solid var(--border-color);
|
||||
.chapter-content table, .chapter-content th, .chapter-content td {
|
||||
border: 1px solid var(--bs-border-color);
|
||||
}
|
||||
th, td {
|
||||
.chapter-content th, .chapter-content td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
th {
|
||||
background: var(--card-background-color);
|
||||
.chapter-content th {
|
||||
background: var(--bs-light);
|
||||
}
|
||||
ul, ol {
|
||||
.chapter-content ul, .chapter-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
li {
|
||||
.chapter-content li {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
img {
|
||||
.chapter-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><?= e($title) ?></h1>
|
||||
<hr>
|
||||
</header>
|
||||
<main class="content">
|
||||
<?= $content ?>
|
||||
</main>
|
||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<small>Сгенерировано <?= date('d.m.Y H:i') ?> | Предпросмотр</small>
|
||||
<br>
|
||||
<a href="javascript:window.close()" class="button secondary">Закрыть</a>
|
||||
<a href="javascript:window.print()" class="button">Печать</a>
|
||||
</footer>
|
||||
<div class="preview-container">
|
||||
<header class="chapter-header text-center">
|
||||
<h1 class="display-6"><?= e($title) ?></h1>
|
||||
</header>
|
||||
|
||||
<main class="chapter-content">
|
||||
<?= $content ?>
|
||||
</main>
|
||||
|
||||
<footer class="mt-5 pt-4 border-top text-center">
|
||||
<div class="d-flex justify-content-center gap-2 flex-wrap">
|
||||
<button onclick="window.print()" class="btn btn-primary">
|
||||
<i class="bi bi-printer"></i> Печать
|
||||
</button>
|
||||
<button onclick="window.close()" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle"></i> Закрыть
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-muted mt-3 small">
|
||||
Сгенерировано <?= date('d.m.Y H:i') ?> | Предпросмотр
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,118 +3,308 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Панель управления</h1>
|
||||
|
||||
<div class="grid" style="margin-bottom: 2rem;">
|
||||
<article style="text-align: center;">
|
||||
<h2>📚 Книги</h2>
|
||||
<div style="font-size: 2rem; font-weight: bold; color: var(--primary);">
|
||||
<?= $total_books ?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Панель управления</h1>
|
||||
<div class="btn-group">
|
||||
<a href="<?= SITE_URL ?>/books/create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Новая книга
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/series/create" class="btn btn-outline-primary">
|
||||
<i class="bi bi-collection"></i> Новая серия
|
||||
</a>
|
||||
</div>
|
||||
<small>Всего книг</small>
|
||||
</article>
|
||||
|
||||
<article style="text-align: center;">
|
||||
<h2>📑 Главы</h2>
|
||||
<div style="font-size: 2rem; font-weight: bold; color: var(--success);">
|
||||
<?= $total_chapters ?>
|
||||
</div>
|
||||
<small>Всего глав</small>
|
||||
</article>
|
||||
|
||||
<article style="text-align: center;">
|
||||
<h2>📝 Слова</h2>
|
||||
<div style="font-size: 2rem; font-weight: bold; color: var(--warning);">
|
||||
<?= number_format($total_words) ?>
|
||||
</div>
|
||||
<small>Всего слов</small>
|
||||
</article>
|
||||
|
||||
<article style="text-align: center;">
|
||||
<h2>🌐 Публикации</h2>
|
||||
<div style="font-size: 2rem; font-weight: bold; color: var(--info);">
|
||||
<?= $published_books_count ?>
|
||||
</div>
|
||||
<small>Опубликовано книг</small>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div>
|
||||
<h2>Недавние книги</h2>
|
||||
<?php if (!empty($recent_books)): ?>
|
||||
<?php foreach ($recent_books as $book): ?>
|
||||
<article style="margin-bottom: 1em; padding-top: 0.5em;">
|
||||
<h3 style="margin-bottom: 0.5rem; margin-top: 0.5em;">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit">
|
||||
<?= e($book['title']) ?>
|
||||
</a>
|
||||
</h3>
|
||||
<?php if ($book['genre']): ?>
|
||||
<p style="margin: 0; color: var(--muted-color); font-size:small;"><em><?= e($book['genre']) ?></em></p>
|
||||
<?php endif; ?>
|
||||
<?php if ($book['description']): ?>
|
||||
<p style="margin: 0; color: var(--muted-color);"><?= e($book['description']) ?></p>
|
||||
<?php endif; ?>
|
||||
<footer>
|
||||
<small>
|
||||
Глав: <?= $book['chapter_count'] ?? 0 ?> |
|
||||
Слов: <?= $book['total_words'] ?? 0 ?> |
|
||||
Статус: <?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
|
||||
</small>
|
||||
</footer>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<?php if (count($recent_books) < count($books)): ?>
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<a href="<?= SITE_URL ?>/books" class="button">Все книги</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<article>
|
||||
<p>У вас пока нет книг.</p>
|
||||
<a href="<?= SITE_URL ?>/books/create" class="button">Создать первую книгу</a>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Мои серии</h2>
|
||||
<?php if (!empty($series)): ?>
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<article>
|
||||
<h3 style="margin-bottom: 0.5rem;">
|
||||
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/edit">
|
||||
<?= e($ser['title']) ?>
|
||||
</a>
|
||||
</h3>
|
||||
<?php if ($ser['description']): ?>
|
||||
<p><?= e(mb_strimwidth($ser['description'], 0, 100, '...')) ?></p>
|
||||
<?php endif; ?>
|
||||
<footer>
|
||||
<small>
|
||||
Книг: <?= $ser['book_count'] ?> |
|
||||
Слов: <?= $ser['total_words'] ?>
|
||||
</small>
|
||||
</footer>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
|
||||
<div style="text-align: center; margin-top: 1rem;">
|
||||
<a href="<?= SITE_URL ?>/series" class="button">Все серии</a>
|
||||
|
||||
<!-- Статистика -->
|
||||
<div class="row g-4 mb-5">
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card border-0 bg-primary bg-opacity-10">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="card-title text-primary"><?= $total_books ?></h3>
|
||||
<p class="card-text text-muted mb-0">Всего книг</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<i class="bi bi-journal-bookmark fs-1 text-primary"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<article>
|
||||
<p>У вас пока нет серий.</p>
|
||||
<a href="<?= SITE_URL ?>/series/create" class="button">Создать первую серию</a>
|
||||
</article>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h2 style="margin-top: 2rem;">Быстрые действия</h2>
|
||||
<div class="button-group">
|
||||
<a href="<?= SITE_URL ?>/books/create" class="button">📖 Новая книга</a>
|
||||
<a href="<?= SITE_URL ?>/series/create" class="button secondary">📚 Новая серия</a>
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card border-0 bg-success bg-opacity-10">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="card-title text-success"><?= $total_chapters ?></h3>
|
||||
<p class="card-text text-muted mb-0">Всего глав</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<i class="bi bi-file-text fs-1 text-success"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card border-0 bg-warning bg-opacity-10">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="card-title text-warning"><?= number_format($total_words) ?></h3>
|
||||
<p class="card-text text-muted mb-0">Всего слов</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<i class="bi bi-fonts fs-1 text-warning"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6">
|
||||
<div class="card border-0 bg-info bg-opacity-10">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<h3 class="card-title text-info"><?= $published_books_count ?></h3>
|
||||
<p class="card-text text-muted mb-0">Опубликовано книг</p>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<i class="bi bi-globe fs-1 text-info"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Недавние книги -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Недавние книги</h5>
|
||||
<a href="<?= SITE_URL ?>/books" class="btn btn-sm btn-outline-primary">Все книги</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($recent_books)): ?>
|
||||
<div class="list-group list-group-flush">
|
||||
<?php foreach ($recent_books as $book): ?>
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-1">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="text-decoration-none">
|
||||
<?= e($book['title']) ?>
|
||||
</a>
|
||||
</h6>
|
||||
<span class="badge <?= $book['published'] ? 'bg-success' : 'bg-warning' ?>">
|
||||
<?= $book['published'] ? 'Опубликована' : 'Черновик' ?>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<?php if ($book['genre']): ?>
|
||||
<p class="text-muted small mb-1"><em><?= e($book['genre']) ?></em></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($book['description']): ?>
|
||||
<p class="text-muted small mb-2"><?= e($book['description']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
(рассказ) | <?= number_format($book['total_words'] ?? 0) ?> слов
|
||||
<?php else: ?>
|
||||
Глав: <?= $book['chapter_count'] ?? 0 ?> | <?= number_format($book['total_words'] ?? 0) ?> слов
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<?php if (empty($book['is_short_story'])): ?>
|
||||
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-file-text"></i>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<?php if (count($recent_books) < $total_books): ?>
|
||||
<div class="text-center mt-3">
|
||||
<a href="<?= SITE_URL ?>/books" class="btn btn-outline-primary">Все книги</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-journal-bookmark fs-1 text-muted"></i>
|
||||
<p class="text-muted mt-2">У вас пока нет книг</p>
|
||||
<a href="<?= SITE_URL ?>/books/create" class="btn btn-primary">Создать первую книгу</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Мои серии и быстрые действия -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">Мои серии</h5>
|
||||
<a href="<?= SITE_URL ?>/series" class="btn btn-sm btn-outline-primary">Все серии</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($series)): ?>
|
||||
<div class="list-group list-group-flush">
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<div class="list-group-item px-0">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-1">
|
||||
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/edit" class="text-decoration-none">
|
||||
<?= e($ser['title']) ?>
|
||||
</a>
|
||||
</h6>
|
||||
<span class="badge bg-primary"><?= $ser['book_count'] ?> книг</span>
|
||||
</div>
|
||||
|
||||
<?php if ($ser['description']): ?>
|
||||
<p class="text-muted small mb-2"><?= e(mb_strimwidth($ser['description'], 0, 100, '...')) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<small class="text-muted">
|
||||
Книг: <?= $ser['book_count'] ?> |
|
||||
Слов: <?= $ser['total_words'] ?>
|
||||
</small>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/view" class="btn btn-outline-success btn-sm" target="_blank">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-collection fs-1 text-muted"></i>
|
||||
<p class="text-muted mt-2">У вас пока нет серий</p>
|
||||
<a href="<?= SITE_URL ?>/series/create" class="btn btn-primary">Создать первую серию</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<!-- Быстрые действия -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">Быстрые действия</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<a href="<?= SITE_URL ?>/books/create" class="btn btn-outline-primary w-100 d-flex align-items-center justify-content-center py-3">
|
||||
<i class="bi bi-journal-plus fs-4 me-2"></i>
|
||||
<div>
|
||||
<div>Новая книга</div>
|
||||
<small class="text-muted">Создать книгу</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a href="<?= SITE_URL ?>/series/create" class="btn btn-outline-secondary w-100 d-flex align-items-center justify-content-center py-3">
|
||||
<i class="bi bi-collection fs-4 me-2"></i>
|
||||
<div>
|
||||
<div>Новая серия</div>
|
||||
<small class="text-muted">Создать серию</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a href="<?= SITE_URL ?>/books" class="btn btn-outline-success w-100 d-flex align-items-center justify-content-center py-3">
|
||||
<i class="bi bi-journal-bookmark fs-4 me-2"></i>
|
||||
<div>
|
||||
<div>Все книги</div>
|
||||
<small class="text-muted">Управление</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<a href="<?= SITE_URL ?>/profile" class="btn btn-outline-info w-100 d-flex align-items-center justify-content-center py-3">
|
||||
<i class="bi bi-person fs-4 me-2"></i>
|
||||
<div>
|
||||
<div>Профиль</div>
|
||||
<small class="text-muted">Настройки</small>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Дополнительная статистика -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="card-title mb-0">Прогресс писателя</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-2 col-6 mb-3">
|
||||
<div class="h4 text-primary mb-1"><?= $total_books ?></div>
|
||||
<small class="text-muted">Книг</small>
|
||||
</div>
|
||||
<div class="col-md-2 col-6 mb-3">
|
||||
<div class="h4 text-success mb-1"><?= $total_chapters ?></div>
|
||||
<small class="text-muted">Глав</small>
|
||||
</div>
|
||||
<div class="col-md-2 col-6 mb-3">
|
||||
<div class="h4 text-warning mb-1"><?= number_format($total_words) ?></div>
|
||||
<small class="text-muted">Слов</small>
|
||||
</div>
|
||||
<div class="col-md-2 col-6 mb-3">
|
||||
<div class="h4 text-info mb-1"><?= $published_books_count ?></div>
|
||||
<small class="text-muted">Опубликовано</small>
|
||||
</div>
|
||||
<div class="col-md-2 col-6 mb-3">
|
||||
<div class="h4 text-secondary mb-1"><?= $total_books > 0 ? round($total_words / $total_books) : 0 ?></div>
|
||||
<small class="text-muted">Слов/книга</small>
|
||||
</div>
|
||||
<div class="col-md-2 col-6 mb-3">
|
||||
<div class="h4 text-dark mb-1"><?= $total_chapters > 0 ? round($total_words / $total_chapters) : 0 ?></div>
|
||||
<small class="text-muted">Слов/глава</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($total_words > 0): ?>
|
||||
<div class="mt-3">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<small>Прогресс этого месяца</small>
|
||||
<small><?= number_format($total_words) ?> слов</small>
|
||||
</div>
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar bg-success" style="width: <?= min(100, ($total_words / 50000) * 100) ?>%"></div>
|
||||
</div>
|
||||
<small class="text-muted">Цель: 50,000 слов в месяц</small>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,17 +3,33 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div class="container" style="text-align: center; padding: 4rem 1rem;">
|
||||
<h1>404 - Страница не найдена</h1>
|
||||
<p style="font-size: 1.2rem; margin-bottom: 2rem;">
|
||||
Запрашиваемая страница не существует или была перемещена.
|
||||
</p>
|
||||
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="<?= SITE_URL ?>/" class="button">🏠 На главную</a>
|
||||
<a href="<?= SITE_URL ?>/books" class="button secondary">📚 К книгам</a>
|
||||
<?php if (!is_logged_in()): ?>
|
||||
<a href="<?= SITE_URL ?>/login" class="button secondary">🔑 Войти</a>
|
||||
<?php endif; ?>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8 text-center">
|
||||
<div class="py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-exclamation-triangle display-1 text-warning"></i>
|
||||
</div>
|
||||
<h1 class="display-4 fw-bold text-muted">404</h1>
|
||||
<h2 class="h3 mb-4">Страница не найдена</h2>
|
||||
<p class="lead text-muted mb-5">
|
||||
Запрашиваемая страница не существует или была перемещена.
|
||||
</p>
|
||||
<div class="d-flex gap-3 justify-content-center flex-wrap">
|
||||
<a href="<?= SITE_URL ?>/" class="btn btn-primary btn-lg">
|
||||
<i class="bi bi-house"></i> На главную
|
||||
</a>
|
||||
<a href="<?= SITE_URL ?>/books" class="btn btn-outline-secondary btn-lg">
|
||||
<i class="bi bi-journal-bookmark"></i> К книгам
|
||||
</a>
|
||||
<?php if (!is_logged_in()): ?>
|
||||
<a href="<?= SITE_URL ?>/login" class="btn btn-outline-primary btn-lg">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Войти
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,88 @@
|
||||
<?php
|
||||
// views/layouts/footer.php
|
||||
?>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="container" style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--muted-border-color);">
|
||||
<small>
|
||||
© <?= date('Y') ?> <?= e(APP_NAME) ?>.
|
||||
<?php if (is_logged_in()): ?>
|
||||
<a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">Моя публичная страница</a>
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
<footer class="bg-light mt-5 py-4 border-top">
|
||||
<div class="container">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-6">
|
||||
<small class="text-muted">
|
||||
© <?= date('Y') ?> <?= e(APP_NAME) ?>
|
||||
</small>
|
||||
</div>
|
||||
<div class="col-md-6 text-md-end">
|
||||
<?php if (is_logged_in()): ?>
|
||||
<a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank" class="text-muted text-decoration-none">
|
||||
<i class="bi bi-eye"></i> Моя публичная страница
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<?php if (!isset($GLOBALS['bootstrap_loaded'])): ?>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<?php $GLOBALS['bootstrap_loaded'] = true; endif; ?>
|
||||
<script>
|
||||
// Функция для установки темы
|
||||
function setTheme(themeName) {
|
||||
// Устанавливаем cookie на 30 дней
|
||||
const date = new Date();
|
||||
date.setTime(date.getTime() + (30 * 24 * 60 * 60 * 1000));
|
||||
const expires = "expires=" + date.toUTCString();
|
||||
document.cookie = "bs_theme=" + themeName + ";" + expires + ";path=/";
|
||||
|
||||
// Перезагружаем страницу для применения темы
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Функция для получения цвета темы (для превью)
|
||||
function getThemeColor(theme) {
|
||||
const themeColors = {
|
||||
'default': '#0d6efd',
|
||||
'cerulean': '#2fa4e7',
|
||||
'cosmo': '#2780e3',
|
||||
'cyborg': '#060606',
|
||||
'darkly': '#375a7f',
|
||||
'flatly': '#2c3e50',
|
||||
'journal': '#eb6864',
|
||||
'litera': '#4582ec',
|
||||
'lumen': '#158cba',
|
||||
'lux': '#1a1a1a',
|
||||
'materia': '#2196f3',
|
||||
'minty': '#78c2ad',
|
||||
'morph': '#6750a4',
|
||||
'pulse': '#593196',
|
||||
'quartz': '#d7ccc8',
|
||||
'sandstone': '#325d88',
|
||||
'simplex': '#d9230f',
|
||||
'sketchy': '#333333',
|
||||
'slate': '#484e5a',
|
||||
'solar': '#b58900',
|
||||
'spacelab': '#446e9b',
|
||||
'superhero': '#4e5d6c',
|
||||
'united': '#e95420',
|
||||
'vapor': '#0d6efd',
|
||||
'yeti': '#008cba',
|
||||
'zephyr': '#0d6efd'
|
||||
};
|
||||
return themeColors[theme] || '#0d6efd';
|
||||
}
|
||||
|
||||
// Применяем цвет превью для всех элементов
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const themePreviews = document.querySelectorAll('.theme-preview');
|
||||
themePreviews.forEach(preview => {
|
||||
const themeOption = preview.closest('.theme-option');
|
||||
if (themeOption) {
|
||||
const theme = themeOption.getAttribute('onclick').match(/setTheme\('([^']+)'\)/)[1];
|
||||
preview.style.backgroundColor = getThemeColor(theme);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Глобальные функции JavaScript
|
||||
function confirmAction(message) {
|
||||
return confirm(message || 'Вы уверены?');
|
||||
|
||||
@@ -1,5 +1,36 @@
|
||||
<?php
|
||||
// views/layouts/header.php
|
||||
|
||||
// Получаем текущую тему из cookies
|
||||
$current_theme = $_COOKIE['bs_theme'] ?? 'default';
|
||||
$available_themes = [
|
||||
'default' => 'Bootstrap Default',
|
||||
'cerulean' => 'Cerulean',
|
||||
'cosmo' => 'Cosmo',
|
||||
'cyborg' => 'Cyborg',
|
||||
'darkly' => 'Darkly',
|
||||
'flatly' => 'Flatly',
|
||||
'journal' => 'Journal',
|
||||
'litera' => 'Litera',
|
||||
'lumen' => 'Lumen',
|
||||
'lux' => 'Lux',
|
||||
'materia' => 'Materia',
|
||||
'minty' => 'Minty',
|
||||
'morph' => 'Morph',
|
||||
'pulse' => 'Pulse',
|
||||
'quartz' => 'Quartz',
|
||||
'sandstone' => 'Sandstone',
|
||||
'simplex' => 'Simplex',
|
||||
'sketchy' => 'Sketchy',
|
||||
'slate' => 'Slate',
|
||||
'solar' => 'Solar',
|
||||
'spacelab' => 'Spacelab',
|
||||
'superhero' => 'Superhero',
|
||||
'united' => 'United',
|
||||
'vapor' => 'Vapor',
|
||||
'yeti' => 'Yeti',
|
||||
'zephyr' => 'Zephyr'
|
||||
];
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
@@ -7,68 +38,156 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= e($page_title ?? 'Web Writer') ?></title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.10/css/pico.min.css">
|
||||
|
||||
<!-- Подключаем выбранную тему Bootstrap -->
|
||||
<?php if ($current_theme === 'default'): ?>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<?php else: ?>
|
||||
<link href="<?= SITE_URL ?>/assets/bs/<?= e($current_theme) ?>/bootstrap.min.css" rel="stylesheet">
|
||||
<?php endif; ?>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
|
||||
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
|
||||
<link href="<?= SITE_URL ?>/assets/css/quill.snow.css" rel="stylesheet">
|
||||
<script src="<?= SITE_URL ?>/assets/js/quill.js"></script>
|
||||
<script src="<?= SITE_URL ?>/assets/js/quill.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<?php $GLOBALS['bootstrap_loaded'] = true; ?>
|
||||
<style>
|
||||
.navbar-brand { font-weight: 600; }
|
||||
.dropdown-menu { min-width: 200px; }
|
||||
.alert { border: none; border-radius: 8px; }
|
||||
.main-container { min-height: calc(100vh - 120px); }
|
||||
.theme-option { padding: 8px 12px; cursor: pointer; }
|
||||
.theme-option:hover { background-color: var(--bs-light); }
|
||||
.theme-option.active { background-color: var(--bs-primary); color: white; }
|
||||
.theme-preview { width: 20px; height: 20px; border-radius: 3px; display: inline-block; margin-right: 8px; border: 1px solid #dee2e6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="container-fluid">
|
||||
<ul>
|
||||
<li><strong><a href="<?= SITE_URL ?>/"><?= e(APP_NAME) ?></a></strong></li>
|
||||
</ul>
|
||||
<ul>
|
||||
<?php if (is_logged_in()): ?>
|
||||
<li><a href="<?= SITE_URL ?>/dashboard">📊 Панель управления</a></li>
|
||||
<li><a href="<?= SITE_URL ?>/books">📚 Мои книги</a></li>
|
||||
<li><a href="<?= SITE_URL ?>/series">📑 Серии</a></li>
|
||||
<li>
|
||||
<details role="list" dir="rtl">
|
||||
<summary aria-haspopup="listbox" role="link">
|
||||
👤 <?= e($_SESSION['display_name']) ?>
|
||||
</summary>
|
||||
<ul role="listbox">
|
||||
<li><a href="<?= SITE_URL ?>/profile">⚙️ Профиль</a></li>
|
||||
<li><a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">👤 Моя публичная страница</a></li>
|
||||
<?php if ($_SESSION['user_id'] == 1): // Проверка на администратора ?>
|
||||
<li><a href="<?= SITE_URL ?>/admin/users">👥 Управление пользователями</a></li>
|
||||
<?php endif; ?>
|
||||
<li><a href="<?= SITE_URL ?>/logout">🚪 Выход</a></li>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="<?= SITE_URL ?>/"><?= e(APP_NAME) ?></a>
|
||||
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
<?php if (is_logged_in()): ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="<?= SITE_URL ?>/dashboard">
|
||||
<i class="bi bi-speedometer2"></i> Панель управления
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="<?= SITE_URL ?>/books">
|
||||
<i class="bi bi-journal-bookmark"></i> Мои книги
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="<?= SITE_URL ?>/series">
|
||||
<i class="bi bi-collection"></i> Серии
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
|
||||
<ul class="navbar-nav">
|
||||
<!-- Выбор темы -->
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-palette"></i> Тема
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><h6 class="dropdown-header">Выбор темы</h6></li>
|
||||
<?php foreach ($available_themes as $theme_key => $theme_name): ?>
|
||||
<li>
|
||||
<a class="dropdown-item theme-option <?= $current_theme === $theme_key ? 'active' : '' ?>"
|
||||
href="#"
|
||||
onclick="setTheme('<?= $theme_key ?>')">
|
||||
<span class="theme-preview" style="background-color: getThemeColor('<?= $theme_key ?>')"></span>
|
||||
<?= e($theme_name) ?>
|
||||
<?php if ($current_theme === $theme_key): ?>
|
||||
<i class="bi bi-check float-end"></i>
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
</li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<li><a href="<?= SITE_URL ?>/login">🔑 Вход</a></li>
|
||||
<li><a href="<?= SITE_URL ?>/register">📝 Регистрация</a></li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
<?php if (is_logged_in()): ?>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-person-circle"></i> <?= e($_SESSION['display_name']) ?>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="<?= SITE_URL ?>/profile">
|
||||
<i class="bi bi-gear"></i> Профиль
|
||||
</a></li>
|
||||
<li><a class="dropdown-item" href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">
|
||||
<i class="bi bi-eye"></i> Публичная страница
|
||||
</a></li>
|
||||
<?php if ($_SESSION['user_id'] == 1): ?>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" href="<?= SITE_URL ?>/admin/users">
|
||||
<i class="bi bi-people"></i> Управление пользователями
|
||||
</a></li>
|
||||
<?php endif; ?>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item text-danger" href="<?= SITE_URL ?>/logout">
|
||||
<i class="bi bi-box-arrow-right"></i> Выход
|
||||
</a></li>
|
||||
</ul>
|
||||
</li>
|
||||
<?php else: ?>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="<?= SITE_URL ?>/login">
|
||||
<i class="bi bi-box-arrow-in-right"></i> Вход
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="<?= SITE_URL ?>/register">
|
||||
<i class="bi bi-person-plus"></i> Регистрация
|
||||
</a>
|
||||
</li>
|
||||
<?php endif; ?>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
<?php if (isset($_SESSION['success'])): ?>
|
||||
<div class="alert alert-success">
|
||||
<?= e($_SESSION['success']) ?>
|
||||
<?php unset($_SESSION['success']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<main class="main-container">
|
||||
<div class="container mt-4">
|
||||
<?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-error">
|
||||
<?= e($_SESSION['error']) ?>
|
||||
<?php unset($_SESSION['error']); ?>
|
||||
</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; ?>
|
||||
|
||||
<?php if (isset($_SESSION['warning'])): ?>
|
||||
<div class="alert alert-warning">
|
||||
<?= e($_SESSION['warning']) ?>
|
||||
<?php unset($_SESSION['warning']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_SESSION['warning'])): ?>
|
||||
<div class="alert alert-warning alert-dismissible fade show">
|
||||
<?= e($_SESSION['warning']) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<?php unset($_SESSION['warning']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (isset($_SESSION['info'])): ?>
|
||||
<div class="alert alert-info">
|
||||
<?= e($_SESSION['info']) ?>
|
||||
<?php unset($_SESSION['info']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($_SESSION['info'])): ?>
|
||||
<div class="alert alert-info alert-dismissible fade show">
|
||||
<?= e($_SESSION['info']) ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
<?php unset($_SESSION['info']); ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -3,56 +3,70 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Создание новой серии</h1>
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Создание новой серии</h1>
|
||||
<a href="<?= SITE_URL ?>/series" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к сериям
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<?php if (isset($error) && $error): ?>
|
||||
<div class="alert alert-error">
|
||||
<?= e($error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?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 style="max-width: 100%; margin-bottom: 1rem;">
|
||||
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Название серии *
|
||||
</label>
|
||||
<input type="text" id="title" name="title"
|
||||
value="<?= e($_POST['title'] ?? '') ?>"
|
||||
placeholder="Введите название серии"
|
||||
style="width: 100%; margin-bottom: 1.5rem;"
|
||||
required>
|
||||
|
||||
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Описание серии
|
||||
</label>
|
||||
<textarea id="description" name="description"
|
||||
placeholder="Описание сюжета серии, общая концепция..."
|
||||
rows="6"
|
||||
style="width: 100%;"><?= e($_POST['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
||||
<button type="submit" class="contrast">
|
||||
📚 Создать серию
|
||||
</button>
|
||||
|
||||
<a href="<?= SITE_URL ?>/series" role="button" class="secondary">
|
||||
❌ Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Название серии *</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
value="<?= e($_POST['title'] ?? '') ?>"
|
||||
placeholder="Введите название серии" required>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="description" class="form-label">Описание серии</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
placeholder="Описание сюжета серии, общая концепция..."
|
||||
rows="6"><?= e($_POST['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-collection"></i> Создать серию
|
||||
</button>
|
||||
|
||||
<a href="<?= SITE_URL ?>/series" class="btn btn-outline-danger">
|
||||
<i class="bi bi-x-circle"></i> Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>Что такое серия?</h3>
|
||||
<p>Серия позволяет объединить несколько книг в одну тематическую коллекцию. Это полезно для:</p>
|
||||
<ul>
|
||||
<li>Циклов книг с общим сюжетом</li>
|
||||
<li>Книг в одном мире или вселенной</li>
|
||||
<li>Организации книг по темам или жанрам</li>
|
||||
</ul>
|
||||
<p>Вы сможете добавить книги в серию после её создания.</p>
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Что такое серия?</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Серия позволяет объединить несколько книг в одну тематическую коллекцию. Это полезно для:</p>
|
||||
<ul>
|
||||
<li>Циклов книг с общим сюжетом</li>
|
||||
<li>Книг в одном мире или вселенной</li>
|
||||
<li>Организации книг по темам или жанрам</li>
|
||||
</ul>
|
||||
<p>Вы сможете добавить книги в серию после её создания.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
@@ -2,115 +2,147 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Редактирование серии: <?= e($series['title']) ?></h1>
|
||||
<article>
|
||||
<h2>Основная информация</h2>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/edit">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<label for="title">
|
||||
Название серии *
|
||||
<input type="text" id="title" name="title" value="<?= e($series['title']) ?>" required>
|
||||
</label>
|
||||
|
||||
<label for="description">
|
||||
Описание серии
|
||||
<textarea id="description" name="description" rows="4"><?= e($series['description'] ?? '') ?></textarea>
|
||||
</label>
|
||||
|
||||
<button type="submit" class="primary-btn">Сохранить изменения</button>
|
||||
</form>
|
||||
</article>
|
||||
<div class="grid">
|
||||
<div>
|
||||
|
||||
|
||||
<article>
|
||||
<h2>Добавить книгу в серию</h2>
|
||||
<?php if (!empty($available_books)): ?>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/add-book">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<label for="book_id">
|
||||
Выберите книгу
|
||||
<select id="book_id" name="book_id" required>
|
||||
<option value="">-- Выберите книгу --</option>
|
||||
<?php foreach ($available_books as $book): ?>
|
||||
<option value="<?= $book['id'] ?>"><?= e($book['title']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</label>
|
||||
<label for="sort_order">
|
||||
Порядковый номер в серии
|
||||
<input type="number" id="sort_order" name="sort_order" value="<?= count($books_in_series) + 1 ?>" min="1">
|
||||
</label>
|
||||
<button type="submit" class="secondary-btn">Добавить в серию</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<p>Все ваши книги уже добавлены в эту серию или у вас нет доступных книг.</p>
|
||||
<a href="/books/create" class="primary-btn" role="button">Создать новую книгу</a>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Редактирование серии: <?= e($series['title']) ?></h1>
|
||||
<a href="<?= SITE_URL ?>/series" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад к сериям
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<article>
|
||||
<h2>Книги в серии (<?= count($books_in_series) ?>)</h2>
|
||||
|
||||
<?php if (!empty($books_in_series)): ?>
|
||||
<div id="series-books-list">
|
||||
<form id="reorder-form" method="post" action="/series/<?= $series['id'] ?>/update-order">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Основная информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/edit">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="books-list">
|
||||
<?php foreach ($books_in_series as $index => $book): ?>
|
||||
<div class="book-item" data-book-id="<?= $book['id'] ?>">
|
||||
<div class="book-drag-handle" style="cursor: move;">☰</div>
|
||||
<div class="book-info">
|
||||
<strong><?= e($book['title']) ?></strong>
|
||||
<small>Порядок: <?= $book['sort_order_in_series'] ?></small>
|
||||
</div>
|
||||
<div class="book-actions" style="display: grid; min-width: 2rem; margin-top: 1rem;">
|
||||
<a href="/books/<?= $book['id'] ?>/edit" class="compact-button" role="button" style="margin-top: 0em;">✏️</a>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/remove-book/<?= $book['id'] ?>"
|
||||
style="display: inline;" onsubmit="return confirm('Удалить книгу из серии?')">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button red-btn" style="margin-top: 0em;">🗑️</button>
|
||||
</form>
|
||||
</div>
|
||||
<input type="hidden" name="order[]" value="<?= $book['id'] ?>">
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Название серии *</label>
|
||||
<input type="text" class="form-control" id="title" name="title"
|
||||
value="<?= e($series['title']) ?>" required>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="secondary-btn" id="save-order-btn" style="display: none;">
|
||||
Сохранить порядок
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Описание серии</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="4"><?= e($series['description'] ?? '') ?></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Сохранить изменения
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p>В этой серии пока нет книг. Добавьте книги с помощью формы слева.</p>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Добавить книгу в серию</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($available_books)): ?>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/add-book">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="book_id" class="form-label">Выберите книгу</label>
|
||||
<select class="form-select" id="book_id" name="book_id" required>
|
||||
<option value="">-- Выберите книгу --</option>
|
||||
<?php foreach ($available_books as $book): ?>
|
||||
<option value="<?= $book['id'] ?>"><?= e($book['title']) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="sort_order" class="form-label">Порядковый номер в серии</label>
|
||||
<input type="number" class="form-control" id="sort_order" name="sort_order"
|
||||
value="<?= count($books_in_series) + 1 ?>" min="1">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-outline-primary">
|
||||
<i class="bi bi-plus-circle"></i> Добавить в серию
|
||||
</button>
|
||||
</form>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-3">
|
||||
<i class="bi bi-journal-bookmark fs-1 text-muted"></i>
|
||||
<p class="text-muted mt-2">Все ваши книги уже добавлены в эту серию или у вас нет доступных книг.</p>
|
||||
<a href="/books/create" class="btn btn-primary">Создать новую книгу</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Книги в серии (<?= count($books_in_series) ?>)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<?php if (!empty($books_in_series)): ?>
|
||||
<div id="series-books-list">
|
||||
<form id="reorder-form" method="post" action="/series/<?= $series['id'] ?>/update-order">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="books-list">
|
||||
<?php foreach ($books_in_series as $index => $book): ?>
|
||||
<div class="book-item border rounded p-3 mb-2 d-flex align-items-center"
|
||||
data-book-id="<?= $book['id'] ?>">
|
||||
<div class="book-drag-handle text-muted me-3" style="cursor: move;">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
</div>
|
||||
<div class="book-info flex-grow-1">
|
||||
<strong><?= e($book['title']) ?></strong>
|
||||
<br>
|
||||
<small class="text-muted">Порядок: <?= $book['sort_order_in_series'] ?></small>
|
||||
</div>
|
||||
<div class="book-actions ms-3">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a href="/books/<?= $book['id'] ?>/edit" class="btn btn-outline-primary" title="Редактировать">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<form method="post" action="/series/<?= $series['id'] ?>/remove-book/<?= $book['id'] ?>"
|
||||
onsubmit="return confirm('Удалить книгу из серии?')" 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>
|
||||
</div>
|
||||
<input type="hidden" name="order[]" value="<?= $book['id'] ?>">
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-outline-success w-100 mt-3" id="save-order-btn" style="display: none;">
|
||||
<i class="bi bi-check-circle"></i> Сохранить порядок
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-journal-bookmark fs-1 text-muted"></i>
|
||||
<p class="text-muted mt-2">В этой серии пока нет книг.</p>
|
||||
<p class="text-muted small">Добавьте книги с помощью формы слева.</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.books-list {
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.book-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
background: white;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.book-item:last-child {
|
||||
border-bottom: none;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.book-item:hover {
|
||||
@@ -126,30 +158,8 @@ include 'views/layouts/header.php';
|
||||
}
|
||||
|
||||
.book-drag-handle {
|
||||
padding: 0 10px;
|
||||
color: #666;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.book-info {
|
||||
flex: 1;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.book-info strong {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.book-info small {
|
||||
color: #666;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.book-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script>
|
||||
@@ -172,13 +182,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Автосохранение порядка через 2 секунды после изменения
|
||||
let saveTimeout;
|
||||
saveOrderBtn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
clearTimeout(saveTimeout);
|
||||
document.getElementById('reorder-form').submit();
|
||||
});
|
||||
|
||||
// Автоматическое сохранение при изменении порядка
|
||||
booksList.addEventListener('sortupdate', function() {
|
||||
clearTimeout(saveTimeout);
|
||||
saveTimeout = setTimeout(() => {
|
||||
|
||||
@@ -2,82 +2,97 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div style="display: block; justify-content: between; align-items: center; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem;">
|
||||
<h1 style="margin: 0;">Мои серии книг</h1>
|
||||
<a href="/series/create" class="action-button primary" role="button">➕ Создать серию</a>
|
||||
</div>
|
||||
|
||||
<?php if (empty($series)): ?>
|
||||
<article class="series-empty-state">
|
||||
<div class="series-empty-icon">📚</div>
|
||||
<h2>Пока нет серий</h2>
|
||||
<p style="color: #666; margin-bottom: 2rem;">
|
||||
Создайте свою первую серию, чтобы организовать книги в циклы и сериалы.
|
||||
</p>
|
||||
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
|
||||
<a href="/series/create" class="action-button primary" role="button">Создать серию</a>
|
||||
<a href="/books" class="action-button secondary">Перейти к книгам</a>
|
||||
</div>
|
||||
</article>
|
||||
<?php else: ?>
|
||||
<div class="series-grid">
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<article class="series-card">
|
||||
<div class="series-header">
|
||||
<h3 class="series-title">
|
||||
<a href="/series/<?= $ser['id'] ?>/edit"><?= e($ser['title']) ?></a>
|
||||
</h3>
|
||||
<div class="series-meta">
|
||||
Создана <?= date('d.m.Y', strtotime($ser['created_at'])) ?>
|
||||
<?php if ($ser['updated_at'] != $ser['created_at']): ?>
|
||||
• Обновлена <?= date('d.m.Y', strtotime($ser['updated_at'])) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($ser['description'])): ?>
|
||||
<div class="series-description">
|
||||
<?= e($ser['description']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="series-stats-grid">
|
||||
<div class="series-stat">
|
||||
<span class="series-stat-number"><?= $ser['book_count'] ?? 0 ?></span>
|
||||
<span class="series-stat-label">книг</span>
|
||||
</div>
|
||||
<div class="series-stat">
|
||||
<span class="series-stat-number"><?= number_format($ser['total_words'] ?? 0) ?></span>
|
||||
<span class="series-stat-label">слов</span>
|
||||
</div>
|
||||
<div class="series-stat">
|
||||
<span class="series-stat-number">
|
||||
<?php
|
||||
$avg_words = $ser['book_count'] > 0 ? round($ser['total_words'] / $ser['book_count']) : 0;
|
||||
echo number_format($avg_words);
|
||||
?>
|
||||
</span>
|
||||
<span class="series-stat-label">слов/книга</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="series-actions" style="display:grid;">
|
||||
<a href="/series/<?= $ser['id'] ?>/edit" class="compact-button primary-btn" role="button">
|
||||
✏️ Управление
|
||||
</a>
|
||||
<a href="/series/<?= $ser['id'] ?>/view" class="compact-button secondary-btn" target="_blank" role="button">
|
||||
👁️ Публично
|
||||
</a>
|
||||
<form method="post" action="/series/<?= $ser['id'] ?>/delete"
|
||||
onsubmit="return confirm('Удалить серию? Книги останутся, но будут удалены из серии.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="compact-button red-btn">🗑️ Удалить</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Мои серии книг</h1>
|
||||
<a href="/series/create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> Создать серию
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (empty($series)): ?>
|
||||
<div class="text-center py-5">
|
||||
<div class="mb-4">
|
||||
<i class="bi bi-collection fs-1 text-muted"></i>
|
||||
</div>
|
||||
<h3 class="h4 text-muted">Пока нет серий</h3>
|
||||
<p class="text-muted mb-4">
|
||||
Создайте свою первую серию, чтобы организовать книги в циклы и сериалы.
|
||||
</p>
|
||||
<div class="d-flex gap-2 justify-content-center">
|
||||
<a href="/series/create" class="btn btn-primary">Создать серию</a>
|
||||
<a href="/books" class="btn btn-outline-secondary">Перейти к книгам</a>
|
||||
</div>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-4">
|
||||
<?php foreach ($series as $ser): ?>
|
||||
<div class="col-lg-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<h5 class="card-title">
|
||||
<a href="/series/<?= $ser['id'] ?>/edit" class="text-decoration-none"><?= e($ser['title']) ?></a>
|
||||
</h5>
|
||||
<span class="badge bg-primary"><?= $ser['book_count'] ?? 0 ?> книг</span>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mb-3">
|
||||
Создана <?= date('d.m.Y', strtotime($ser['created_at'])) ?>
|
||||
<?php if ($ser['updated_at'] != $ser['created_at']): ?>
|
||||
• Обновлена <?= date('d.m.Y', strtotime($ser['updated_at'])) ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($ser['description'])): ?>
|
||||
<p class="card-text"><?= e($ser['description']) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row text-center mb-3">
|
||||
<div class="col-4">
|
||||
<div class="border-end">
|
||||
<div class="fw-bold text-primary"><?= $ser['book_count'] ?? 0 ?></div>
|
||||
<small class="text-muted">книг</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="border-end">
|
||||
<div class="fw-bold text-success"><?= number_format($ser['total_words'] ?? 0) ?></div>
|
||||
<small class="text-muted">слов</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div>
|
||||
<div class="fw-bold text-info">
|
||||
<?= $ser['book_count'] > 0 ? number_format(round($ser['total_words'] / $ser['book_count'])) : 0 ?>
|
||||
</div>
|
||||
<small class="text-muted">слов/книга</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<a href="/series/<?= $ser['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil"></i> Управление
|
||||
</a>
|
||||
<a href="/series/<?= $ser['id'] ?>/view" class="btn btn-outline-success btn-sm" target="_blank">
|
||||
<i class="bi bi-eye"></i> Публично
|
||||
</a>
|
||||
<form method="post" action="/series/<?= $ser['id'] ?>/delete"
|
||||
onsubmit="return confirm('Удалить серию? Книги останутся, но будут удалены из серии.')">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i> Удалить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
include 'views/layouts/footer.php';
|
||||
|
||||
@@ -3,119 +3,143 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div class="container">
|
||||
<article style="max-width: 800px; margin: 0 auto;">
|
||||
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid var(--muted-border-color); padding-bottom: 1rem;">
|
||||
<h1 style="margin-bottom: 0.5rem;"><?= e($series['title']) ?></h1>
|
||||
<p style="color: var(--muted-color); font-style: italic; margin-bottom: 0.5rem;">
|
||||
Серия книг от
|
||||
<a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
|
||||
</p>
|
||||
|
||||
<?php if ($series['description']): ?>
|
||||
<div style="background: var(--card-background-color); padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;">
|
||||
<?= e($series['description']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);">
|
||||
<span>Книг: <?= count($books) ?></span>
|
||||
<span>Глав: <?= $total_chapters ?></span>
|
||||
<span>Слов: <?= $total_words ?></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<?php if (empty($books)): ?>
|
||||
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>В этой серии пока нет опубликованных книг</h3>
|
||||
<p>Автор еще не опубликовал книги из этой серии</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="series-books">
|
||||
<h2 style="text-align: center; margin-bottom: 2rem;">Книги серии</h2>
|
||||
|
||||
<?php foreach ($books as $book): ?>
|
||||
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 8px;">
|
||||
<?php if ($book['cover_image']): ?>
|
||||
<div style="flex-shrink: 0;">
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="flex-shrink: 0;">
|
||||
<div class="cover-placeholder" style="width: 120px; height: 160px; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
|
||||
📚
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<header class="text-center mb-5">
|
||||
<h1 class="display-5 mb-3"><?= e($series['title']) ?></h1>
|
||||
|
||||
<p class="lead text-muted mb-3">
|
||||
Серия книг от
|
||||
<a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>" class="text-decoration-none">
|
||||
<?= e($author['display_name'] ?: $author['username']) ?>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<?php if ($series['description']): ?>
|
||||
<div class="bg-light p-4 rounded mb-4">
|
||||
<?= e($series['description']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-center gap-4 flex-wrap">
|
||||
<div class="text-center">
|
||||
<div class="h4 text-primary mb-0"><?= count($books) ?></div>
|
||||
<small class="text-muted">Книг</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h4 text-success mb-0"><?= $total_chapters ?></div>
|
||||
<small class="text-muted">Глав</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h4 text-warning mb-0"><?= $total_words ?></div>
|
||||
<small class="text-muted">Слов</small>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin-top: 0;">
|
||||
<?php if ($book['sort_order_in_series']): ?>
|
||||
<small style="color: var(--muted-color);">Книга <?= $book['sort_order_in_series'] ?></small><br>
|
||||
<?php endif; ?>
|
||||
<?= e($book['title']) ?>
|
||||
</h3>
|
||||
|
||||
<?php if ($book['genre']): ?>
|
||||
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($book['description']): ?>
|
||||
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
|
||||
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button">
|
||||
Читать
|
||||
</a>
|
||||
|
||||
<?php
|
||||
$bookModel = new Book($pdo);
|
||||
$book_stats = $bookModel->getBookStats($book['id'], true);
|
||||
?>
|
||||
|
||||
<small style="color: var(--muted-color);">
|
||||
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | Слов: <?= $book_stats['total_words'] ?? 0 ?>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;">
|
||||
<p style="color: var(--muted-color);">
|
||||
Серия создана в <?= e(APP_NAME) ?> •
|
||||
Автор: <a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
<?php if (empty($books)): ?>
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-collection fs-1 text-muted"></i>
|
||||
<h3 class="h4 text-muted mt-3">В этой серии пока нет опубликованных книг</h3>
|
||||
<p class="text-muted">Автор еще не опубликовал книги из этой серии</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="mb-5">
|
||||
<h2 class="h3 text-center mb-4">
|
||||
<i class="bi bi-book"></i> Книги серии
|
||||
</h2>
|
||||
|
||||
<?php foreach ($books as $book): ?>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-start">
|
||||
<?php if ($book['cover_image']): ?>
|
||||
<div class="col-md-3 text-center mb-3 mb-md-0">
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
class="img-fluid rounded shadow"
|
||||
style="max-height: 200px;"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<?php else: ?>
|
||||
<div class="col-12">
|
||||
<?php endif; ?>
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h3 class="h4 mb-0">
|
||||
<?php if ($book['sort_order_in_series']): ?>
|
||||
<small class="text-muted">Книга <?= $book['sort_order_in_series'] ?></small><br>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
<i class="bi bi-file-text text-success me-1"></i>
|
||||
<?php else: ?>
|
||||
<i class="bi bi-book text-primary me-1"></i>
|
||||
<?php endif; ?>
|
||||
<?= e($book['title']) ?>
|
||||
</h3>
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
<span class="text-success">(рассказ)</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($book['genre']): ?>
|
||||
<p class="text-muted mb-2"><em><?= e($book['genre']) ?></em></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($book['description']): ?>
|
||||
<p class="mb-3"><?= nl2br(e($book['description'])) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="btn btn-primary">
|
||||
<i class="bi bi-book"></i> Читать
|
||||
</a>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<?php
|
||||
$book_stats = $book_model->getBookStats($book['id'], true);
|
||||
?>
|
||||
<small class="text-muted">
|
||||
<?php if (!empty($book_stats['is_short_story'])): ?>
|
||||
<?= number_format($book_stats['total_words'] ?? 0) ?> слов
|
||||
<?php else: ?>
|
||||
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | <?= number_format($book_stats['total_words'] ?? 0) ?> слов
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<footer class="text-center mt-5 pt-4 border-top">
|
||||
<p class="text-muted">
|
||||
Серия создана в <?= e(APP_NAME) ?> •
|
||||
Автор: <a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.series-books article {
|
||||
.card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.series-books article:hover {
|
||||
.card: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;
|
||||
}
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -3,139 +3,139 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<h1>Мой профиль</h1>
|
||||
|
||||
<?php if ($message): ?>
|
||||
<div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-error' : 'alert-success' ?>">
|
||||
<?= e($message) ?>
|
||||
<div class="container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="h2">Мой профиль</h1>
|
||||
<a href="<?= SITE_URL ?>/dashboard" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Назад в панель
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="grid">
|
||||
<article>
|
||||
<h2>Основная информация</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Имя пользователя (нельзя изменить)
|
||||
</label>
|
||||
<input type="text" id="username" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Отображаемое имя *
|
||||
</label>
|
||||
<input type="text" id="display_name" name="display_name"
|
||||
value="<?= e($user['display_name'] ?? $user['username']) ?>"
|
||||
style="width: 100%;" required>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Email
|
||||
</label>
|
||||
<input type="email" id="email" name="email"
|
||||
value="<?= e($user['email'] ?? '') ?>"
|
||||
style="width: 100%;">
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<label for="bio" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
О себе (отображается на вашей публичной странице)
|
||||
</label>
|
||||
<textarea id="bio" name="bio"
|
||||
placeholder="Расскажите о себе, своих интересах, стиле письма..."
|
||||
rows="6"
|
||||
style="width: 100%;"><?= e($user['bio'] ?? '') ?></textarea>
|
||||
<small style="color: var(--muted-color);">
|
||||
Поддерживается Markdown форматирование
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="profile-buttons">
|
||||
<button type="submit" class="profile-button primary">
|
||||
💾 Сохранить изменения
|
||||
</button>
|
||||
<a href="<?= SITE_URL ?>/dashboard" class="profile-button secondary">
|
||||
↩️ Назад
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Аватарка</h2>
|
||||
|
||||
<div style="text-align: center; margin-bottom: 1.5rem;">
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
|
||||
alt="Аватарка"
|
||||
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid var(--primary);"
|
||||
onerror="this.style.display='none'">
|
||||
<?php else: ?>
|
||||
<div style="width: 200px; height: 200px; border-radius: 50%; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 4rem; margin: 0 auto;">
|
||||
<?= mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($message): ?>
|
||||
<div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-danger' : 'alert-success' ?>">
|
||||
<?= e($message) ?>
|
||||
</div>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="avatar" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
|
||||
Загрузить новую аватарку
|
||||
</label>
|
||||
<input type="file" id="avatar" name="avatar"
|
||||
accept="image/jpeg, image/png, image/gif, image/webp"
|
||||
style="height: 2.6rem;">
|
||||
<small style="color: var(--muted-color);">
|
||||
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB.
|
||||
Рекомендуемый размер: 200×200 пикселей.
|
||||
</small>
|
||||
|
||||
<?php if (!empty($avatar_error)): ?>
|
||||
<div style="color: #d32f2f; margin-top: 0.5rem;">
|
||||
❌ <?= e($avatar_error) ?>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Основная информация</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<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" value="<?= e($user['username']) ?>" disabled>
|
||||
</div>
|
||||
|
||||
<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($user['display_name'] ?? $user['username']) ?>" required>
|
||||
</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($user['email'] ?? '') ?>">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="bio" class="form-label">О себе</label>
|
||||
<textarea class="form-control" id="bio" name="bio"
|
||||
placeholder="Расскажите о себе, своих интересах, стиле письма..."
|
||||
rows="6"><?= e($user['bio'] ?? '') ?></textarea>
|
||||
<div class="form-text">
|
||||
Отображается на вашей публичной странице. Поддерживается Markdown форматирование.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> Сохранить изменения
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Аватарка</h5>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
|
||||
alt="Аватарка"
|
||||
class="rounded-circle mb-3 border border-3 border-primary"
|
||||
style="width: 150px; height: 150px; object-fit: cover;"
|
||||
onerror="this.style.display='none'">
|
||||
<?php else: ?>
|
||||
<div class="rounded-circle bg-primary bg-gradient d-flex align-items-center justify-content-center mx-auto mb-3"
|
||||
style="width: 150px; height: 150px;">
|
||||
<span class="text-white fw-bold fs-2">
|
||||
<?= mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="avatar" class="form-label">Загрузить новую аватарку</label>
|
||||
<input type="file" class="form-control" id="avatar" name="avatar"
|
||||
accept="image/jpeg, image/png, image/gif, image/webp">
|
||||
<div class="form-text">
|
||||
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB.
|
||||
</div>
|
||||
|
||||
<?php if (!empty($avatar_error)): ?>
|
||||
<div class="alert alert-danger mt-2">
|
||||
<i class="bi bi-exclamation-triangle"></i> <?= e($avatar_error) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-upload"></i> Загрузить аватарку
|
||||
</button>
|
||||
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<button type="submit" name="delete_avatar" value="1" class="btn btn-outline-danger">
|
||||
<i class="bi bi-trash"></i> Удалить аватарку
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="card-title mb-0">Информация об аккаунте</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank" class="btn btn-outline-primary w-100">
|
||||
<i class="bi bi-eye"></i> Посмотреть публичную страницу
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<p><strong>Дата регистрации:</strong><br>
|
||||
<small class="text-muted"><?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></small></p>
|
||||
<?php if ($user['last_login']): ?>
|
||||
<p><strong>Последний вход:</strong><br>
|
||||
<small class="text-muted"><?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<button type="submit" class="contrast" style="flex: 1;">
|
||||
📤 Загрузить аватарку
|
||||
</button>
|
||||
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<button type="submit" name="delete_avatar" value="1" class="secondary" style="flex: 1; background: #ff4444; border-color: #ff4444; color: white;">
|
||||
🗑️ Удалить аватарку
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<div style="margin-top: 1rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<p style="margin: 0; font-size: 0.9em; color: var(--muted-color);">
|
||||
<strong>Примечание:</strong> Аватарка отображается на вашей публичной странице автора
|
||||
</p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article>
|
||||
<h3>Информация об аккаунте</h3>
|
||||
<p><a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank" class="adaptive-button secondary">
|
||||
👁️ Посмотреть мою публичную страницу
|
||||
</a></p>
|
||||
<p><strong>Дата регистрации:</strong> <?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></p>
|
||||
<?php if ($user['last_login']): ?>
|
||||
<p><strong>Последний вход:</strong> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></p>
|
||||
<?php endif; ?>
|
||||
</article>
|
||||
|
||||
<?php include 'views/layouts/footer.php'; ?>
|
||||
@@ -3,153 +3,152 @@
|
||||
include 'views/layouts/header.php';
|
||||
?>
|
||||
|
||||
<div class="container" style="width:100%; margin-left: 0em; margin-right: 0em auto;">
|
||||
<article>
|
||||
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid var(--muted-border-color); padding-bottom: 1rem;">
|
||||
<!-- Аватарка автора -->
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
|
||||
alt="<?= e($user['display_name'] ?: $user['username']) ?>"
|
||||
style="width: 150px; height: 150px; border-radius: 50%; border: 3px solid var(--primary); object-fit: cover;"
|
||||
onerror="this.style.display='none'">
|
||||
<?php else: ?>
|
||||
<div style="width: 150px; height: 150px; border-radius: 50%; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem; margin: 0 auto;">
|
||||
<?= mb_substr(e($user['display_name'] ?: $user['username']), 0, 1) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h1 style="margin-bottom: 0.5rem;"><?= e($user['display_name'] ?: $user['username']) ?></h1>
|
||||
|
||||
<!-- Биография автора -->
|
||||
<?php if (!empty($user['bio'])): ?>
|
||||
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
|
||||
<?= e($user['bio']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- Статистика автора -->
|
||||
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);">
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 1.5em; font-weight: bold; color: var(--primary);"><?= $total_books ?></div>
|
||||
<div>Книг</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 1.5em; font-weight: bold; color: var(--success);"><?= $total_chapters ?></div>
|
||||
<div>Глав</div>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 1.5em; font-weight: bold; color: var(--warning);"><?= $total_words ?></div>
|
||||
<div>Слов</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2>
|
||||
|
||||
<?php if (empty($books)): ?>
|
||||
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;">
|
||||
<h3>У этого автора пока нет опубликованных книг</h3>
|
||||
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="author-books">
|
||||
<?php foreach ($books as $book): ?>
|
||||
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1.5rem; background: var(--card-background-color); border-radius: 8px;">
|
||||
<?php if ($book['cover_image']): ?>
|
||||
<div style="flex-shrink: 0;">
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);"
|
||||
<div class="container py-4">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-10">
|
||||
<article class="card border-0 shadow-sm">
|
||||
<div class="card-body p-4">
|
||||
<header class="text-center mb-5">
|
||||
<!-- Аватарка автора -->
|
||||
<div class="mb-4">
|
||||
<?php if (!empty($user['avatar'])): ?>
|
||||
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
|
||||
alt="<?= e($user['display_name'] ?: $user['username']) ?>"
|
||||
class="rounded-circle border border-4 border-primary"
|
||||
style="width: 150px; height: 150px; object-fit: cover;"
|
||||
onerror="this.style.display='none'">
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div style="flex-shrink: 0;">
|
||||
<div class="cover-placeholder" style="width: 120px; height: 160px; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
|
||||
📚
|
||||
<?php else: ?>
|
||||
<div class="rounded-circle bg-primary bg-gradient d-flex align-items-center justify-content-center mx-auto"
|
||||
style="width: 150px; height: 150px;">
|
||||
<span class="text-white fw-bold display-6">
|
||||
<?= mb_substr(e($user['display_name'] ?: $user['username']), 0, 1) ?>
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<h1 class="display-5 mb-2"><?= e($user['display_name'] ?: $user['username']) ?></h1>
|
||||
|
||||
<!-- Биография автора -->
|
||||
<?php if (!empty($user['bio'])): ?>
|
||||
<div class="bg-light p-4 rounded mb-4">
|
||||
<?= e($user['bio']) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div style="flex: 1;">
|
||||
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
|
||||
|
||||
<?php if ($book['genre']): ?>
|
||||
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($book['description']): ?>
|
||||
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$book_stats = $bookModel->getBookStats($book['id'], true);
|
||||
$chapter_count = $book_stats['chapter_count'] ?? 0;
|
||||
$word_count = $book_stats['total_words'] ?? 0;
|
||||
?>
|
||||
|
||||
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
|
||||
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button">
|
||||
Читать книгу
|
||||
</a>
|
||||
|
||||
<small style="color: var(--muted-color);">
|
||||
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?>
|
||||
</small>
|
||||
<!-- Статистика автора -->
|
||||
<div class="d-flex justify-content-center gap-4 flex-wrap">
|
||||
<div class="text-center">
|
||||
<div class="h3 text-primary mb-0"><?= $total_books ?></div>
|
||||
<small class="text-muted">Книг</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h3 text-success mb-0"><?= $total_chapters ?></div>
|
||||
<small class="text-muted">Глав</small>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<div class="h3 text-warning mb-0"><?= $total_words ?></div>
|
||||
<small class="text-muted">Слов</small>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</header>
|
||||
|
||||
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;">
|
||||
<p style="color: var(--muted-color);">
|
||||
Страница автора создана в <?= e(APP_NAME) ?> •
|
||||
<?= date('Y') ?>
|
||||
</p>
|
||||
</footer>
|
||||
</article>
|
||||
<h2 class="h3 text-center mb-4">Публикации автора</h2>
|
||||
|
||||
<?php if (empty($books)): ?>
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-journal-bookmark fs-1 text-muted"></i>
|
||||
<h3 class="h4 text-muted mt-3">У этого автора пока нет опубликованных книг</h3>
|
||||
<p class="text-muted">Следите за обновлениями, скоро здесь появятся новые произведения!</p>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<div class="row g-4">
|
||||
<?php foreach ($books as $book): ?>
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start mb-3">
|
||||
<?php if ($book['cover_image']): ?>
|
||||
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
|
||||
alt="<?= e($book['title']) ?>"
|
||||
class="rounded me-3"
|
||||
style="width: 60px; height: 80px; object-fit: cover;"
|
||||
onerror="this.style.display='none'">
|
||||
<?php else: ?>
|
||||
<div class="bg-primary bg-gradient rounded d-flex align-items-center justify-content-center me-3"
|
||||
style="width: 60px; height: 80px;">
|
||||
<i class="bi bi-book text-white"></i>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="flex-grow-1">
|
||||
<h5 class="card-title mb-0">
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
<i class="bi bi-file-text text-success me-1"></i>
|
||||
<?php else: ?>
|
||||
<i class="bi bi-book text-primary me-1"></i>
|
||||
<?php endif; ?>
|
||||
<?= e($book['title']) ?>
|
||||
</h5>
|
||||
<?php if ($book['genre']): ?>
|
||||
<p class="text-muted small mb-1"><em><?= e($book['genre']) ?></em></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if ($book['description']): ?>
|
||||
<p class="card-text text-muted small mb-3">
|
||||
<?= nl2br(e($book['description'])) ?>
|
||||
</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
$book_stats = $book_model->getBookStats($book['id'], true);
|
||||
$chapter_count = $book_stats['chapter_count'] ?? 0;
|
||||
$word_count = $book_stats['total_words'] ?? 0;
|
||||
?>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="btn btn-primary btn-sm">
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
<i class="bi bi-file-text"></i> Читать рассказ
|
||||
<?php else: ?>
|
||||
<i class="bi bi-book"></i> Читать книгу
|
||||
<?php endif; ?>
|
||||
</a>
|
||||
<small class="text-muted">
|
||||
<?php if (!empty($book['is_short_story'])): ?>
|
||||
(рассказ) <?= number_format($word_count) ?> слов
|
||||
<?php else: ?>
|
||||
Глав: <?= $chapter_count ?> | <?= number_format($word_count) ?> слов
|
||||
<?php endif; ?>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<footer class="text-center mt-5 pt-4 border-top">
|
||||
<p class="text-muted">
|
||||
Страница автора создана в <?= e(APP_NAME) ?> • <?= date('Y') ?>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.author-books article {
|
||||
.card {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.author-books article:hover {
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 120px;
|
||||
height: 160px;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.author-books article {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.author-books .book-cover {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
header .author-stats {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||