Compare commits

...
22 Commits
Author SHA1 Message Date
mirivlad b69a110b97 fix editor for short_story and chapters 2026-05-01 21:53:43 +03:00
mirivlad ce6fffaa57 many fixes in design and add shirt story type for creation book 2026-05-01 20:51:42 +03:00
mirivlad 9294399517 fix txt export (paragraph problems) 2025-12-06 22:29:20 +03:00
mirivlad 18a338c2ce fix delete chapter in chapter list 2025-12-06 22:09:22 +03:00
mirivlad 3af0f18d55 Merge branch 'main' of https://git.mirv.top/mirivlad/web_writer 2025-11-29 15:24:37 +03:00
mirivlad 7fd1fb20c4 add fullscreen for editor 2025-11-29 15:24:11 +03:00
mirivlad 15ab9d1947 fix 2025-11-28 17:20:33 +08:00
mirivlad 5da459132b fix 2025-11-28 17:10:23 +08:00
mirivlad 6738576501 fix2 2025-11-28 17:08:10 +08:00
mirivlad 35ca6223cb fix 2025-11-28 17:07:08 +08:00
mirivlad b9d3426213 Add screenshots 2025-11-28 17:02:59 +08:00
mirivlad c4eae6b45f fix Readme 2025-11-28 16:48:35 +08:00
mirivlad de4d5ac595 Clear unneeded bootsrap themes files. Change Readme 2025-11-28 16:47:47 +08:00
mirivlad cd382c5c7b Add pagination in users administration. 2025-11-28 04:09:35 +08:00
mirivlad 70b75f8426 Add sorting for chapters in book. 2025-11-28 03:47:39 +08:00
mirivlad e1a5403936 fix formatting and add buttons for formating dialogs in selected text 2025-11-28 03:36:46 +08:00
mirivlad 2aa9b37d4e Add autoformater for book dialogues 2025-11-28 02:32:24 +08:00
mirivlad b32c26cb04 Also one small fix for public view 2025-11-27 15:05:40 +08:00
mirivlad 3edfa05f46 Fix public view for book 2025-11-27 15:04:25 +08:00
mirivlad 37676a3ccb Change info for autosave 2025-11-27 14:52:01 +08:00
mirivlad 39cda7814f Change CSS framework from PicoCSS to Bootstrap 5.3 2025-11-27 14:46:51 +08:00
mirivlad e51768622f Fix public access to private book view 2025-11-27 09:27:40 +08:00
82 changed files with 4769 additions and 3388 deletions
+6
View File
@@ -0,0 +1,6 @@
*.log
.env
config/config.php
*.lock
uploads/*
vendor/composer
+79 -15
View File
@@ -8,34 +8,97 @@
## 🚀 Возможности
- **Книги и серии:** создавайте серии и добавляйте книги с главами.
- **Редактор книг:** Markdown, автосохранение текста, интерактивное содержание.
- **Книги и серии:** создавайте серии и добавляйте книги с главами.
- **Сортировка книг и глав:** сортируйте книги в сериях, и главы в книгах в том порядке в каком вам нравится.
- **Редактор книг:**
- WYSIWYG редактор Quill.js
- Автосохранение текста
- Автоматическое форматирование книжных диалогов.
- **Темы оформления:** выберите в настройке тему которая вам больше по душе.
- **Предпросмотр книг:**
- **Автор:** видит все черновики и опубликованные главы.
- **Публичный доступ:** только опубликованные главы по ссылке с `shared_token`.
- **Обложки и аватары:** добавляйте изображения к книгам и профилям.
- **Экспорт:** PDF, DOCX, HTML, TXT.
- **Интерактивное содержание** в просмотре и экспорте книг
- **Администрирование пользователей:**
- Управление аккаунтами, активация/деактивация.
- При удалении пользователя удаляются все его книги.
- **Публичные ссылки:** делитесь `shared_token` для просмотра опубликованных глав.
- **Публичные ссылки:**
- Делитесь книгой по `shared_token`. Хотите чтобы её никто больше не увидел? Перегенерируйте токен!
- Давайте ссылку на свою страницу автора, где будет список опубликованных вами книг.
---
## ⚙️ Требования
## ✨ Скриншоты
**Панель управления и смена темы**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/5.jpg" target="_blank"><img src="assets/images/screens/5.jpg" alt="Панель управления" height="200" /></a>
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/3.jpg" target="_blank"><img src="assets/images/screens/3.jpg" alt="Смена темы" height="200" /></a>
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/2.jpg" target="_blank"><img src="assets/images/screens/2.jpg" alt="Панель управления после смены темы" height="200" /></a>
**Редактор текста**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/10.jpg" target="_blank"><img src="assets/images/screens/10.jpg" alt="Редактор текста Quill" height="200" /></a>
**Управление главами книги**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/9.jpg" target="_blank"><img src="assets/images/screens/9.jpg" alt="Управление главами книги" height="200" /></a>
**Управление сериями книг**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/6.jpg" target="_blank"><img src="assets/images/screens/6.jpg" alt="Управление сериями книг" height="200" /></a>
**Просмотр книги**
<a href="/mirivlad/web_writer/raw/branch/main/assets/images/screens/11.jpg" target="_blank"><img src="assets/images/screens/11.jpg" alt="Просмотр книги" height="200" /></a>
---
## ⚙️ Требования и используемые технологии
- **PHP:** 8.0 и выше
- **MySQL** с InnoDB и внешними ключами
- **PHP расширения:** `mbstring`, `json`, `PDO`
- Веб-сервер с правами на запись в папки `config/` и `uploads/`
- **Веб-сервер** (Nginx/Apache) с правами на запись в папки `config/` и `uploads/`
> Все библиотеки уже включены в `vendor/`. Composer не нужен.
- Самописный MVC на PHP + MySQL - максимальная простота и скорость
- Bootstrap5 и темы для него для UI
- Javascript - Quill.js (редактор текста), Sortable.js - для Drag'n'Drop сортировки. Стандартный bundle Bootstrap5. Всё остальное - самописный JS
- TCPDF и PhpWord - для экспорта книг
---
## 📁 Структура проекта
```
web_writer/
├── controllers/ # Контроллеры приложения
│ ├── AuthController.php
│ ├── BookController.php
│ ├── ChapterController.php
│ └── ...
├── models/ # Модели данных
│ ├── User.php
│ ├── Book.php
│ ├── Chapter.php
│ └── ...
├── views/ # Шаблоны представлений
├── assets/ # Статические файлы
│ ├── css/
│ ├── js/
│ └── bs # Темы для Bootstrap
├── uploads/ # Загружаемые файлы
│ ├── covers/ # Обложки книг
│ └── avatars/ # Аватары пользователей
├── vendor/ # Внешние библиотеки
├── config/ # Конфигурационные файлы
└── index.php # Точка входа
```
## 🛠 Установка
1. Скопируйте файлы на веб-сервер.
1. Скопируйте файлы на веб-сервер или склонируйте репозиторий.
2. Проверьте доступность папок `config/` и `uploads/` для записи.
3. Перейдите в браузере на `install.php` и следуйте шагам:
@@ -52,7 +115,7 @@
4. После успешной установки файл `config/config.php` будет сгенерирован автоматически.
5. Перейдите на главную страницу приложения (`index.php`) и войдите под админом.
6. **Не забудьте удалить или переместить файл install.php!!!**
6. **Не забудьте удалить или переместить файл install.php!!!** (не обязательно, но желательно)
---
@@ -61,10 +124,7 @@
Файл `config/config.php` содержит:
- Подключение к базе данных: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`
- Пути к файлам:
- `UPLOAD_PATH` — корневая папка загрузок
- `COVERS_PATH` / `COVERS_URL` — обложки книг
- `AVATARS_PATH` / `AVATARS_URL` — аватары пользователей
- Константы для системных путей
- Адрес сайта: `SITE_URL`
- Имя приложения: `APP_NAME` = "Web Writer"
@@ -72,11 +132,12 @@
## 🛠 Дальнейшее развитие
- Планирую вынести работу с сущностями (книги, главы, серии, пользователи) в контроллеры.
- Создать единую точку входа для приложения.
- Возможно, добавится поддержка комментариев для публичных книг и рейтинг. Но это не точно :)
---
## ❗ Поддержка
Все ошибки и предложения шлите в issue
@@ -85,4 +146,7 @@
## 📜 Лицензия
Приложение распространяется под лицензией [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html).
Приложение распространяется под лицензией [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.html).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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
-41
View File
@@ -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;
}
+21 -880
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

+89 -65
View File
@@ -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();
});
+400 -19
View File
@@ -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');
}
}
});
+9
View File
File diff suppressed because one or more lines are too long
View File
+124 -12
View File
@@ -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
]);
}
}
+11 -2
View File
@@ -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);
}
}
}
+107 -10
View File
@@ -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']
]);
}
+34 -1
View File
@@ -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);
+202 -89
View File
@@ -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 теги и нормализуем пробелы
+1
View File
@@ -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'] . ' — серия книг'
]);
}
+1
View File
@@ -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']) . ' — публичная страница'
]);
}
+375 -354
View File
@@ -1,355 +1,376 @@
<?php
// includes/functions.php
function is_logged_in() {
return isset($_SESSION['user_id']);
}
function require_login() {
if (!is_logged_in()) {
header('Location: login.php');
exit;
}
}
function generate_csrf_token() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token($token) {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
function e($string) {
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}
function redirect($url) {
header("Location: " . $url);
exit;
}
function transliterate($text) {
$cyr = [
'а','б','в','г','д','е','ё','ж','з','и','й','к','л','м','н','о','п',
'р','с','т','у','ф','х','ц','ч','ш','щ','ъ','ы','ь','э','ю','я',
'А','Б','В','Г','Д','Е','Ё','Ж','З','И','Й','К','Л','М','Н','О','П',
'Р','С','Т','У','Ф','Х','Ц','Ч','Ш','Щ','Ъ','Ы','Ь','Э','Ю','Я',
' ',',','!','?',':',';','"','\'','(',')','[',']','{','}'
];
$lat = [
'a','b','v','g','d','e','yo','zh','z','i','y','k','l','m','n','o','p',
'r','s','t','u','f','h','ts','ch','sh','sht','a','i','y','e','yu','ya',
'A','B','V','G','D','E','Yo','Zh','Z','I','Y','K','L','M','N','O','P',
'R','S','T','U','F','H','Ts','Ch','Sh','Sht','A','I','Y','E','Yu','Ya',
'_','_','_','_','_','_','_','_','_','_','_','_','_','_'
];
return str_replace($cyr, $lat, $text);
}
// Функция для очистки имени файла
function cleanFilename($filename) {
// Сначала транслитерируем
$filename = transliterate($filename);
// Затем убираем оставшиеся недопустимые символы
$filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename);
// Убираем множественные подчеркивания
$filename = preg_replace('/_{2,}/', '_', $filename);
// Убираем подчеркивания с начала и конца
$filename = trim($filename, '_');
// Если после очистки имя файла пустое, используем стандартное
if (empty($filename)) {
$filename = 'book';
}
// Ограничиваем длину имени файла
if (strlen($filename) > 100) {
$filename = substr($filename, 0, 100);
}
return $filename;
}
function handleCoverUpload($file, $book_id) {
global $pdo;
// Проверяем папку для загрузок
if (!file_exists(COVERS_PATH)) {
mkdir(COVERS_PATH, 0755, true);
}
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
$max_size = 5 * 1024 * 1024; // 5MB
// Проверка типа файла
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types)) {
return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения'];
}
// Проверка размера
if ($file['size'] > $max_size) {
return ['success' => false, 'error' => 'Размер изображения не должен превышать 5MB'];
}
// Проверка на ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']];
}
// Генерация уникального имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'cover_' . $book_id . '_' . time() . '.' . $extension;
$file_path = COVERS_PATH . $filename;
// Удаляем старую обложку если есть
$bookModel = new Book($pdo);
$bookModel->deleteCover($book_id);
// Сохраняем новую обложку
if (move_uploaded_file($file['tmp_name'], $file_path)) {
// Оптимизируем изображение
optimizeImage($file_path);
return ['success' => true, 'filename' => $filename];
} else {
return ['success' => false, 'error' => 'Не удалось сохранить файл'];
}
}
function optimizeImage($file_path) {
list($width, $height, $type) = getimagesize($file_path);
$max_width = 800;
$max_height = 1200;
if ($width > $max_width || $height > $max_height) {
// Вычисляем новые размеры
$ratio = $width / $height;
if ($max_width / $max_height > $ratio) {
$new_width = $max_height * $ratio;
$new_height = $max_height;
} else {
$new_width = $max_width;
$new_height = $max_width / $ratio;
}
// Создаем новое изображение
$new_image = imagecreatetruecolor($new_width, $new_height);
// Загружаем исходное изображение в зависимости от типа
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($file_path);
// Сохраняем прозрачность для PNG
imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127));
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($file_path);
break;
default:
return; // Не поддерживаемый тип
}
// Ресайз и сохраняем
imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($new_image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($new_image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($new_image, $file_path);
break;
}
// Освобождаем память
imagedestroy($source);
imagedestroy($new_image);
}
}
function handleAvatarUpload($file, $user_id) {
global $pdo;
// Проверяем папку для загрузок
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
$max_size = 2 * 1024 * 1024; // 2MB
// Проверка типа файла
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types)) {
return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения'];
}
// Проверка размера
if ($file['size'] > $max_size) {
return ['success' => false, 'error' => 'Размер изображения не должен превышать 2MB'];
}
// Проверка на ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']];
}
// Проверка реального типа файла по содержимому
$allowed_signatures = [
'image/jpeg' => "\xFF\xD8\xFF",
'image/png' => "\x89\x50\x4E\x47",
'image/gif' => "GIF",
'image/webp' => "RIFF"
];
$file_content = file_get_contents($file['tmp_name']);
$signature = substr($file_content, 0, 4);
$valid_signature = false;
foreach ($allowed_signatures as $type => $sig) {
if (strpos($signature, $sig) === 0) {
$valid_signature = true;
break;
}
}
if (!$valid_signature) {
return ['success' => false, 'error' => 'Неверный формат изображения'];
}
// Генерация уникального имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'avatar_' . $user_id . '_' . time() . '.' . $extension;
$file_path = AVATARS_PATH . $filename;
// Удаляем старый аватар если есть
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$old_file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($old_file_path)) {
unlink($old_file_path);
}
}
// Сохраняем новую аватарку
if (move_uploaded_file($file['tmp_name'], $file_path)) {
// Оптимизируем изображение
optimizeAvatar($file_path);
return ['success' => true, 'filename' => $filename];
} else {
return ['success' => false, 'error' => 'Не удалось сохранить файл'];
}
}
function optimizeAvatar($file_path) {
// Оптимизация аватарки - ресайз до 200x200
list($width, $height, $type) = getimagesize($file_path);
$max_size = 200;
if ($width > $max_size || $height > $max_size) {
// Вычисляем новые размеры
$ratio = $width / $height;
if ($ratio > 1) {
$new_width = $max_size;
$new_height = $max_size / $ratio;
} else {
$new_width = $max_size * $ratio;
$new_height = $max_size;
}
// Создаем новое изображение
$new_image = imagecreatetruecolor($new_width, $new_height);
// Загружаем исходное изображение в зависимости от типа
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($file_path);
// Сохраняем прозрачность для PNG
imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127));
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($file_path);
break;
case IMAGETYPE_WEBP:
$source = imagecreatefromwebp($file_path);
break;
default:
return; // Не поддерживаемый тип
}
// Ресайз и сохраняем
imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($new_image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($new_image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($new_image, $file_path);
break;
case IMAGETYPE_WEBP:
imagewebp($new_image, $file_path, 85);
break;
}
// Освобождаем память
imagedestroy($source);
imagedestroy($new_image);
}
}
function deleteUserAvatar($user_id) {
global $pdo;
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($file_path)) {
unlink($file_path);
}
// Обновляем запись в БД
$stmt = $pdo->prepare("UPDATE users SET avatar = NULL WHERE id = ?");
return $stmt->execute([$user_id]);
}
return true;
}
<?php
// includes/functions.php
function is_logged_in() {
return isset($_SESSION['user_id']);
}
function require_login() {
if (!is_logged_in()) {
header('Location: login.php');
exit;
}
}
function generate_csrf_token() {
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
function verify_csrf_token($token) {
return isset($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
}
function e($string) {
return htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
}
function redirect($url) {
header("Location: " . $url);
exit;
}
function transliterate($text) {
$cyr = [
'а','б','в','г','д','е','ё','ж','з','и','й','к','л','м','н','о','п',
'р','с','т','у','ф','х','ц','ч','ш','щ','ъ','ы','ь','э','ю','я',
'А','Б','В','Г','Д','Е','Ё','Ж','З','И','Й','К','Л','М','Н','О','П',
'Р','С','Т','У','Ф','Х','Ц','Ч','Ш','Щ','Ъ','Ы','Ь','Э','Ю','Я',
' ',',','!','?',':',';','"','\'','(',')','[',']','{','}'
];
$lat = [
'a','b','v','g','d','e','yo','zh','z','i','y','k','l','m','n','o','p',
'r','s','t','u','f','h','ts','ch','sh','sht','a','i','y','e','yu','ya',
'A','B','V','G','D','E','Yo','Zh','Z','I','Y','K','L','M','N','O','P',
'R','S','T','U','F','H','Ts','Ch','Sh','Sht','A','I','Y','E','Yu','Ya',
'_','_','_','_','_','_','_','_','_','_','_','_','_','_'
];
return str_replace($cyr, $lat, $text);
}
// Функция для очистки имени файла
function cleanFilename($filename) {
// Сначала транслитерируем
$filename = transliterate($filename);
// Затем убираем оставшиеся недопустимые символы
$filename = preg_replace('/[^a-zA-Z0-9_\-\.]/', '_', $filename);
// Убираем множественные подчеркивания
$filename = preg_replace('/_{2,}/', '_', $filename);
// Убираем подчеркивания с начала и конца
$filename = trim($filename, '_');
// Если после очистки имя файла пустое, используем стандартное
if (empty($filename)) {
$filename = 'book';
}
// Ограничиваем длину имени файла
if (strlen($filename) > 100) {
$filename = substr($filename, 0, 100);
}
return $filename;
}
function handleCoverUpload($file, $book_id) {
global $pdo;
// Проверяем папку для загрузок
if (!file_exists(COVERS_PATH)) {
mkdir(COVERS_PATH, 0755, true);
}
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
$max_size = 5 * 1024 * 1024; // 5MB
// Проверка типа файла
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types)) {
return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения'];
}
// Проверка размера
if ($file['size'] > $max_size) {
return ['success' => false, 'error' => 'Размер изображения не должен превышать 5MB'];
}
// Проверка на ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']];
}
// Генерация уникального имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'cover_' . $book_id . '_' . time() . '.' . $extension;
$file_path = COVERS_PATH . $filename;
// Удаляем старую обложку если есть
$bookModel = new Book($pdo);
$bookModel->deleteCover($book_id);
// Сохраняем новую обложку
if (move_uploaded_file($file['tmp_name'], $file_path)) {
// Оптимизируем изображение
optimizeImage($file_path);
return ['success' => true, 'filename' => $filename];
} else {
return ['success' => false, 'error' => 'Не удалось сохранить файл'];
}
}
function optimizeImage($file_path) {
list($width, $height, $type) = getimagesize($file_path);
$max_width = 800;
$max_height = 1200;
if ($width > $max_width || $height > $max_height) {
// Вычисляем новые размеры
$ratio = $width / $height;
if ($max_width / $max_height > $ratio) {
$new_width = $max_height * $ratio;
$new_height = $max_height;
} else {
$new_width = $max_width;
$new_height = $max_width / $ratio;
}
// Создаем новое изображение
$new_image = imagecreatetruecolor($new_width, $new_height);
// Загружаем исходное изображение в зависимости от типа
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($file_path);
// Сохраняем прозрачность для PNG
imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127));
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($file_path);
break;
default:
return; // Не поддерживаемый тип
}
// Ресайз и сохраняем
imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($new_image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($new_image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($new_image, $file_path);
break;
}
// Освобождаем память
imagedestroy($source);
imagedestroy($new_image);
}
}
function handleAvatarUpload($file, $user_id) {
global $pdo;
// Проверяем папку для загрузок
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
$max_size = 2 * 1024 * 1024; // 2MB
// Проверка типа файла
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types)) {
return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения'];
}
// Проверка размера
if ($file['size'] > $max_size) {
return ['success' => false, 'error' => 'Размер изображения не должен превышать 2MB'];
}
// Проверка на ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']];
}
// Проверка реального типа файла по содержимому
$allowed_signatures = [
'image/jpeg' => "\xFF\xD8\xFF",
'image/png' => "\x89\x50\x4E\x47",
'image/gif' => "GIF",
'image/webp' => "RIFF"
];
$file_content = file_get_contents($file['tmp_name']);
$signature = substr($file_content, 0, 4);
$valid_signature = false;
foreach ($allowed_signatures as $type => $sig) {
if (strpos($signature, $sig) === 0) {
$valid_signature = true;
break;
}
}
if (!$valid_signature) {
return ['success' => false, 'error' => 'Неверный формат изображения'];
}
// Генерация уникального имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'avatar_' . $user_id . '_' . time() . '.' . $extension;
$file_path = AVATARS_PATH . $filename;
// Удаляем старый аватар если есть
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$old_file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($old_file_path)) {
unlink($old_file_path);
}
}
// Сохраняем новую аватарку
if (move_uploaded_file($file['tmp_name'], $file_path)) {
// Оптимизируем изображение
optimizeAvatar($file_path);
return ['success' => true, 'filename' => $filename];
} else {
return ['success' => false, 'error' => 'Не удалось сохранить файл'];
}
}
function optimizeAvatar($file_path) {
// Оптимизация аватарки - ресайз до 200x200
list($width, $height, $type) = getimagesize($file_path);
$max_size = 200;
if ($width > $max_size || $height > $max_size) {
// Вычисляем новые размеры
$ratio = $width / $height;
if ($ratio > 1) {
$new_width = $max_size;
$new_height = $max_size / $ratio;
} else {
$new_width = $max_size * $ratio;
$new_height = $max_size;
}
// Создаем новое изображение
$new_image = imagecreatetruecolor($new_width, $new_height);
// Загружаем исходное изображение в зависимости от типа
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($file_path);
// Сохраняем прозрачность для PNG
imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127));
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($file_path);
break;
case IMAGETYPE_WEBP:
$source = imagecreatefromwebp($file_path);
break;
default:
return; // Не поддерживаемый тип
}
// Ресайз и сохраняем
imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($new_image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($new_image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($new_image, $file_path);
break;
case IMAGETYPE_WEBP:
imagewebp($new_image, $file_path, 85);
break;
}
// Освобождаем память
imagedestroy($source);
imagedestroy($new_image);
}
}
function deleteUserAvatar($user_id) {
global $pdo;
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($file_path)) {
unlink($file_path);
}
// Обновляем запись в БД
$stmt = $pdo->prepare("UPDATE users SET avatar = NULL WHERE id = ?");
return $stmt->execute([$user_id]);
}
return true;
}
function checkRateLimit($pdo, $ip, $max_attempts = 5, $timeframe = 60) {
$stmt = $pdo->prepare("
SELECT COUNT(*) FROM login_attempts
WHERE ip_address = ? AND attempted_at > NOW() - INTERVAL ? SECOND
");
$stmt->execute([$ip, $timeframe]);
$count = $stmt->fetchColumn();
return $count < $max_attempts;
}
function logLoginAttempt($pdo, $ip, $username) {
$stmt = $pdo->prepare("INSERT INTO login_attempts (ip_address, username) VALUES (?, ?)");
return $stmt->execute([$ip, $username]);
}
function clearLoginAttempts($pdo, $ip) {
$stmt = $pdo->prepare("DELETE FROM login_attempts WHERE ip_address = ?");
return $stmt->execute([$ip]);
}
?>
+3 -1
View File
@@ -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');
+24 -24
View File
@@ -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;
}
?>
+31 -14
View File
@@ -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);
+28 -27
View File
@@ -20,33 +20,31 @@ class Chapter {
}
public function findByBook($book_id) {
$stmt = $this->pdo->prepare("
SELECT * FROM chapters
WHERE book_id = ?
ORDER BY sort_order, created_at
");
$stmt = $this->pdo->prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY sort_order ASC, id ASC");
$stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function create($data) {
// Получаем максимальный порядковый номер для этой книги
$stmt = $this->pdo->prepare("SELECT MAX(sort_order) as max_order FROM chapters WHERE book_id = ?");
$stmt->execute([$data['book_id']]);
$result = $stmt->fetch();
$result = $stmt->fetch(PDO::FETCH_ASSOC);
$next_order = ($result['max_order'] ?? 0) + 1;
$word_count = $this->countWords($data['content']);
$stmt = $this->pdo->prepare("
INSERT INTO chapters (book_id, title, content, sort_order, word_count, status)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO chapters (book_id, title, content, word_count, sort_order, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW())
");
$word_count = $this->countWords($data['content']);
return $stmt->execute([
$data['book_id'],
$data['title'],
$data['content'],
$next_order,
$word_count,
$next_order,
$data['status'] ?? 'draft'
]);
}
@@ -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;
}
}
}
?>
+27 -15
View File
@@ -8,16 +8,21 @@ class Series {
$this->pdo = $pdo;
}
public function findById($id) {
public function findById($id) {
$stmt = $this->pdo->prepare("
SELECT s.*,
COUNT(b.id) as book_count,
COALESCE((
SELECT SUM(c.word_count)
FROM chapters c
JOIN books b2 ON c.book_id = b2.id
(
SELECT COALESCE(SUM(
CASE
WHEN b2.is_short_story = 1
THEN CHAR_LENGTH(b2.content) - CHAR_LENGTH(REPLACE(b2.content, ' ', '')) + 1
ELSE (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b2.id)
END
), 0)
FROM books b2
WHERE b2.series_id = s.id AND b2.published = 1
), 0) as total_words
) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id AND b.published = 1
WHERE s.id = ?
@@ -25,18 +30,19 @@ class Series {
");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}
public function findByUser($user_id, $include_stats = true) {
if ($include_stats) {
$sql = "
SELECT s.*,
COUNT(b.id) as book_count,
COALESCE((
SELECT SUM(c.word_count)
FROM chapters c
JOIN books b2 ON c.book_id = b2.id
WHERE b2.series_id = s.id AND b2.user_id = ?
COALESCE(SUM(
CASE
WHEN b.is_short_story = 1
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
ELSE (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b.id)
END
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
@@ -45,7 +51,7 @@ class Series {
ORDER BY s.created_at DESC
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id, $user_id]);
$stmt->execute([$user_id]);
} else {
$sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC";
$stmt = $this->pdo->prepare($sql);
@@ -128,8 +134,14 @@ class Series {
$sql = "
SELECT
COUNT(b.id) as book_count,
COALESCE(SUM(stats.chapter_count), 0) as chapter_count,
COALESCE(SUM(stats.total_words), 0) as total_words
COALESCE(SUM(
CASE WHEN b.is_short_story = 1 THEN 0 ELSE stats.chapter_count END
), 0) as chapter_count,
COALESCE(SUM(
CASE WHEN b.is_short_story = 1
THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
ELSE COALESCE(stats.total_words, 0) END
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
LEFT JOIN (
+18
View File
@@ -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);
}
}
?>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

+88 -71
View File
@@ -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'; ?>
+187 -78
View File
@@ -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">&laquo;</span>
</a>
</li>
<!-- Элементы пагинации -->
<?php foreach ($pagination as $item): ?>
<?php if ($item['type'] === 'ellipsis'): ?>
<li class="page-item disabled">
<span class="page-link"><?= $item['label'] ?></span>
</li>
<?php else: ?>
<li class="page-item <?= $item['active'] ? 'active' : '' ?>">
<a class="page-link" href="?page=<?= $item['page'] ?>&per_page=<?= $per_page ?>">
<?= $item['label'] ?>
</a>
</li>
<?php endif; ?>
<?php endforeach; ?>
<!-- Кнопка "Вперед" -->
<li class="page-item <?= $current_page >= $total_pages ? 'disabled' : '' ?>">
<a class="page-link" href="?page=<?= $current_page + 1 ?>&per_page=<?= $per_page ?>" aria-label="Следующая">
<span aria-hidden="true">&raquo;</span>
</a>
</li>
</ul>
</nav>
<!-- Информация о странице -->
<div class="text-center mt-3">
<p class="text-muted">
Страница <?= $current_page ?> из <?= $total_pages ?>
<?php if ($total_pages > 1): ?>
| Перейти:
<form method="get" class="d-inline">
<input type="hidden" name="per_page" value="<?= $per_page ?>">
<input type="number" name="page" min="1" max="<?= $total_pages ?>" value="<?= $current_page ?>"
class="form-control d-inline-block" style="width: 80px;"
onchange="this.form.submit()">
</form>
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<?php endif; ?>
</div>
<style>
.form-control[type="number"] {
display: inline-block;
width: auto;
}
</style>
<?php include 'views/layouts/footer.php'; ?>
+42 -37
View File
@@ -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>
+79 -73
View File
@@ -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>
+156 -78
View File
@@ -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'; ?>
+199 -217
View File
@@ -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>
+163 -108
View File
@@ -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'; ?>
+167 -128
View File
@@ -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>
+60 -49
View File
@@ -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'; ?>
+109 -73
View File
@@ -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);
+268 -69
View File
@@ -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'; ?>
+82 -46
View File
@@ -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>
+298 -108
View File
@@ -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>
+27 -11
View File
@@ -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>
+78 -8
View File
@@ -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>
&copy; <?= 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">
&copy; <?= 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 || 'Вы уверены?');
+174 -55
View File
@@ -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; ?>
+61 -47
View File
@@ -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'; ?>
+127 -124
View File
@@ -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(() => {
+90 -75
View File
@@ -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';
+127 -103
View File
@@ -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>
+128 -128
View File
@@ -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'; ?>
+131 -132
View File
@@ -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>