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
+77 -13
View File
@@ -9,33 +9,96 @@
## 🚀 Возможности ## 🚀 Возможности
- **Книги и серии:** создавайте серии и добавляйте книги с главами. - **Книги и серии:** создавайте серии и добавляйте книги с главами.
- **Редактор книг:** Markdown, автосохранение текста, интерактивное содержание. - **Сортировка книг и глав:** сортируйте книги в сериях, и главы в книгах в том порядке в каком вам нравится.
- **Редактор книг:**
- WYSIWYG редактор Quill.js
- Автосохранение текста
- Автоматическое форматирование книжных диалогов.
- **Темы оформления:** выберите в настройке тему которая вам больше по душе.
- **Предпросмотр книг:** - **Предпросмотр книг:**
- **Автор:** видит все черновики и опубликованные главы. - **Автор:** видит все черновики и опубликованные главы.
- **Публичный доступ:** только опубликованные главы по ссылке с `shared_token`. - **Публичный доступ:** только опубликованные главы по ссылке с `shared_token`.
- **Обложки и аватары:** добавляйте изображения к книгам и профилям. - **Обложки и аватары:** добавляйте изображения к книгам и профилям.
- **Экспорт:** PDF, DOCX, HTML, TXT. - **Экспорт:** 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 и выше - **PHP:** 8.0 и выше
- **MySQL** с InnoDB и внешними ключами - **MySQL** с InnoDB и внешними ключами
- **PHP расширения:** `mbstring`, `json`, `PDO` - **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/` для записи. 2. Проверьте доступность папок `config/` и `uploads/` для записи.
3. Перейдите в браузере на `install.php` и следуйте шагам: 3. Перейдите в браузере на `install.php` и следуйте шагам:
@@ -52,7 +115,7 @@
4. После успешной установки файл `config/config.php` будет сгенерирован автоматически. 4. После успешной установки файл `config/config.php` будет сгенерирован автоматически.
5. Перейдите на главную страницу приложения (`index.php`) и войдите под админом. 5. Перейдите на главную страницу приложения (`index.php`) и войдите под админом.
6. **Не забудьте удалить или переместить файл install.php!!!** 6. **Не забудьте удалить или переместить файл install.php!!!** (не обязательно, но желательно)
--- ---
@@ -61,10 +124,7 @@
Файл `config/config.php` содержит: Файл `config/config.php` содержит:
- Подключение к базе данных: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME` - Подключение к базе данных: `DB_HOST`, `DB_USER`, `DB_PASS`, `DB_NAME`
- Пути к файлам: - Константы для системных путей
- `UPLOAD_PATH` — корневая папка загрузок
- `COVERS_PATH` / `COVERS_URL` — обложки книг
- `AVATARS_PATH` / `AVATARS_URL` — аватары пользователей
- Адрес сайта: `SITE_URL` - Адрес сайта: `SITE_URL`
- Имя приложения: `APP_NAME` = "Web Writer" - Имя приложения: `APP_NAME` = "Web Writer"
@@ -72,11 +132,12 @@
## 🛠 Дальнейшее развитие ## 🛠 Дальнейшее развитие
- Планирую вынести работу с сущностями (книги, главы, серии, пользователи) в контроллеры. - Возможно, добавится поддержка комментариев для публичных книг и рейтинг. Но это не точно :)
- Создать единую точку входа для приложения.
--- ---
## ❗ Поддержка ## ❗ Поддержка
Все ошибки и предложения шлите в issue Все ошибки и предложения шлите в issue
@@ -86,3 +147,6 @@
## 📜 Лицензия ## 📜 Лицензия
Приложение распространяется под лицензией [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 * https://quilljs.com
* Copyright (c) 2017-2024, Slab * Copyright (c) 2017-2024, Slab
* Copyright (c) 2014, Jason Chen * 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 { .theme-switcher {
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;
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
} }
.dashboard-item { .theme-option {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
padding: 1rem;
text-align: center;
}
.dashboard-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
/* Центрируем welcome сообщение */
.welcome-message {
text-align: center;
padding: 3rem;
background: #f9f9f9;
border-radius: 8px;
margin: 2rem auto;
max-width: 800px;
}
.welcome-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 1.5rem;
}
/* ===== АДАПТИВНОСТЬ ===== */
@media (max-width: 768px) {
.container {
padding: 0 0.5rem;
}
.button-group {
flex-direction: column;
}
.dashboard-buttons {
flex-direction: column;
align-items: center;
}
.book-content {
font-size: 16px;
padding: 0 0.5rem;
}
.book-content h1 {
font-size: 1.6em;
}
.book-content h2 {
font-size: 1.4em;
}
.avatar, .avatar-placeholder {
width: 120px;
height: 120px;
}
.action-button {
min-width: 120px;
padding: 0.6rem 1rem;
}
.welcome-message {
padding: 2rem 1rem;
margin: 1rem 0.5rem;
}
}
@media (max-width: 480px) {
.book-content h1 {
font-size: 1.4em;
}
.avatar, .avatar-placeholder {
width: 100px;
height: 100px;
}
.action-button {
width: 100%;
min-width: auto;
}
.author-stats {
flex-direction: column;
gap: 1rem;
}
}
/* Стили для управления сериями */
.books-list {
border: 1px solid #e0e0e0;
border-radius: 4px;
background: #fafafa;
}
.book-item {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid #e0e0e0;
background: white;
transition: all 0.2s ease; transition: all 0.2s ease;
border-radius: 4px;
} }
.book-item:last-child { .theme-option:hover {
border-bottom: none; transform: translateX(5px);
}
.book-item:hover {
background: #f8f9fa;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.book-item.sortable-ghost {
opacity: 0.6;
background: #e3f2fd;
}
.book-item.sortable-chosen {
background: #e3f2fd;
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.book-drag-handle {
padding: 0 12px;
color: #666;
font-size: 1.2rem;
cursor: move;
user-select: none;
}
.book-drag-handle:hover {
color: #007bff;
}
.book-info {
flex: 1;
padding: 0 12px;
}
.book-info strong {
display: block;
margin-bottom: 4px;
color: #333;
}
.book-info small {
color: #666;
font-size: 0.8rem;
}
.book-actions {
display: flex;
gap: 8px;
} }
/* Адаптивность для мобильных */ /* Адаптивность для мобильных */
@media (max-width: 768px) { @media (max-width: 768px) {
.book-item { .theme-preview {
flex-direction: column; width: 16px;
align-items: flex-start; height: 16px;
gap: 10px;
}
.book-drag-handle {
align-self: flex-start;
}
.book-actions {
align-self: stretch;
justify-content: space-between;
}
.book-actions .compact-button {
flex: 1;
text-align: center;
} }
} }
.series-grid { /* editor styles */
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
.series-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 1.5rem;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.series-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
border-color: #007bff;
}
.series-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.series-header {
margin-bottom: 1rem;
}
.series-title {
font-size: 1.3rem;
font-weight: bold;
margin-bottom: 0.5rem;
color: #333;
}
.series-title a {
text-decoration: none;
color: inherit;
}
.series-title a:hover {
color: #007bff;
}
.series-meta {
color: #666;
font-size: 0.9rem;
}
.series-description {
color: #555;
line-height: 1.5;
margin-bottom: 1.5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.series-stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
margin-bottom: 1.5rem;
text-align: center;
}
.series-stat {
padding: 0.5rem;
}
.series-stat-number {
font-size: 1.4rem;
font-weight: bold;
color: #6f42c1;
display: block;
}
.series-stat-label {
font-size: 0.8rem;
color: #666;
display: block;
}
.series-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Пустое состояние */
.series-empty-state {
text-align: center;
padding: 3rem 2rem;
background: #f8f9fa;
border-radius: 8px;
border: 2px dashed #dee2e6;
}
.series-empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Адаптивность для серий */
@media (max-width: 768px) { @media (max-width: 768px) {
.series-grid { .writer-editor-container .ql-container {
grid-template-columns: 1fr; font-size: 16px !important; /* Предотвращает масштабирование в iOS */
} }
.series-stats-grid { .writer-editor-container .ql-toolbar {
grid-template-columns: repeat(3, 1fr); padding: 8px !important;
} }
.series-actions { .writer-editor-container .ql-toolbar .ql-formats {
flex-direction: column; margin-right: 8px !important;
}
.series-actions .compact-button {
width: 100%;
text-align: center;
} }
} }
@media (max-width: 480px) { /* Стили для полноэкранного режима */
.series-card { .ql-toolbar.ql-snow {
padding: 1rem;
}
.series-stats-grid {
grid-template-columns: 1fr;
gap: 0.25rem;
}
.series-stat {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
}
.series-stat-number {
font-size: 1.1rem;
}
}
/* ===== СТИЛИ ДЛЯ СПИСКА КНИГ ===== */
.books-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
.book-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease; transition: all 0.3s ease;
position: relative;
} }
.book-card:hover { .writer-editor-container.fullscreen {
transform: translateY(-2px); transition: all 0.3s ease;
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;
}
} }
.ql-editor p { .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

+29 -5
View File
@@ -1,7 +1,20 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const quill = window.quillEditorInstance; // Не запускать на странице создания книги
const textarea = window.quillTextarea; if (window.location.href.includes('/books/create')) {
if (!quill || !textarea) return; return;
}
// Ждём инициализации writerEditor
function initAutosave() {
const writerEditor = window.writerEditor;
if (!writerEditor || !writerEditor.quill) {
// Пробуем снова через 500ms
setTimeout(initAutosave, 500);
return;
}
const textarea = writerEditor.textarea;
if (!textarea) return;
let lastSavedContent = textarea.value; let lastSavedContent = textarea.value;
let saveTimeout; let saveTimeout;
@@ -33,11 +46,18 @@ document.addEventListener('DOMContentLoaded', () => {
const autoSave = () => { const autoSave = () => {
const currentContent = textarea.value; const currentContent = textarea.value;
// Не сохранять пустой контент
if (!currentContent || currentContent.trim() === '' || currentContent === '<p><br></p>') {
return;
}
if (currentContent === lastSavedContent) return; if (currentContent === lastSavedContent) return;
const form = document.getElementById('chapter-form'); const form = writerEditor.form;
if (!form) return;
const formData = new FormData(form); const formData = new FormData(form);
formData.append('autosave', 'true'); formData.append('autosave', 'true');
formData.append('content', currentContent);
showMessage('Сохранение...'); showMessage('Сохранение...');
@@ -63,11 +83,15 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}; };
quill.on('text-change', () => { writerEditor.quill.on('text-change', () => {
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
saveTimeout = setTimeout(autoSave, 2000); saveTimeout = setTimeout(autoSave, 2000);
}); });
// Периодическая автосохранение // Периодическая автосохранение
setInterval(autoSave, 30000); setInterval(autoSave, 30000);
}
// Запускаем
initAutosave();
}); });
+393 -12
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 { class WriterEditor {
constructor(formSelector = '#chapter-form', editorContainerId = 'quill-editor', textareaId = 'content') { constructor(formSelector = '#chapter-form', editorContainerId = 'quill-editor', textareaId = 'content') {
this.form = document.querySelector(formSelector); this.form = document.querySelector(formSelector);
this.editorContainer = document.getElementById(editorContainerId); this.editorContainer = document.getElementById(editorContainerId);
this.textarea = document.getElementById(textareaId); this.textarea = document.getElementById(textareaId);
this.isFullscreen = false;
this.originalStyles = {};
this.init(); this.init();
} }
init() { init() {
if (!this.editorContainer || !this.textarea || !this.form) return; if (!this.editorContainer || !this.textarea || !this.form) return;
this.quill = new Quill(this.editorContainer, { // Полный тулбар (одинаковый для глав и рассказов)
theme: 'snow', const toolbarOptions = [
modules: {
toolbar: [
[{ 'header': [1, 2, 3, 4, 5, 6, false] }], [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
['bold','italic','underline','strike'], ['bold','italic','underline','strike'],
[{ 'align': [] }],
[{ 'color': [] }, { 'background': [] }],
['blockquote','code-block'], ['blockquote','code-block'],
[{ 'list': 'ordered' }, { 'list': 'bullet' }], [{ 'list': 'ordered' }, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }], [{ 'script': 'sub'}, { 'script': 'super' }],
[{ 'indent': '-1'}, { 'indent': '+1' }], [{ 'indent': '-1'}, { 'indent': '+1' }],
['dialogue', 'undodialogue'],
[{ 'size': ['small', false, 'large', 'huge'] }], [{ 'size': ['small', false, 'large', 'huge'] }],
[{ 'font': [] }], [{ 'font': [] }],
['link','image','video'], ['link','image','video'],
['clean'] ['clean'],
], ['fullscreen']
history: { delay: 1000, maxStack: 100, userOnly: true } ];
this.quill = new Quill(this.editorContainer, {
theme: 'snow',
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
'dialogue': () => {
if (this.dialogueFormatter) {
this.dialogueFormatter.formatSelectionAsDialogue();
}
},
'undodialogue': () => {
if (this.dialogueFormatter) {
this.dialogueFormatter.unformatSelectionAsDialogue();
}
},
'fullscreen': () => {
this.toggleFullscreen();
}
}
},
history: { delay: 1000, maxStack: 100, userOnly: true },
keyboard: {
bindings: {
'list autofill': {
key: ' ',
format: ['list'],
handler: function(range, context) {
if (context.prefix && context.prefix.trim() === '-') {
return true;
}
return Quill.import('modules/keyboard').bindings['list autofill'].handler.call(this, range, context);
}
}
}
}
}, },
placeholder: 'Введите текст главы...' placeholder: 'Введите текст главы...'
}); });
// Загружаем текст this.dialogueFormatter = new DialogueFormatter(this.quill);
const rawContent = this.editorContainer.dataset.content || ''; const rawContent = this.editorContainer.dataset.content || '';
if (rawContent.trim()) this.quill.root.innerHTML = rawContent.trim(); if (rawContent.trim()) this.quill.root.innerHTML = rawContent.trim();
// Синхронизация с textarea setTimeout(() => this.formatExistingDialogues(), 100);
const sync = () => { const sync = () => {
let html = this.quill.root.innerHTML; let html = this.quill.root.innerHTML;
html = html.replace(/^(<p><br><\/p>)+/, '').replace(/(<p><br><\/p>)+$/, ''); html = html.replace(/^(<p><br><\/p>)+/, '').replace(/(<p><br><\/p>)+$/, '');
@@ -41,14 +193,243 @@ class WriterEditor {
}; };
this.quill.on('text-change', sync); 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.quillEditorInstance = this.quill;
window.quillTextarea = this.textarea; 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', () => { 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,24 +11,126 @@ class AdminController extends BaseController {
public function users() { public function users() {
$userModel = new User($this->pdo); $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', [ $this->render('admin/users', [
'users' => $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) { public function toggleUserStatus($user_id) {
if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) { if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Неверный метод запроса или токен безопасности"; $_SESSION['error'] = "Неверный метод запроса или токен безопасности";
$this->redirect('/admin/users'); $this->redirect('/admin/users');
return; return;
} }
// Сохраняем параметры пагинации
$page = $_GET['page'] ?? 1;
$per_page = $_GET['per_page'] ?? 10;
if ($user_id == $_SESSION['user_id']) { if ($user_id == $_SESSION['user_id']) {
$_SESSION['error'] = "Нельзя изменить статус собственного аккаунта"; $_SESSION['error'] = "Нельзя изменить статус собственного аккаунта";
$this->redirect('/admin/users'); $this->redirect("/admin/users?page=$page&per_page=$per_page");
return; return;
} }
@@ -37,7 +139,7 @@ class AdminController extends BaseController {
if (!$user) { if (!$user) {
$_SESSION['error'] = "Пользователь не найден"; $_SESSION['error'] = "Пользователь не найден";
$this->redirect('/admin/users'); $this->redirect("/admin/users?page=$page&per_page=$per_page");
return; return;
} }
@@ -48,7 +150,7 @@ class AdminController extends BaseController {
$_SESSION['error'] = "Ошибка при обновлении статуса"; $_SESSION['error'] = "Ошибка при обновлении статуса";
} }
$this->redirect('/admin/users'); $this->redirect("/admin/users?page=$page&per_page=$per_page");
} }
public function deleteUser($user_id) { public function deleteUser($user_id) {
@@ -58,9 +160,13 @@ class AdminController extends BaseController {
return; return;
} }
// Сохраняем параметры пагинации
$page = $_GET['page'] ?? 1;
$per_page = $_GET['per_page'] ?? 10;
if ($user_id == $_SESSION['user_id']) { if ($user_id == $_SESSION['user_id']) {
$_SESSION['error'] = "Нельзя удалить собственный аккаунт"; $_SESSION['error'] = "Нельзя удалить собственный аккаунт";
$this->redirect('/admin/users'); $this->redirect("/admin/users?page=$page&per_page=$per_page");
return; return;
} }
@@ -69,7 +175,7 @@ class AdminController extends BaseController {
if (!$user) { if (!$user) {
$_SESSION['error'] = "Пользователь не найден"; $_SESSION['error'] = "Пользователь не найден";
$this->redirect('/admin/users'); $this->redirect("/admin/users?page=$page&per_page=$per_page");
return; return;
} }
@@ -79,13 +185,17 @@ class AdminController extends BaseController {
$_SESSION['error'] = "Ошибка при удалении пользователя"; $_SESSION['error'] = "Ошибка при удалении пользователя";
} }
$this->redirect('/admin/users'); $this->redirect("/admin/users?page=$page&per_page=$per_page");
} }
public function addUser() { public function addUser() {
$error = ''; $error = '';
$success = ''; $success = '';
// Сохраняем параметры пагинации для возврата
$return_page = $_GET['page'] ?? 1;
$return_per_page = $_GET['per_page'] ?? 10;
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности"; $error = "Ошибка безопасности";
@@ -119,9 +229,9 @@ class AdminController extends BaseController {
]; ];
if ($userModel->create($data)) { if ($userModel->create($data)) {
$success = 'Пользователь успешно создан'; $_SESSION['success'] = 'Пользователь успешно создан';
// Очищаем поля формы $this->redirect("/admin/users?page=1&per_page=$return_per_page");
$_POST = []; return;
} else { } else {
$error = 'Ошибка при создании пользователя'; $error = 'Ошибка при создании пользователя';
} }
@@ -133,7 +243,9 @@ class AdminController extends BaseController {
$this->render('admin/add_user', [ $this->render('admin/add_user', [
'error' => $error, 'error' => $error,
'success' => $success, '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 { } else {
$username = trim($_POST['username'] ?? ''); $username = trim($_POST['username'] ?? '');
$password = $_POST['password'] ?? ''; $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 = 'Пожалуйста, введите имя пользователя и пароль'; $error = 'Пожалуйста, введите имя пользователя и пароль';
} else { } else {
$userModel = new User($this->pdo); $userModel = new User($this->pdo);
@@ -29,8 +33,12 @@ class AuthController extends BaseController {
if ($user && $userModel->verifyPassword($password, $user['password_hash'])) { if ($user && $userModel->verifyPassword($password, $user['password_hash'])) {
if (!$user['is_active']) { if (!$user['is_active']) {
$error = 'Ваш аккаунт деактивирован или ожидает активации администратором.'; $error = 'Ваш аккаунт деактивирован или ожидает активации администратором.';
logLoginAttempt($this->pdo, $ip, $username);
} else { } else {
// Успешный вход // Успешный вход - очищаем попытки
clearLoginAttempts($this->pdo, $ip);
// Обновляем сессию
session_regenerate_id(true); session_regenerate_id(true);
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];
@@ -45,6 +53,7 @@ class AuthController extends BaseController {
} }
} else { } else {
$error = 'Неверное имя пользователя или пароль'; $error = 'Неверное имя пользователя или пароль';
logLoginAttempt($this->pdo, $ip, $username);
} }
} }
} }
+102 -5
View File
@@ -36,6 +36,16 @@ class BookController extends BaseController {
$this->redirect('/books/create'); $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); $bookModel = new Book($this->pdo);
$data = [ $data = [
'title' => $title, 'title' => $title,
@@ -44,7 +54,9 @@ class BookController extends BaseController {
'user_id' => $_SESSION['user_id'], 'user_id' => $_SESSION['user_id'],
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null, '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, '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)) { if ($bookModel->create($data)) {
@@ -62,8 +74,15 @@ class BookController extends BaseController {
} }
} }
$_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : ""); // Если это рассказ с содержанием - редиректим на редактирование
// Если рассказ без содержания - тоже на редактирование (чтобы добавить текст)
if ($is_short_story) {
$_SESSION['success'] = "Рассказ успешно создан! Теперь можно добавить содержание.";
$this->redirect("/books/{$new_book_id}/edit"); $this->redirect("/books/{$new_book_id}/edit");
} else {
$_SESSION['success'] = "Книга успешно создана" . ($cover_error ? ", но возникла ошибка с обложкой: " . $cover_error : "");
$this->redirect("/books/{$new_book_id}/chapters/create");
}
} else { } else {
$_SESSION['error'] = "Ошибка при создании книги"; $_SESSION['error'] = "Ошибка при создании книги";
} }
@@ -91,11 +110,63 @@ class BookController extends BaseController {
$seriesModel = new Series($this->pdo); $seriesModel = new Series($this->pdo);
$series = $seriesModel->findByUser($_SESSION['user_id']); $series = $seriesModel->findByUser($_SESSION['user_id']);
$error = ''; $error = '';
$cover_error = ''; $cover_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { 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'] ?? '')) { if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$error = "Ошибка безопасности"; $error = "Ошибка безопасности";
} else { } else {
@@ -103,6 +174,9 @@ class BookController extends BaseController {
if (empty($title)) { if (empty($title)) {
$error = "Название книги обязательно"; $error = "Название книги обязательно";
} else { } else {
$is_short_story = isset($_POST['is_short_story']) ? 1 : 0;
$content = $is_short_story ? ($_POST['content'] ?? '') : null;
$data = [ $data = [
'title' => $title, 'title' => $title,
'description' => trim($_POST['description'] ?? ''), 'description' => trim($_POST['description'] ?? ''),
@@ -110,7 +184,9 @@ class BookController extends BaseController {
'user_id' => $_SESSION['user_id'], 'user_id' => $_SESSION['user_id'],
'series_id' => !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null, '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, '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'); $this->render('errors/404');
return; return;
} }
// Если это рассказ - не загружаем главы
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->getPublishedChapters($book['id']); $chapters = $chapterModel->getPublishedChapters($book['id']);
$is_short_story = false;
}
// Получаем информацию об авторе // Получаем информацию об авторе
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?"); $stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
@@ -263,20 +347,32 @@ class BookController extends BaseController {
'book' => $book, 'book' => $book,
'chapters' => $chapters, 'chapters' => $chapters,
'author' => $author, 'author' => $author,
'is_short_story' => $is_short_story,
'page_title' => $book['title'] 'page_title' => $book['title']
]); ]);
} }
public function viewAll($id) { public function viewAll($id) {
$this->requireLogin();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($this->pdo); $bookModel = new Book($this->pdo);
$chapterModel = new Chapter($this->pdo); $chapterModel = new Chapter($this->pdo);
$book = $bookModel->findById($id); $book = $bookModel->findByUserBook($id, $user_id);
if (!$book) { if (!$book) {
http_response_code(404); http_response_code(404);
$this->render('errors/404'); $this->render('errors/404');
return; return;
} }
// Если это рассказ - не загружаем главы
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->findByBook($book['id']); $chapters = $chapterModel->findByBook($book['id']);
$is_short_story = false;
}
// Получаем информацию об авторе // Получаем информацию об авторе
$stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?"); $stmt = $this->pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
@@ -287,6 +383,7 @@ class BookController extends BaseController {
'book' => $book, 'book' => $book,
'chapters' => $chapters, 'chapters' => $chapters,
'author' => $author, 'author' => $author,
'is_short_story' => $is_short_story,
'page_title' => $book['title'] '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) { function cleanChapterContent($content) {
// Удаляем лишние пробелы в начале и конце // Удаляем лишние пробелы в начале и конце
$content = trim($content); $content = trim($content);
+144 -31
View File
@@ -26,13 +26,19 @@ class ExportController extends BaseController {
$this->redirect('/books'); $this->redirect('/books');
} }
// Для автора - все главы // Для автора - все главы или контент рассказа
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->findByBook($book_id); $chapters = $chapterModel->findByBook($book_id);
$is_short_story = false;
}
// Получаем информацию об авторе // Получаем информацию об авторе
$author_name = $this->getAuthorName($book['user_id']); $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') { public function exportShared($share_token, $format = 'pdf') {
@@ -45,13 +51,19 @@ class ExportController extends BaseController {
$this->redirect('/'); $this->redirect('/');
} }
// Для публичного доступа - только опубликованные главы // Для публичного доступа - только опубликованные главы или контент рассказа
if ($book['is_short_story']) {
$chapters = [];
$is_short_story = true;
} else {
$chapters = $chapterModel->getPublishedChapters($book['id']); $chapters = $chapterModel->getPublishedChapters($book['id']);
$is_short_story = false;
}
// Получаем информацию об авторе // Получаем информацию об авторе
$author_name = $this->getAuthorName($book['user_id']); $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) { private function getAuthorName($user_id) {
@@ -68,21 +80,20 @@ class ExportController extends BaseController {
return "Неизвестный автор"; 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) { switch ($format) {
case 'pdf': case 'pdf':
$this->exportPDF($book, $chapters, $is_public, $author_name); $this->exportPDF($book, $chapters, $is_public, $author_name, $is_short_story);
break; break;
case 'docx': case 'docx':
$this->exportDOCX($book, $chapters, $is_public, $author_name); $this->exportDOCX($book, $chapters, $is_public, $author_name, $is_short_story);
break; break;
case 'html': case 'html':
$this->exportHTML($book, $chapters, $is_public, $author_name); $this->exportHTML($book, $chapters, $is_public, $author_name, $is_short_story);
break; break;
case 'txt': case 'txt':
$this->exportTXT($book, $chapters, $is_public, $author_name); $this->exportTXT($book, $chapters, $is_public, $author_name, $is_short_story);
break; break;
default: default:
$_SESSION['error'] = "Неверный формат экспорта"; $_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); $pdf = new TCPDF(PDF_PAGE_ORIENTATION, PDF_UNIT, PDF_PAGE_FORMAT, true, 'UTF-8', false);
@@ -157,9 +167,18 @@ class ExportController extends BaseController {
$pdf->Ln(10); $pdf->Ln(10);
} }
// Интерактивное оглавление // Если это рассказ - сразу выводим контент
if ($is_short_story && !empty($book['content'])) {
$pdf->SetFont('dejavusans', '', 11);
$pdf->writeHTML($book['content'], true, false, true, false, '');
$pdf->Ln(8);
}
// Интерактивное оглавление (только для обычных книг)
if (!$is_short_story) {
$chapterLinks = []; $chapterLinks = [];
if (!empty($chapters)) { if (!empty($chapters)) {
if (!empty($chapters)) {
$pdf->SetFont('dejavusans', 'B', 14); $pdf->SetFont('dejavusans', 'B', 14);
$pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C'); $pdf->Cell(0, 10, 'Оглавление', 0, 1, 'C');
$pdf->Ln(5); $pdf->Ln(5);
@@ -220,7 +239,7 @@ class ExportController extends BaseController {
exit; exit;
} }
function exportDOCX($book, $chapters, $is_public, $author_name) { function exportDOCX($book, $chapters, $is_public, $author_name, $is_short_story = false) {
$phpWord = new PhpWord(); $phpWord = new PhpWord();
@@ -271,8 +290,19 @@ class ExportController extends BaseController {
$section->addTextBreak(2); $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->addText('Оглавление', ['bold' => true, 'size' => 14], ['alignment' => 'center']);
$section->addTextBreak(1); $section->addTextBreak(1);
@@ -286,9 +316,12 @@ class ExportController extends BaseController {
} }
// Разделитель // Разделитель
if (!$is_short_story) {
$section->addPageBreak(); $section->addPageBreak();
}
// Главы с закладками // Главы с закладками (только для обычных книг)
if (!$is_short_story) {
foreach ($chapters as $index => $chapter) { foreach ($chapters as $index => $chapter) {
// Добавляем закладку для главы // Добавляем закладку для главы
$section->addBookmark("chapter_{$chapter['id']}"); $section->addBookmark("chapter_{$chapter['id']}");
@@ -332,7 +365,7 @@ class ExportController extends BaseController {
exit; 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 = '<!DOCTYPE html>
<html lang="ru"> <html lang="ru">
@@ -514,8 +547,13 @@ class ExportController extends BaseController {
$html .= '</div>'; $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 .= '<div class="table-of-contents">';
$html .= '<h3>Оглавление</h3>'; $html .= '<h3>Оглавление</h3>';
$html .= '<ul>'; $html .= '<ul>';
@@ -554,7 +592,7 @@ class ExportController extends BaseController {
exit; exit;
} }
function exportTXT($book, $chapters, $is_public, $author_name) { function exportTXT($book, $chapters, $is_public, $author_name, $is_short_story = false) {
$content = "=" . str_repeat("=", 80) . "=\n"; $content = "=" . str_repeat("=", 80) . "=\n";
$content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n"; $content .= str_pad($book['title'], 80, " ", STR_PAD_BOTH) . "\n";
$content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n"; $content .= str_pad($author_name, 80, " ", STR_PAD_BOTH) . "\n";
@@ -567,13 +605,36 @@ class ExportController extends BaseController {
if (!empty($book['description'])) { if (!empty($book['description'])) {
$content .= "ОПИСАНИЕ:\n"; $content .= "ОПИСАНИЕ:\n";
// Обрабатываем описание // Обрабатываем описание, сохраняя абзацы
$descriptionText = strip_tags($book['description']); $descriptionText = $this->htmlToPlainText($book['description']);
$content .= wordwrap($descriptionText, 144) . "\n\n"; $descriptionLines = explode("\n", $descriptionText);
foreach ($descriptionLines as $line) {
if (trim($line) !== '') {
$content .= wordwrap(trim($line), 144) . "\n";
}
}
$content .= "\n";
} }
// Оглавление // Если это рассказ - выводим контент сразу
if (!empty($chapters)) { 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 .= "ОГЛАВЛЕНИЕ:\n";
$content .= str_repeat("-", 60) . "\n"; $content .= str_repeat("-", 60) . "\n";
foreach ($chapters as $index => $chapter) { foreach ($chapters as $index => $chapter) {
@@ -585,17 +646,22 @@ class ExportController extends BaseController {
$content .= str_repeat("-", 144) . "\n\n"; $content .= str_repeat("-", 144) . "\n\n";
// Главы (только для обычных книг)
if (!$is_short_story) {
foreach ($chapters as $index => $chapter) { foreach ($chapters as $index => $chapter) {
$content .= $chapter['title'] . "\n"; $content .= $chapter['title'] . "\n";
$content .= str_repeat("-", 60) . "\n\n"; $content .= str_repeat("-", 60) . "\n\n";
// Получаем очищенный текст // Используем новый метод для преобразования HTML в текст с сохранением абзацев
$cleanContent = strip_tags($chapter['content']); $plainText = $this->htmlToPlainText($chapter['content']);
$paragraphs = $this->htmlToPlainTextParagraphs($cleanContent); $paragraphs = explode("\n", $plainText);
foreach ($paragraphs as $paragraph) { foreach ($paragraphs as $paragraph) {
if (!empty(trim($paragraph))) { $trimmed = trim($paragraph);
$content .= wordwrap($paragraph, 144) . "\n\n"; if (!empty($trimmed)) {
// Оборачиваем текст с учетом ширины строки
$wrapped = wordwrap($trimmed, 144);
$content .= $wrapped . "\n\n";
} }
} }
@@ -615,7 +681,54 @@ class ExportController extends BaseController {
echo $content; echo $content;
exit; 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 на абзацы // Функция для разбивки HTML на абзацы
function htmlToParagraphs($html) { function htmlToParagraphs($html) {
// Убираем HTML теги и нормализуем пробелы // Убираем HTML теги и нормализуем пробелы
+1
View File
@@ -185,6 +185,7 @@ class SeriesController extends BaseController {
'author' => $author, 'author' => $author,
'total_words' => $total_words, 'total_words' => $total_words,
'total_chapters' => $total_chapters, 'total_chapters' => $total_chapters,
'book_model' => $bookModel,
'page_title' => $series['title'] . ' — серия книг' 'page_title' => $series['title'] . ' — серия книг'
]); ]);
} }
+1
View File
@@ -108,6 +108,7 @@ class UserController extends BaseController {
'total_books' => $total_books, 'total_books' => $total_books,
'total_words' => $total_words, 'total_words' => $total_words,
'total_chapters' => $total_chapters, 'total_chapters' => $total_chapters,
'book_model'=>$bookModel,
'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница' 'page_title' => ($user['display_name'] ?: $user['username']) . ' — публичная страница'
]); ]);
} }
+21
View File
@@ -352,4 +352,25 @@ function deleteUserAvatar($user_id) {
return true; 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 <?php
// index.php - единая точка входа // index.php - единая точка входа
require_once 'config/config.php'; require_once __DIR__ . '/../config/config.php';
// Получаем путь к запрашиваемому ресурсу // Получаем путь к запрашиваемому ресурсу
$requestUri = $_SERVER['REQUEST_URI']; $requestUri = $_SERVER['REQUEST_URI'];
@@ -53,6 +53,7 @@ if (pathinfo($physicalPath, PATHINFO_EXTENSION) != 'php') {
// Простой роутер // Простой роутер
class Router { class Router {
private $routes = []; private $routes = [];
public $params = [];
public function add($pattern, $handler) { public function add($pattern, $handler) {
$this->routes[$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}/edit', 'ChapterController@edit');
$router->add('/chapters/{id}/delete', 'ChapterController@delete'); $router->add('/chapters/{id}/delete', 'ChapterController@delete');
$router->add('/chapters/preview', 'ChapterController@preview'); $router->add('/chapters/preview', 'ChapterController@preview');
$router->add('/books/{id}/chapters/update-order', 'ChapterController@updateOrder');
// Серии // Серии
$router->add('/series', 'SeriesController@index'); $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(), `updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`share_token` varchar(32) DEFAULT NULL, `share_token` varchar(32) DEFAULT NULL,
`published` tinyint(1) NOT NULL DEFAULT 0, `published` tinyint(1) NOT NULL DEFAULT 0,
`is_short_story` tinyint(1) NOT NULL DEFAULT 0,
`content` text DEFAULT NULL,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `share_token` (`share_token`), UNIQUE KEY `share_token` (`share_token`),
KEY `user_id` (`user_id`), KEY `user_id` (`user_id`),
@@ -198,32 +200,27 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} }
function generate_config($db) { 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 return <<<EOT
<?php <?php
// config/config.php - автоматически сгенерирован установщиком // config/config.php - автоматически сгенерирован установщиком
// Подключаем функции // Подключаем функции
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../public/includes/functions.php';
session_start();
// Настройки базы данных // Настройки базы данных
define('DB_HOST', '{$db['db_host']}'); define('DB_HOST', '{$db['db_host']}');
define('DB_USER', '{$db['db_user']}'); define('DB_USER', '{$db['db_user']}');
define('DB_PASS', '{$db['db_pass']}'); define('DB_PASS', '{$db['db_pass']}');
define('DB_NAME', '{$db['db_name']}'); 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('APP_NAME', 'Web Writer');
define('UPLOAD_PATH', __DIR__ . '/../public/uploads/');
define('CONTROLLERS_PATH', __DIR__ . '/../controllers/');
define('VIEWS_PATH', __DIR__ . '/../views/');
define('LAYOUTS_PATH', VIEWS_PATH . 'layouts/');
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('COVERS_PATH', UPLOAD_PATH . 'covers/'); define('COVERS_PATH', UPLOAD_PATH . 'covers/');
define('COVERS_URL', SITE_URL . '/uploads/covers/'); define('COVERS_URL', SITE_URL . '/uploads/covers/');
define('AVATARS_PATH', UPLOAD_PATH . 'avatars/'); define('AVATARS_PATH', UPLOAD_PATH . 'avatars/');
@@ -237,25 +234,28 @@ if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true); mkdir(AVATARS_PATH, 0755, true);
} }
// Подключение к базе данных // Подключение к базе данных
try { try {
\$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS); $pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
\$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch(PDOException \$e) { } catch(PDOException $e) {
error_log("DB Error: " . \$e->getMessage()); error_log("DB Error: " . $e->getMessage());
die("Ошибка подключения к базе данных"); 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) {
spl_autoload_register(function (\$class_name) { $controller_file = CONTROLLERS_PATH . $class_name . '.php';
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php'; if (file_exists($controller_file)) {
if (file_exists(\$model_file)) { require_once $controller_file;
require_once \$model_file;
} }
}); });
?>
EOT; EOT;
} }
?> ?>
+29 -12
View File
@@ -12,6 +12,11 @@ class Book {
$stmt->execute([$id]); $stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC); 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) { public function findByShareToken($share_token) {
$stmt = $this->pdo->prepare("SELECT * FROM books WHERE share_token = ?"); $stmt = $this->pdo->prepare("SELECT * FROM books WHERE share_token = ?");
@@ -23,7 +28,11 @@ class Book {
$sql = " $sql = "
SELECT b.*, SELECT b.*,
COUNT(c.id) as chapter_count, 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 FROM books b
LEFT JOIN chapters c ON b.id = c.book_id LEFT JOIN chapters c ON b.id = c.book_id
WHERE b.user_id = ? WHERE b.user_id = ?
@@ -40,10 +49,12 @@ class Book {
public function create($data) { public function create($data) {
$share_token = bin2hex(random_bytes(16)); $share_token = bin2hex(random_bytes(16));
$published = isset($data['published']) ? (int)$data['published'] : 0; $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(" $stmt = $this->pdo->prepare("
INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published) INSERT INTO books (title, description, genre, user_id, series_id, sort_order_in_series, share_token, published, is_short_story, content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"); ");
return $stmt->execute([ return $stmt->execute([
$data['title'], $data['title'],
@@ -53,12 +64,16 @@ class Book {
$data['series_id'] ?? null, $data['series_id'] ?? null,
$data['sort_order_in_series'] ?? null, $data['sort_order_in_series'] ?? null,
$share_token, $share_token,
$published $published,
$is_short_story,
$content
]); ]);
} }
public function update($id, $data) { public function update($id, $data) {
$published = isset($data['published']) ? (int)$data['published'] : 0; $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 полей // Преобразуем пустые строки в NULL для integer полей
$series_id = !empty($data['series_id']) ? (int)$data['series_id'] : null; $series_id = !empty($data['series_id']) ? (int)$data['series_id'] : null;
@@ -66,7 +81,7 @@ class Book {
$stmt = $this->pdo->prepare(" $stmt = $this->pdo->prepare("
UPDATE books 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 = ? WHERE id = ? AND user_id = ?
"); ");
return $stmt->execute([ return $stmt->execute([
@@ -76,6 +91,8 @@ class Book {
$series_id, // Теперь это либо integer, либо NULL $series_id, // Теперь это либо integer, либо NULL
$sort_order_in_series, // Теперь это либо integer, либо NULL $sort_order_in_series, // Теперь это либо integer, либо NULL
$published, $published,
$is_short_story,
$content,
$id, $id,
$data['user_id'] $data['user_id']
]); ]);
@@ -195,17 +212,17 @@ class Book {
public function getBookStats($book_id, $only_published_chapters = false) { public function getBookStats($book_id, $only_published_chapters = false) {
$sql = " $sql = "
SELECT SELECT
COUNT(c.id) as chapter_count, b.is_short_story,
COALESCE(SUM(c.word_count), 0) as total_words (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 FROM books b
LEFT JOIN chapters c ON b.id = c.book_id
WHERE b.id = ? WHERE b.id = ?
"; ";
if ($only_published_chapters) {
$sql .= " AND c.status = 'published'";
}
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
$stmt->execute([$book_id]); $stmt->execute([$book_id]);
return $stmt->fetch(PDO::FETCH_ASSOC); return $stmt->fetch(PDO::FETCH_ASSOC);
+26 -25
View File
@@ -20,33 +20,31 @@ class Chapter {
} }
public function findByBook($book_id) { public function findByBook($book_id) {
$stmt = $this->pdo->prepare(" $stmt = $this->pdo->prepare("SELECT * FROM chapters WHERE book_id = ? ORDER BY sort_order ASC, id ASC");
SELECT * FROM chapters
WHERE book_id = ?
ORDER BY sort_order, created_at
");
$stmt->execute([$book_id]); $stmt->execute([$book_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(PDO::FETCH_ASSOC);
} }
public function create($data) { public function create($data) {
// Получаем максимальный порядковый номер для этой книги
$stmt = $this->pdo->prepare("SELECT MAX(sort_order) as max_order FROM chapters WHERE book_id = ?"); $stmt = $this->pdo->prepare("SELECT MAX(sort_order) as max_order FROM chapters WHERE book_id = ?");
$stmt->execute([$data['book_id']]); $stmt->execute([$data['book_id']]);
$result = $stmt->fetch(); $result = $stmt->fetch(PDO::FETCH_ASSOC);
$next_order = ($result['max_order'] ?? 0) + 1; $next_order = ($result['max_order'] ?? 0) + 1;
$stmt = $this->pdo->prepare("
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']); $word_count = $this->countWords($data['content']);
$stmt = $this->pdo->prepare("
INSERT INTO chapters (book_id, title, content, sort_order, word_count, status)
VALUES (?, ?, ?, ?, ?, ?)
");
return $stmt->execute([ return $stmt->execute([
$data['book_id'], $data['book_id'],
$data['title'], $data['title'],
$data['content'], $data['content'],
$next_order,
$word_count, $word_count,
$next_order,
$data['status'] ?? 'draft' $data['status'] ?? 'draft'
]); ]);
} }
@@ -108,21 +106,24 @@ class Chapter {
return $stmt->fetchAll(PDO::FETCH_ASSOC); return $stmt->fetchAll(PDO::FETCH_ASSOC);
} }
// private function getAllChapters($book_id) { public function updateChaptersOrder($book_id, $chapter_ids) {
// $stmt = $this->pdo->prepare("SELECT id, content FROM chapters WHERE book_id = ?"); try {
// $stmt->execute([$book_id]); $this->pdo->beginTransaction();
// return $stmt->fetchAll(PDO::FETCH_ASSOC);
// }
// private function updateChapterContent($chapter_id, $content) { // Обновляем порядок для каждой главы
// $word_count = $this->countWords($content); foreach ($chapter_ids as $index => $chapter_id) {
// $stmt = $this->pdo->prepare(" $stmt = $this->pdo->prepare("UPDATE chapters SET sort_order = ? WHERE id = ? AND book_id = ?");
// UPDATE chapters $stmt->execute([$index + 1, $chapter_id, $book_id]);
// SET content = ?, word_count = ?, updated_at = CURRENT_TIMESTAMP }
// WHERE id = ?
// "); $this->pdo->commit();
// return $stmt->execute([$content, $word_count, $chapter_id]); return true;
// } } catch (Exception $e) {
$this->pdo->rollBack();
error_log("Error updating chapters order: " . $e->getMessage());
return false;
}
}
} }
?> ?>
+25 -13
View File
@@ -12,12 +12,17 @@ class Series {
$stmt = $this->pdo->prepare(" $stmt = $this->pdo->prepare("
SELECT s.*, SELECT s.*,
COUNT(b.id) as book_count, COUNT(b.id) as book_count,
COALESCE(( (
SELECT SUM(c.word_count) SELECT COALESCE(SUM(
FROM chapters c CASE
JOIN books b2 ON c.book_id = b2.id 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 WHERE b2.series_id = s.id AND b2.published = 1
), 0) as total_words ) as total_words
FROM series s FROM series s
LEFT JOIN books b ON s.id = b.series_id AND b.published = 1 LEFT JOIN books b ON s.id = b.series_id AND b.published = 1
WHERE s.id = ? WHERE s.id = ?
@@ -32,11 +37,12 @@ class Series {
$sql = " $sql = "
SELECT s.*, SELECT s.*,
COUNT(b.id) as book_count, COUNT(b.id) as book_count,
COALESCE(( COALESCE(SUM(
SELECT SUM(c.word_count) CASE
FROM chapters c WHEN b.is_short_story = 1
JOIN books b2 ON c.book_id = b2.id THEN CHAR_LENGTH(b.content) - CHAR_LENGTH(REPLACE(b.content, ' ', '')) + 1
WHERE b2.series_id = s.id AND b2.user_id = ? ELSE (SELECT COALESCE(SUM(word_count), 0) FROM chapters WHERE book_id = b.id)
END
), 0) as total_words ), 0) as total_words
FROM series s FROM series s
LEFT JOIN books b ON s.id = b.series_id LEFT JOIN books b ON s.id = b.series_id
@@ -45,7 +51,7 @@ class Series {
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
"; ";
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id, $user_id]); $stmt->execute([$user_id]);
} else { } else {
$sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC"; $sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC";
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
@@ -128,8 +134,14 @@ class Series {
$sql = " $sql = "
SELECT SELECT
COUNT(b.id) as book_count, COUNT(b.id) as book_count,
COALESCE(SUM(stats.chapter_count), 0) as chapter_count, COALESCE(SUM(
COALESCE(SUM(stats.total_words), 0) as total_words 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 FROM series s
LEFT JOIN books b ON s.id = b.series_id LEFT JOIN books b ON s.id = b.series_id
LEFT JOIN ( LEFT JOIN (
+18
View File
@@ -115,5 +115,23 @@ class User {
$stmt = $this->pdo->prepare($sql); $stmt = $this->pdo->prepare($sql);
return $stmt->execute($params); 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

+60 -43
View File
@@ -1,10 +1,15 @@
<?php include 'views/layouts/header.php'; ?> <?php include 'views/layouts/header.php'; ?>
<div class="container"> <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): ?> <?php if (isset($error) && $error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($error) ?> <?= e($error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -15,74 +20,86 @@
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="post" style="max-width: 500px; margin: 0 auto;"> <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() ?>"> <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;"> <div class="row">
Имя пользователя * <div class="col-md-6">
</label> <div class="mb-3">
<input type="text" id="username" name="username" <label for="username" class="form-label">Имя пользователя *</label>
<input type="text" class="form-control" id="username" name="username"
value="<?= e($_POST['username'] ?? '') ?>" value="<?= e($_POST['username'] ?? '') ?>"
placeholder="Введите имя пользователя" placeholder="Введите имя пользователя"
style="width: 100%;"
required required
pattern="[a-zA-Z0-9_]+" pattern="[a-zA-Z0-9_]+"
title="Только латинские буквы, цифры и символ подчеркивания"> title="Только латинские буквы, цифры и символ подчеркивания">
</div> </div>
<div style="margin-bottom: 1rem;"> </div>
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="col-md-6">
Отображаемое имя <div class="mb-3">
</label> <label for="display_name" class="form-label">Отображаемое имя</label>
<input type="text" id="display_name" name="display_name" <input type="text" class="form-control" id="display_name" name="display_name"
value="<?= e($_POST['display_name'] ?? '') ?>" value="<?= e($_POST['display_name'] ?? '') ?>"
placeholder="Введите отображаемое имя" placeholder="Введите отображаемое имя">
style="width: 100%;">
</div> </div>
<div style="margin-bottom: 1rem;"> </div>
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> </div>
Email
</label> <div class="mb-3">
<input type="email" id="email" name="email" <label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email"
value="<?= e($_POST['email'] ?? '') ?>" value="<?= e($_POST['email'] ?? '') ?>"
placeholder="Введите email" placeholder="Введите email">
style="width: 100%;">
</div> </div>
<div style="margin-bottom: 1rem;">
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="row">
Пароль * <div class="col-md-6">
</label> <div class="mb-3">
<input type="password" id="password" name="password" <label for="password" class="form-label">Пароль *</label>
<input type="password" class="form-control" id="password" name="password"
placeholder="Введите пароль (минимум 6 символов)" placeholder="Введите пароль (минимум 6 символов)"
style="width: 100%;"
required required
minlength="6"> minlength="6">
</div> </div>
<div style="margin-bottom: 1rem;"> </div>
<label for="password_confirm" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="col-md-6">
Подтверждение пароля * <div class="mb-3">
</label> <label for="password_confirm" class="form-label">Подтверждение пароля *</label>
<input type="password" id="password_confirm" name="password_confirm" <input type="password" class="form-control" id="password_confirm" name="password_confirm"
placeholder="Повторите пароль" placeholder="Повторите пароль"
style="width: 100%;"
required required
minlength="6"> minlength="6">
</div> </div>
<div style="margin-bottom: 1.5rem;"> </div>
<label for="is_active"> </div>
<input type="checkbox" id="is_active" name="is_active" value="1"
<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' ?>> <?= isset($_POST['is_active']) ? 'checked' : 'checked' ?>>
<label class="form-check-label" for="is_active">
Активировать пользователя сразу Активировать пользователя сразу
</label> </label>
</div> </div>
<div style="display: flex; gap: 10px;"> </div>
<button type="submit" class="contrast" style="flex: 1;">
👥 Добавить пользователя <div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-person-plus"></i> Добавить пользователя
</button> </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 href="<?= SITE_URL ?>/admin/users" class="btn btn-outline-danger">
❌ Отмена <i class="bi bi-x-circle"></i> Отмена
</a> </a>
</div> </div>
</form> </form>
</div> </div>
</div>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?> <?php include 'views/layouts/footer.php'; ?>
+143 -34
View File
@@ -1,45 +1,86 @@
<?php include 'views/layouts/header.php'; ?> <?php include 'views/layouts/header.php'; ?>
<div class="container" style="margin:0; width: auto;"> <div class="container-fluid">
<h1>Управление пользователями</h1> <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'])): ?> <?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success"> <div class="alert alert-success alert-dismissible fade show">
<?= e($_SESSION['success']) ?> <?= e($_SESSION['success']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['success']); ?> <?php unset($_SESSION['success']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION['error'])): ?> <?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error"> <div class="alert alert-danger alert-dismissible fade show">
<?= e($_SESSION['error']) ?> <?= e($_SESSION['error']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['error']); ?> <?php unset($_SESSION['error']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;"> <!-- Выбор количества элементов на странице -->
<h2 style="margin: 0;">Всего пользователей: <?= count($users) ?></h2> <div class="card mb-4">
<a href="<?= SITE_URL ?>/admin/add-user" class="action-button primary">➕ Добавить пользователя</a> <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> </div>
<?php if (empty($users)): ?> <?php if (empty($users)): ?>
<article style="text-align: center; padding: 2rem;"> <div class="text-center py-5">
<h3>Пользователи не найдены</h3> <div class="mb-4">
<p>Зарегистрируйте первого пользователя</p> <i class="bi bi-people fs-1 text-muted"></i>
<a href="<?= SITE_URL ?>/admin/add-user" role="button">📝 Добавить пользователя</a> </div>
</article> <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: ?> <?php else: ?>
<div style="overflow-x: auto; width:100%;"> <div class="card">
<table class="compact-table"> <div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th style="width: 5%;">ID</th> <th>ID</th>
<th style="width: 15%;">Имя пользователя</th> <th>Имя пользователя</th>
<th style="width: 20%;">Отображаемое имя</th> <th>Отображаемое имя</th>
<th style="width: 20%;">Email</th> <th>Email</th>
<th style="width: 15%;">Дата регистрации</th> <th>Дата регистрации</th>
<th style="width: 10%;">Статус</th> <th>Статус</th>
<th style="width: 15%;">Действия</th> <th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -47,9 +88,13 @@
<tr> <tr>
<td><?= $user['id'] ?></td> <td><?= $user['id'] ?></td>
<td> <td>
<strong><a href="<?= SITE_URL ?>/author/<?= $user['id'] ?>"><?= e($user['username']) ?></a></strong> <strong>
<a href="<?= SITE_URL ?>/author/<?= $user['id'] ?>" class="text-decoration-none">
<?= e($user['username']) ?>
</a>
</strong>
<?php if ($user['id'] == $_SESSION['user_id']): ?> <?php if ($user['id'] == $_SESSION['user_id']): ?>
<br><small style="color: #666;">(Вы)</small> <br><small class="text-muted">(Вы)</small>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td><?= e($user['display_name']) ?></td> <td><?= e($user['display_name']) ?></td>
@@ -57,32 +102,35 @@
<td> <td>
<small><?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></small> <small><?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></small>
<?php if ($user['last_login']): ?> <?php if ($user['last_login']): ?>
<br><small style="color: #666;">Вход: <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small> <br><small class="text-muted">Вход: <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<span style="color: <?= $user['is_active'] ? 'green' : 'red' ?>"> <span class="badge <?= $user['is_active'] ? 'bg-success' : 'bg-danger' ?>">
<?= $user['is_active'] ? '✅ Активен' : '❌ Неактивен' ?> <?= $user['is_active'] ? 'Активен' : 'Неактивен' ?>
</span> </span>
</td> </td>
<td> <td>
<?php if ($user['id'] != $_SESSION['user_id']): ?> <?php if ($user['id'] != $_SESSION['user_id']): ?>
<div style="display: flex; gap: 3px; flex-wrap: wrap;"> <div class="btn-group btn-group-sm">
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/toggle-status" style="display: inline;"> <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() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button secondary" title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>"> <button type="submit" class="btn btn-outline-<?= $user['is_active'] ? 'warning' : 'success' ?>"
<?= $user['is_active'] ? '⏸️' : '▶️' ?> title="<?= $user['is_active'] ? 'Деактивировать' : 'Активировать' ?>">
<i class="bi bi-<?= $user['is_active'] ? 'pause' : 'play' ?>"></i>
</button> </button>
</form> </form>
<form method="post" action="<?= SITE_URL ?>/admin/user/<?= $user['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить пользователя «<?= e($user['username']) ?>»? Все его книги и главы также будут удалены.');"> <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() ?>"> <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 type="submit" class="btn btn-outline-danger" title="Удалить">
🗑️ <i class="bi bi-trash"></i>
</button> </button>
</form> </form>
</div> </div>
<?php else: ?> <?php else: ?>
<small style="color: #666;">Текущий пользователь</small> <small class="text-muted">Текущий пользователь</small>
<?php endif; ?> <?php endif; ?>
</td> </td>
</tr> </tr>
@@ -90,7 +138,68 @@
</tbody> </tbody>
</table> </table>
</div> </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; ?> <?php endif; ?>
</div> </div>
<style>
.form-control[type="number"] {
display: inline-block;
width: auto;
}
</style>
<?php include 'views/layouts/footer.php'; ?> <?php include 'views/layouts/footer.php'; ?>
+28 -23
View File
@@ -4,45 +4,50 @@ include 'views/layouts/header.php';
?> ?>
<div class="container"> <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): ?> <?php if (isset($error) && $error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($error) ?> <?= e($error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="post" style="max-width: 400px; margin: 0 auto;"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;"> <div class="mb-3">
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="username" class="form-label">Имя пользователя</label>
Имя пользователя <input type="text" class="form-control" id="username" name="username"
</label>
<input type="text" id="username" name="username"
value="<?= e($_POST['username'] ?? '') ?>" value="<?= e($_POST['username'] ?? '') ?>"
placeholder="Введите имя пользователя" placeholder="Введите имя пользователя" required>
style="width: 100%;"
required>
</div> </div>
<div style="margin-bottom: 1.5rem;"> <div class="mb-4">
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="password" class="form-label">Пароль</label>
Пароль <input type="password" class="form-control" id="password" name="password"
</label> placeholder="Введите пароль" required>
<input type="password" id="password" name="password"
placeholder="Введите пароль"
style="width: 100%;"
required>
</div> </div>
<button type="submit" class="contrast" style="width: 100%;"> <button type="submit" class="btn btn-primary w-100 py-2">
🔑 Войти <i class="bi bi-box-arrow-in-right"></i> Войти
</button> </button>
</form> </form>
<div style="text-align: center; margin-top: 1rem;"> <div class="text-center mt-4">
<p>Нет аккаунта? <a href="<?= SITE_URL ?>/register">Зарегистрируйтесь здесь</a></p> <p class="text-muted">Нет аккаунта?
<a href="<?= SITE_URL ?>/register" class="text-decoration-none">Зарегистрируйтесь здесь</a>
</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
+54 -48
View File
@@ -1,13 +1,21 @@
// views/auth/register.php
<?php <?php
// views/auth/register.php // views/auth/register.php
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div class="container"> <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): ?> <?php if (isset($error) && $error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($error) ?> <?= e($error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
@@ -18,67 +26,65 @@ include 'views/layouts/header.php';
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="post" style="max-width: 400px; margin: 0 auto;"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;"> <div class="row">
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="col-md-6">
Имя пользователя * <div class="mb-3">
</label> <label for="username" class="form-label">Имя пользователя *</label>
<input type="text" id="username" name="username" <input type="text" class="form-control" id="username" name="username"
value="<?= e($_POST['username'] ?? '') ?>" value="<?= e($_POST['username'] ?? '') ?>"
placeholder="Введите имя пользователя" placeholder="Введите имя пользователя" required>
style="width: 100%;"
required>
</div> </div>
</div>
<div style="margin-bottom: 1rem;"> <div class="col-md-6">
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="mb-3">
Отображаемое имя <label for="display_name" class="form-label">Отображаемое имя</label>
</label> <input type="text" class="form-control" id="display_name" name="display_name"
<input type="text" id="display_name" name="display_name"
value="<?= e($_POST['display_name'] ?? '') ?>" value="<?= e($_POST['display_name'] ?? '') ?>"
placeholder="Как вас будут видеть другие" placeholder="Как вас будут видеть другие">
style="width: 100%;"> </div>
</div>
</div> </div>
<div style="margin-bottom: 1rem;"> <div class="mb-3">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="email" class="form-label">Email</label>
Email <input type="email" class="form-control" id="email" name="email"
</label>
<input type="email" id="email" name="email"
value="<?= e($_POST['email'] ?? '') ?>" value="<?= e($_POST['email'] ?? '') ?>"
placeholder="email@example.com" placeholder="email@example.com">
style="width: 100%;">
</div> </div>
<div style="margin-bottom: 1rem;"> <div class="row">
<label for="password" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="col-md-6">
Пароль * <div class="mb-3">
</label> <label for="password" class="form-label">Пароль *</label>
<input type="password" id="password" name="password" <input type="password" class="form-control" id="password" name="password"
placeholder="Не менее 6 символов" placeholder="Не менее 6 символов" required>
style="width: 100%;" </div>
required> </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> </div>
<div style="margin-bottom: 1.5rem;"> <button type="submit" class="btn btn-primary w-100 py-2">
<label for="password_confirm" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <i class="bi bi-person-plus"></i> Зарегистрироваться
Подтверждение пароля *
</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> </button>
</form> </form>
<div style="text-align: center; margin-top: 1rem;"> <div class="text-center mt-4">
<p>Уже есть аккаунт? <a href="<?= SITE_URL ?>/login">Войдите здесь</a></p> <p class="text-muted">Уже есть аккаунт?
<a href="<?= SITE_URL ?>/login" class="text-decoration-none">Войдите здесь</a>
</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
+128 -50
View File
@@ -2,47 +2,61 @@
// views/books/create.php // views/books/create.php
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<h1>Создание новой книги</h1>
<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>
<?php if (isset($_SESSION['error'])): ?> <?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($_SESSION['error']) ?> <?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?> <?php unset($_SESSION['error']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($error) && $error): ?> <?php if (isset($error) && $error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($error) ?> <?= e($error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($cover_error) && $cover_error): ?> <?php if (isset($cover_error) && $cover_error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
Ошибка загрузки обложки: <?= e($cover_error) ?> Ошибка загрузки обложки: <?= e($cover_error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="max-width: 100%; margin-bottom: 0.5rem;">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Название книги *
</label>
<input type="text" id="title" name="title"
value="<?= e($_POST['title'] ?? '') ?>"
placeholder="Введите название книги"
style="width: 100%; margin-bottom: 1.5rem;"
required>
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Жанр
</label>
<input type="text" id="genre" name="genre"
value="<?= e($_POST['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..."
style="width: 100%; margin-bottom: 1.5rem;">
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="card">
Серия <div class="card-body">
</label> <form method="post" enctype="multipart/form-data" id="book-form">
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;"> <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> <option value="">-- Без серии --</option>
<?php foreach ($series as $ser): ?> <?php foreach ($series as $ser): ?>
<option value="<?= $ser['id'] ?>" <?= (($_POST['series_id'] ?? '') == $ser['id']) ? 'selected' : '' ?>> <option value="<?= $ser['id'] ?>" <?= (($_POST['series_id'] ?? '') == $ser['id']) ? 'selected' : '' ?>>
@@ -50,38 +64,102 @@ include 'views/layouts/header.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </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>
<div style="margin-top: 1rem;"> </div>
<label for="published"> </div>
<input type="checkbox" id="published" name="published" value="1"
<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' : '' ?>> <?= (!empty($_POST['published']) && $_POST['published']) ? 'checked' : '' ?>>
<label class="form-check-label" for="published">
Опубликовать книгу (показывать на публичной странице автора) Опубликовать книгу (показывать на публичной странице автора)
</label> </label>
</div> </div>
</div> </div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;">
<button type="submit" class="contrast"> <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> </button>
<a href="<?= SITE_URL ?>/books" role="button" class="secondary"> <a href="<?= SITE_URL ?>/books" class="btn btn-outline-secondary">
❌ Отмена <i class="bi bi-x-circle"></i> Отмена
</a> </a>
</div> </div>
</form> </form>
</div>
</div>
</div>
</div>
<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'; ?> <?php include 'views/layouts/footer.php'; ?>
+145 -163
View File
@@ -2,35 +2,55 @@
// views/books/edit.php // views/books/edit.php
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h2">Редактирование книги</h1>
<a href="<?= SITE_URL ?>/books" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Назад к книгам
</a>
</div>
<?php if (isset($_SESSION['cover_error'])): ?> <?php if (isset($_SESSION['cover_error'])): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
Ошибка загрузки обложки: <?= e($_SESSION['cover_error']) ?> Ошибка загрузки обложки: <?= e($_SESSION['cover_error']) ?>
<?php unset($_SESSION['cover_error']); ?> <?php unset($_SESSION['cover_error']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<h1>Редактирование книги</h1>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data" id="book-form">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <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;"> <!-- Аккордеон для основной информации (свернут) -->
Название книги * <div class="accordion mb-4" id="bookInfoAccordion">
</label> <div class="accordion-item">
<input type="text" id="title" name="title" <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'] ?? '') ?>" value="<?= e($book['title'] ?? '') ?>"
placeholder="Введите название книги" placeholder="Введите название книги" required>
style="width: 100%; margin-bottom: 1.5rem;" </div>
required>
<label for="genre" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="row">
Жанр <div class="col-md-6">
</label> <div class="mb-3">
<input type="text" id="genre" name="genre" <label for="genre" class="form-label">Жанр</label>
<input type="text" class="form-control" id="genre" name="genre"
value="<?= e($book['genre'] ?? '') ?>" value="<?= e($book['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..." placeholder="Например: Фантастика, Роман, Детектив...">
style="width: 100%; margin-bottom: 1.5rem;"> </div>
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> </div>
Серия <div class="col-md-6">
</label> <div class="mb-3">
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;"> <label for="series_id" class="form-label">Серия</label>
<select class="form-select" id="series_id" name="series_id">
<option value="">-- Без серии --</option> <option value="">-- Без серии --</option>
<?php foreach ($series as $ser): ?> <?php foreach ($series as $ser): ?>
<option value="<?= $ser['id'] ?>" <?= ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '' ?>> <option value="<?= $ser['id'] ?>" <?= ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '' ?>>
@@ -38,149 +58,112 @@ include 'views/layouts/header.php';
</option> </option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<label for="sort_order_in_series" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> </div>
Порядок в серии </div>
</label> </div>
<input type="number" id="sort_order_in_series" name="sort_order_in_series"
<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'] ?? '') ?>" value="<?= e($book['sort_order_in_series'] ?? '') ?>"
placeholder="Номер по порядку в серии" placeholder="Номер по порядку в серии" min="1">
min="1" </div>
style="width: 100%; margin-bottom: 1.5rem;">
<!-- Обложка --> <div class="mb-3">
<div style="margin-bottom: 1.5rem;"> <label for="description" class="form-label">Описание книги</label>
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <textarea class="form-control" id="description" name="description"
Обложка книги placeholder="Краткое описание сюжета или аннотация..."
</label> 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'])): ?> <?php if (!empty($book['cover_image'])): ?>
<div style="margin-bottom: 1rem;"> <div class="mb-2">
<p><strong>Текущая обложка:</strong></p>
<img src="<?= COVERS_URL . e($book['cover_image']) ?>" <img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="Обложка" alt="Обложка"
style="max-width: 200px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);"> class="img-thumbnail"
<div style="margin-top: 0.5rem;"> style="max-height: 150px;">
<label style="display: inline-flex; align-items: center; gap: 0.5rem;"> <div class="form-text">Текущая обложка</div>
<input type="checkbox" name="delete_cover" value="1">
Удалить обложку
</label>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<input type="file" id="cover_image" name="cover_image" <input type="file" class="form-control" id="cover_image" name="cover_image"
accept="image/jpeg, image/png, image/gif, image/webp" accept="image/jpeg,image/png,image/gif,image/webp">
style="height: 2.6rem;"> <div class="form-text">
<small style="color: var(--muted-color);"> Разрешены форматы: JPG, PNG, GIF, WebP. Максимальный размер: 5MB. Оставьте пустым, чтобы оставить текущую обложку.
Разрешены: 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>
<?php endif; ?>
</div> </div>
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Описание книги <div class="mb-4">
</label> <div class="form-check">
<textarea id="description" name="description" <input class="form-check-input" type="checkbox" id="published" name="published" value="1"
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' : '' ?>> <?= !empty($book['published']) ? 'checked' : '' ?>>
<label class="form-check-label" for="published">
Опубликовать книгу (показывать на публичной странице автора) Опубликовать книгу (показывать на публичной странице автора)
</label> </label>
</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>
<?php if ($book): ?> <div class="mb-4">
<div style="margin-top: 0.5rem; padding: 0rem; background: var(--card-background-color); border-radius: 5px;"> <div class="form-check">
<h3>Публичная ссылка для чтения</h3> <input class="form-check-input" type="checkbox" id="is_short_story" name="is_short_story" value="1"
<div style="display: flex; gap: 5px; align-items: center; flex-wrap: wrap;"> <?= !empty($book['is_short_story']) ? 'checked' : '' ?> onchange="toggleStoryEditor()">
<input type="text" <label class="form-check-label" for="is_short_story">
id="share-link" Это рассказ (без разбивки на главы)
value="<?= e(SITE_URL . '/book/' . $book['share_token']) ?>" </label>
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> </div>
<p style="margin-top: 0.5rem; font-size: 0.8em; color: var(--muted-color);">
<strong>Примечание:</strong> В публичном просмотре отображаются только главы со статусом "Опубликована"
</p>
</div> </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> </div>
<p style="margin-top: 0.5rem; font-size: 0.9em; color: var(--muted-color);">
<strong>Примечание:</strong> Экспортируются все главы книги (включая черновики)
</p>
</div> </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> </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>
</div>
</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)): ?> <?php if (!empty($chapters)): ?>
<div style="overflow-x: auto;"> <div class="table-responsive">
<table style="width: 100%;"> <table class="table table-hover">
<thead> <thead>
<tr> <tr>
<th style="text-align: left; padding: 12px 8px;">Название</th> <th>Название</th>
<th style="text-align: left; padding: 12px 8px;">Статус</th> <th>Статус</th>
<th style="text-align: left; padding: 12px 8px;">Слов</th> <th>Слов</th>
<th style="text-align: left; padding: 12px 8px;">Действия</th> <th>Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($chapters as $chapter): ?> <?php foreach ($chapters as $chapter): ?>
<tr style="border-bottom: 1px solid var(--border-color);"> <tr>
<td style="padding: 12px 8px;"><?= e($chapter['title']) ?></td> <td><?= e($chapter['title']) ?></td>
<td style="padding: 12px 8px;"> <td>
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>"> <span class="badge <?= $chapter['status'] == 'published' ? 'bg-success' : 'bg-warning' ?>">
<?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?> <?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
</span> </span>
</td> </td>
<td style="padding: 12px 8px;"><?= $chapter['word_count'] ?></td> <td><?= $chapter['word_count'] ?></td>
<td style="padding: 12px 8px;"> <td>
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary"> <a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="btn btn-sm btn-outline-primary">
Редактировать <i class="bi bi-pencil"></i> Редактировать
</a> </a>
</td> </td>
</tr> </tr>
@@ -189,42 +172,41 @@ include 'views/layouts/header.php';
</table> </table>
</div> </div>
<?php else: ?> <?php else: ?>
<div style="text-align: center; padding: 0.5rem; background: var(--card-background-color); border-radius: 5px;"> <div class="text-center py-4">
<p style="margin-bottom: 1rem;">В этой книге пока нет глав.</p> <i class="bi bi-file-text fs-1 text-muted"></i>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button secondary" role="button"> <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> </a>
</div> </div>
<?php endif; ?>
</div> </div>
<div style="margin-top: 0.5rem; text-align: center;"> </div>
<form method="post" action="<?= SITE_URL ?>/books/<?= $book['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить книгу «<?= e($book['title']) ?>»? Все главы также будут удалены.');"> </div>
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="adaptive-button red-btn"> <!-- Кнопка сохранения -->
🗑️ Удалить книгу <div class="mb-4">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-check-circle"></i> Сохранить изменения
</button> </button>
</form>
</div> </div>
<?php endif; ?> </form>
<script src="<?= SITE_URL ?>/assets/js/editor.js"></script>
<script src="<?= SITE_URL ?>/assets/js/autosave.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const checkbox = document.getElementById('is_short_story');
// Копирование ссылки для чтения if (checkbox && checkbox.checked) {
window.copyShareLink = function() { toggleStoryEditor();
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> </script>
</div>
<?php include 'views/layouts/footer.php'; ?>
+118 -63
View File
@@ -3,102 +3,156 @@
include 'views/layouts/header.php'; 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>
<div style="display: flex; justify-content: left; margin-bottom: 1rem; flex-wrap: wrap; gap: 1rem;"> <h1 class="h2">Мои книги</h1>
<a href="<?= SITE_URL ?>/books/create" class="action-button primary" role="button">➕ Новая книга</a> <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)): ?> <?php if (!empty($books)): ?>
<a href="#" onclick="showDeleteAllConfirmation()" class="action-button delete">🗑️ Удалить все книги</a> <button type="button" onclick="showDeleteAllConfirmation()" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Удалить все
</button>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div>
<?php if (empty($books)): ?> <?php if (empty($books)): ?>
<article style="text-align: center; padding: 2rem;"> <div class="text-center py-5">
<h3>У вас пока нет книг</h3> <div class="mb-4">
<p>Создайте свою первую книгу и начните писать!</p> <i class="bi bi-journal-bookmark fs-1 text-muted"></i>
<a href="<?= SITE_URL ?>/books/create" role="button">📖 Создать первую книгу</a> </div>
</article> <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: ?> <?php else: ?>
<div class="books-grid"> <div class="row g-4">
<?php foreach ($books as $book): ?> <?php foreach ($books as $book): ?>
<article class="book-card"> <div class="col-xl-4 col-lg-6">
<!-- Обложка книги --> <div class="card h-100 book-card">
<div class="book-cover-container"> <div class="card-body">
<div class="d-flex align-items-start mb-3">
<?php if (!empty($book['cover_image'])): ?> <?php if (!empty($book['cover_image'])): ?>
<img src="<?= COVERS_URL . e($book['cover_image']) ?>" <img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>" alt="<?= e($book['title']) ?>"
class="book-cover" class="rounded me-3"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"> style="width: 60px; height: 80px; object-fit: cover;"
<div class="cover-placeholder" style="display: none;"> onerror="this.style.display='none'">
📚
</div>
<?php else: ?>
<div class="cover-placeholder">
📚
</div>
<?php endif; ?> <?php endif; ?>
<div class="flex-grow-1">
<!-- Статус книги --> <div class="d-flex justify-content-between align-items-start">
<div class="book-status <?= $book['published'] ? 'published' : 'draft' ?>"> <h5 class="card-title mb-1">
<?= $book['published'] ? '✅' : '📝' ?> <?php if (!empty($book['is_short_story'])): ?>
</div> <i class="bi bi-file-text text-success me-1"></i>
</div> <?php else: ?>
<i class="bi bi-book text-primary me-1"></i>
<!-- Информация о книге --> <?php endif; ?>
<div class="book-info"> <a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="text-decoration-none">
<h3 class="book-title">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit">
<?= e($book['title']) ?> <?= e($book['title']) ?>
</a> </a>
</h3> </h5>
<span class="badge <?= $book['published'] ? 'bg-success' : 'bg-warning' ?>">
<?= $book['published'] ? 'Опубликована' : 'Черновик' ?>
</span>
</div>
<?php if (!empty($book['genre'])): ?> <?php if (!empty($book['genre'])): ?>
<p class="book-genre"><?= e($book['genre']) ?></p> <p class="text-muted small mb-2"><?= e($book['genre']) ?></p>
<?php endif; ?> <?php endif; ?>
</div>
</div>
<?php if (!empty($book['description'])): ?> <?php if (!empty($book['description'])): ?>
<p class="book-description"> <p class="card-text text-muted small">
<?= e(mb_strimwidth($book['description'], 0, 120, '...')) ?> <?= e(mb_strimwidth($book['description'], 0, 120, '...')) ?>
</p> </p>
<?php endif; ?> <?php endif; ?>
<!-- Статистика --> <div class="row text-center mb-3">
<div class="book-stats"> <?php if (!empty($book['is_short_story'])): ?>
<span class="stat-item"> <div class="col-6">
<strong><?= $book['chapter_count'] ?? 0 ?></strong> глав <div class="border-end">
</span> <div class="fw-bold text-success"><?= number_format($book['total_words'] ?? 0) ?></div>
<span class="stat-item"> <small class="text-muted">слов</small>
<strong><?= number_format($book['total_words'] ?? 0) ?></strong> слов </div>
</span> </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>
<!-- Действия --> <div class="d-grid gap-2">
<div class="book-actions"> <a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="compact-button primary-btn"> <i class="bi bi-pencil"></i> Редактировать
✏️ Редактировать
</a> </a>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="compact-button secondary-btn"> <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> </a>
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="compact-button green-btn" target="_blank"> <?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> </a>
</div> </div>
</div> </div>
</article> </div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<!-- Статистика внизу --> <div class="mt-4 p-3 bg-light rounded">
<div class="books-stats-footer"> <div class="row text-center">
<strong>Общая статистика:</strong> <div class="col-md-3 col-6 mb-2">
Книг: <?= count($books) ?> | <strong class="text-primary"><?= count($books) ?></strong>
Глав: <?= array_sum(array_column($books, 'chapter_count')) ?> | <small class="text-muted d-block">Книг</small>
Слов: <?= number_format(array_sum(array_column($books, 'total_words'))) ?> | </div>
Опубликовано: <?= count(array_filter($books, function($book) { return $book['published']; })) ?> <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> </div>
<?php endif; ?> <?php endif; ?>
</div>
<?php if (!empty($books)): ?> <?php if (!empty($books)): ?>
<script> <script>
function showDeleteAllConfirmation() { function showDeleteAllConfirmation() {
@@ -119,4 +173,5 @@ include 'views/layouts/header.php';
} }
</script> </script>
<?php endif; ?> <?php endif; ?>
<?php include 'views/layouts/footer.php'; ?> <?php include 'views/layouts/footer.php'; ?>
+125 -86
View File
@@ -3,159 +3,198 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div class="container" style="padding: 0em; margin: 0em auto; width: 90%;"> <div class="container py-4">
<article style="margin: 0 auto;"> <div class="row">
<header style="text-align: center; margin-bottom: 2rem;"> <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'])): ?> <?php if (!empty($book['cover_image'])): ?>
<div style="margin-bottom: 1rem;"> <div class="mb-4">
<img src="<?= COVERS_URL . e($book['cover_image']) ?>" <img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>" alt="<?= e($book['title']) ?>"
style="max-width: 200px; height: auto; border-radius: 8px; box-shadow: 0 4px 8px rgba(0,0,0,0.1);" class="img-fluid rounded shadow"
style="max-height: 300px;"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
</div> </div>
<?php endif; ?> <?php endif; ?>
<h1 style="margin-bottom: 0.5rem;"><?= e($book['title']) ?></h1> <h1 class="display-5 mb-2"><?= e($book['title']) ?></h1>
<p style="color: #666; font-style: italic; margin-bottom: 0.5rem;"> <p class="lead text-muted mb-2">
Автор: <a href="<?= SITE_URL ?>/author/<?= $book['user_id'] ?>"><?= e($author['display_name']??$author['username']) ?></a> Автор: <a href="<?= SITE_URL ?>/author/<?= $book['user_id'] ?>" class="text-decoration-none">
<?= e($author['display_name']??$author['username']) ?>
</a>
</p> </p>
<?php if (!empty($book['genre'])): ?> <?php if (!empty($book['genre'])): ?>
<p style="color: #666; font-style: italic; margin-bottom: 1rem;"> <p class="text-muted mb-3">
<?= e($book['genre']) ?> <i class="bi bi-tags"></i> <?= e($book['genre']) ?>
</p> </p>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($book['description'])): ?> <?php if (!empty($book['description'])): ?>
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;"> <div class="text-start bg-light p-4 rounded mb-4 ">
<?= nl2br(e($book['description'])) ?> <?= nl2br(e($book['description'])) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div style="display: block; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: #666;"> <div class="d-flex justify-content-center gap-4 flex-wrap mb-4">
<span>Глав: <?= count($chapters) ?></span> <?php if (!$is_short_story): ?>
<span>Слов: <?= array_sum(array_column($chapters, 'word_count')) ?></span> <div class="text-center">
<p> <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>
<div class="d-flex justify-content-center gap-2 flex-wrap">
<?php if (!is_logged_in()): ?> <?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="btn btn-outline-danger" target="_blank">
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button"> <i class="bi bi-file-pdf"></i> PDF
📄 PDF
</a> </a>
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button"> <a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/docx" class="btn btn-outline-primary" target="_blank">
📝 DOCX <i class="bi bi-file-word"></i> DOCX
</a> </a>
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/html" class="adaptive-button secondary" target="_blank" role="button"> <a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/html" class="btn btn-outline-success" target="_blank">
🌐 HTML <i class="bi bi-file-code"></i> HTML
</a> </a>
<a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button"> <a href="<?= SITE_URL ?>/export/shared/<?= $book['share_token'] ?>/txt" class="btn btn-outline-secondary" target="_blank">
📄 TXT <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> </a>
</div>
<?php endif; ?> <?php endif; ?>
<?php if (is_logged_in()): ?>
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content: center;">
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/pdf" class="adaptive-button secondary" target="_blank" role="button">
📄 PDF
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/docx" class="adaptive-button secondary" target="_blank" role="button">
📝 DOCX
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/html" class="adaptive-button secondary" target="_blank" role="button">
🌐 HTML
</a>
<a href="<?= SITE_URL ?>/export/<?= $book['id'] ?>/txt" class="adaptive-button secondary" target="_blank" role="button">
📄 TXT
</a>
</div>
<?php endif; ?>
</p>
</div> </div>
</header> </header>
<?php if (empty($chapters)): ?> <?php if ($is_short_story && !empty($book['content'])): ?>
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;"> <div class="chapter-content mb-5">
<h3>В этой книге пока нет глав</h3> <div class="chapter-text" style="line-height: 1.8; font-size: 1.1em;">
<p>Автор еще не опубликовал содержание книги</p> <?= $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> </div>
<?php else: ?> <?php else: ?>
<h3 style="text-align: center; margin-bottom: 2rem; margin-top: 0em;">Оглавление</h3> <div class="mb-5">
<div class="chapters-list"> <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): ?> <?php foreach ($chapters as $index => $chapter): ?>
<a href="#chapter-<?= $chapter['id'] ?>" class="list-group-item list-group-item-action border-0">
<h6 style="margin-top: 0; margin-bottom: 0em;"> <div class="d-flex justify-content-between align-items-center">
<a href="#chapter-<?= $chapter['id'] ?>" style="text-decoration: none;"> <span>
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?> <strong><?= e($chapter['title']) ?></strong>
</span>
<small class="text-muted"><?= $chapter['word_count'] ?> слов</small>
</div>
</a> </a>
</h6>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</div>
<hr style="margin: 2rem 0;"> <hr class="my-5">
<?php foreach ($chapters as $index => $chapter): ?> <?php foreach ($chapters as $index => $chapter): ?>
<div class="chapter-content" id="chapter-<?= $chapter['id'] ?>" style="margin-bottom: 3rem;"> <div class="chapter-content mb-5" id="chapter-<?= $chapter['id'] ?>">
<h2 style="border-bottom: 2px solid var(--primary); padding-bottom: 0.5rem;"> <h2 class="border-bottom pb-2 mb-4">
Глава <?= $index + 1 ?>: <?= e($chapter['title']) ?> <?= e($chapter['title']) ?>
</h2> </h2>
<div style="margin-top: 1.5rem; line-height: 1.6;"> <div class="chapter-text" style="line-height: 1.8; font-size: 1.1em;">
<?= $chapter['content'] ?> <?= $chapter['content'] ?>
</div> </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>
</div>
<?php if ($index < count($chapters) - 1): ?>
<hr class="my-5">
<?php endif; ?>
<?php endforeach; ?> <?php endforeach; ?>
<?php endif; ?> <?php endif; ?>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;"> <footer class="text-center mt-5 pt-4 border-top">
<p style="color: var(--muted-color);"> <p class="text-muted">
Книга создана в <?= e(APP_NAME) ?> • Книга создана в <?= e(APP_NAME) ?> • <?= date('Y') ?>
<?= date('Y') ?>
</p> </p>
</footer> </footer>
</div>
</article> </article>
</div> </div>
</div>
</div>
<style> <style>
.chapter-content h1, .chapter-content h2, .chapter-content h3 { .chapter-text p {
margin-top: 1.5em; margin-bottom: 1.5em;
margin-bottom: 0.5em;
}
.chapter-content p {
margin-bottom: 1em;
text-align: justify; text-align: justify;
text-indent: 2em;
} }
.chapter-content .dialogue { .chapter-text .dialogue {
margin-left: 2rem; margin-left: 2rem;
font-style: italic; font-style: italic;
color: #2c5aa0; color: #2c5aa0;
} }
.chapter-content blockquote { .chapter-text blockquote {
border-left: 4px solid var(--primary); border-left: 4px solid var(--bs-primary);
padding-left: 1rem; padding-left: 1rem;
margin-left: 0; margin-left: 0;
color: #555; color: #555;
font-style: italic; font-style: italic;
} background: var(--bs-light);
.chapter-content code {
background: var(--card-background-color);
padding: 2px 4px;
border-radius: 3px;
}
.chapter-content pre {
background: var(--card-background-color);
padding: 1rem; 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; overflow-x: auto;
} }
.chapter-content ul, .chapter-content ol { .chapter-text code {
margin-bottom: 1rem; background: var(--bs-light);
padding-left: 2rem; padding: 0.2em 0.4em;
border-radius: 0.3rem;
font-size: 0.9em;
} }
</style> </style>
+36 -25
View File
@@ -2,30 +2,34 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<h1>Новая глава для: <?= e($book['title']) ?></h1> <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 if (isset($error) && $error): ?> <?php if (isset($error) && $error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($error) ?> <?= e($error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="card">
<div class="card-body">
<form method="post" id="chapter-form"> <form method="post" id="chapter-form">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;"> <div class="mb-3">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="title" class="form-label">Название главы *</label>
Название главы * <input type="text" class="form-control" id="title" name="title"
</label>
<input type="text" id="title" name="title"
value="<?= e($_POST['title'] ?? '') ?>" value="<?= e($_POST['title'] ?? '') ?>"
placeholder="Введите название главы" placeholder="Введите название главы" required>
style="width: 100%; margin-bottom: 1.5rem;" </div>
required>
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="mb-3">
Содержание главы * <label for="content" class="form-label">Содержание главы *</label>
</label>
<!-- Контейнер Quill --> <!-- Контейнер Quill -->
<div id="quill-editor" <div id="quill-editor"
class="writer-editor-container" class="writer-editor-container"
@@ -34,29 +38,36 @@ include 'views/layouts/header.php';
</div> </div>
<!-- Скрытый textarea для формы --> <!-- Скрытый textarea для формы -->
<textarea id="content" name="content" style="display:none;"><?= e($_POST['content'] ?? '') ?></textarea> <textarea id="content" name="content" style="display:none;"><?= e($_POST['content'] ?? '') ?></textarea>
</div>
<div style="margin-top: 1rem;"> <div class="mb-4">
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="status" class="form-label">Статус главы</label>
Статус главы <select class="form-select" id="status" name="status">
</label>
<select id="status" name="status" style="width: 100%;">
<option value="draft" <?= (($_POST['status'] ?? 'draft') == 'draft') ? 'selected' : '' ?>>📝 Черновик</option> <option value="draft" <?= (($_POST['status'] ?? 'draft') == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
<option value="published" <?= (($_POST['status'] ?? '') == 'published') ? 'selected' : '' ?>>✅ Опубликована</option> <option value="published" <?= (($_POST['status'] ?? '') == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
</select> </select>
<small style="color: var(--muted-color);"> <div class="form-text">
Опубликованные главы видны в публичном доступе Опубликованные главы видны в публичном доступе
</small>
</div> </div>
</div> </div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;"> <div class="d-flex gap-2">
<button type="submit" class="contrast">💾 Сохранить главу</button> <button type="submit" class="btn btn-primary">
<button type="button" onclick="previewChapter()" class="secondary">👁️ Предпросмотр</button> <i class="bi bi-check-circle"></i> Сохранить главу
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary">❌ Отмена</a> </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> </div>
</form> </form>
</div>
</div>
</div>
<link href="/assets/css/quill_reset.css" rel="stylesheet"> <!-- <link href="/assets/css/quill_reset.css" rel="stylesheet"> -->
<script> <script>
function previewChapter() { function previewChapter() {
const form = document.getElementById('chapter-form'); const form = document.getElementById('chapter-form');
+78 -42
View File
@@ -3,78 +3,119 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<h1>Редактирование главы: <?= e($chapter['title']) ?></h1> <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>
<?php if (isset($error) && $error): ?> <?php if (isset($error) && $error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($error) ?> <?= e($error) ?>
</div> </div>
<?php endif; ?> <?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"> <form method="post" id="chapter-form">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;"> <div class="mb-3">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="title" class="form-label">Название главы *</label>
Название главы * <input type="text" class="form-control" id="title" name="title"
</label>
<input type="text" id="title" name="title"
value="<?= e($chapter['title']) ?>" value="<?= e($chapter['title']) ?>"
placeholder="Введите название главы" placeholder="Введите название главы" required>
style="width: 100%; margin-bottom: 1.5rem;" </div>
required>
<label for="content" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <div class="mb-3">
Содержание главы * <label for="content" class="form-label">Содержание главы *</label>
</label>
<!-- Контейнер Quill --> <!-- Контейнер Quill -->
<div id="quill-editor" <div id="quill-editor"
class="writer-editor-container" class="writer-editor-container"
style="height:500px;" style="height:500px;"
data-content="<?= htmlspecialchars($chapter['content'] ?? '', ENT_QUOTES) ?>"> data-content="<?= htmlspecialchars($chapter['content'] ?? '', ENT_QUOTES) ?>">
</div> </div>
<!-- Скрытый textarea для формы --> <!-- Скрытый textarea для формы -->
<textarea id="content" name="content" style="display:none;"></textarea> <textarea id="content" name="content" style="display:none;"></textarea>
</div>
<div class="mb-4">
<div style="margin-top: 1rem;"> <label for="status" class="form-label">Статус главы</label>
<label for="status" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <select class="form-select" id="status" name="status">
Статус главы
</label>
<select id="status" name="status" style="width: 100%;">
<option value="draft" <?= ($chapter['status'] == 'draft') ? 'selected' : '' ?>>📝 Черновик</option> <option value="draft" <?= ($chapter['status'] == 'draft') ? 'selected' : '' ?>>📝 Черновик</option>
<option value="published" <?= ($chapter['status'] == 'published') ? 'selected' : '' ?>>✅ Опубликована</option> <option value="published" <?= ($chapter['status'] == 'published') ? 'selected' : '' ?>>✅ Опубликована</option>
</select> </select>
<small style="color: var(--muted-color);"> <div class="form-text">
Опубликованные главы видны в публичном доступе Опубликованные главы видны в публичном доступе
</small>
</div> </div>
</div> </div>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 1.5rem;"> <div class="d-flex gap-2 flex-wrap">
<button type="submit" class="contrast"> <button type="submit" class="btn btn-primary">
💾 Сохранить изменения <i class="bi bi-check-circle"></i> Сохранить изменения
</button> </button>
<button type="button" onclick="previewChapter()" class="secondary"> <button type="button" onclick="previewChapter()" class="btn btn-outline-secondary">
👁️ Предпросмотр <i class="bi bi-eye"></i> Предпросмотр
</button> </button>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" role="button" class="secondary"> <a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters" class="btn btn-outline-danger">
❌ Отмена <i class="bi bi-x-circle"></i> Отмена
</a> </a>
</div> </div>
</form> </form>
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;">
<h3>Информация о главе</h3>
<p><strong>Книга:</strong> <a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit"><?= e($book['title']) ?></a></p>
<p><strong>Количество слов:</strong> <?= $chapter['word_count'] ?></p>
<p><strong>Создана:</strong> <?= date('d.m.Y H:i', strtotime($chapter['created_at'])) ?></p>
<p><strong>Обновлена:</strong> <?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></p>
</div> </div>
<link href="/assets/css/quill_reset.css" rel="stylesheet"> </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"> -->
<script> <script>
function previewChapter() { function previewChapter() {
const form = document.getElementById('chapter-form'); const form = document.getElementById('chapter-form');
@@ -101,11 +142,6 @@ function previewChapter() {
titleInput.value = document.getElementById('title').value || 'Предпросмотр главы'; titleInput.value = document.getElementById('title').value || 'Предпросмотр главы';
tempForm.appendChild(titleInput); 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); document.body.appendChild(tempForm);
tempForm.submit(); tempForm.submit();
document.body.removeChild(tempForm); document.body.removeChild(tempForm);
+233 -34
View File
@@ -3,48 +3,86 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div style="margin-bottom: 1rem;"> <div class="container">
<h1 style="margin: 0 0 0.5rem 0; font-size: 1.5rem;">Главы книги: <?= e($book['title']) ?></h1> <div class="d-flex justify-content-between align-items-center mb-4">
<div style="display: flex; gap: 5px; flex-wrap: wrap; justify-content:center;"> <div>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button" role="button">➕ Новая глава</a> <h1 class="h2">Главы книги: <?= e($book['title']) ?></h1>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="adaptive-button secondary" role="button">✏️ Редактировать книгу</a> <p class="text-muted mb-0">Управление главами вашей книги</p>
<a href="<?= SITE_URL ?>/book/<?= $book['share_token'] ?>" class="adaptive-button green-btn" role="button" target="_blank">👁️ Публичный доступ</a> </div>
<a href="<?= SITE_URL ?>/book/all/<?= $book['id'] ?>" class="adaptive-button" role="button" target="_blank">👁️ Полный обзор</a> <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> </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)): ?> <?php if (empty($chapters)): ?>
<div style="text-align: center; padding: 2rem; background: var(--card-background-color); border-radius: 5px; margin-top: 1rem;"> <div class="text-center py-5">
<h3>В этой книге пока нет глав</h3> <div class="mb-4">
<p>Создайте первую главу для вашей книги</p> <i class="bi bi-file-text fs-1 text-muted"></i>
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/chapters/create" class="adaptive-button">📝 Создать первую главу</a> </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> </div>
<?php else: ?> <?php else: ?>
<div style="overflow-x: auto; margin-top: 1rem;"> <div class="card">
<table class="compact-table"> <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> <thead>
<tr> <tr>
<th style="width: 5%;"></th>
<th style="width: 5%;">№</th> <th style="width: 5%;">№</th>
<th style="width: 40%;">Название главы</th> <th style="width: 35%;">Название главы</th>
<th style="width: 15%;">Статус</th> <th style="width: 15%;">Статус</th>
<th style="width: 10%;">Слов</th> <th style="width: 10%;">Слов</th>
<th style="width: 20%;">Обновлено</th> <th style="width: 20%;">Обновлено</th>
<th style="width: 10%;">Действия</th> <th style="width: 10%;">Действия</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="chapters-list">
<?php foreach ($chapters as $index => $chapter): ?> <?php foreach ($chapters as $index => $chapter): ?>
<tr> <tr data-chapter-id="<?= $chapter['id'] ?>" class="chapter-item">
<td><?= $index + 1 ?></td> <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> <td>
<strong><?= e($chapter['title']) ?></strong> <strong><?= e($chapter['title']) ?></strong>
<?php if ($chapter['content']): ?> <?php if ($chapter['content']): ?>
<br><small style="color: var(--muted-color);"><?= e(mb_strimwidth($chapter['content'], 0, 100, '...')) ?></small> <br><small class="text-muted"><?= e(mb_strimwidth($chapter['content'], 0, 100, '...')) ?></small>
<?php endif; ?> <?php endif; ?>
</td> </td>
<td> <td>
<span style="color: <?= $chapter['status'] == 'published' ? 'green' : 'orange' ?>"> <span class="badge <?= $chapter['status'] == 'published' ? 'bg-success' : 'bg-warning' ?>">
<?= $chapter['status'] == 'published' ? '✅ Опубликована' : '📝 Черновик' ?> <?= $chapter['status'] == 'published' ? 'Опубликована' : 'Черновик' ?>
</span> </span>
</td> </td>
<td><?= $chapter['word_count'] ?></td> <td><?= $chapter['word_count'] ?></td>
@@ -52,30 +90,191 @@ include 'views/layouts/header.php';
<small><?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></small> <small><?= date('d.m.Y H:i', strtotime($chapter['updated_at'])) ?></small>
</td> </td>
<td> <td>
<div style="display: flex; gap: 3px; flex-wrap: wrap;"> <div class="btn-group btn-group-sm">
<a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="compact-button secondary" title="Редактировать" role="button"> <a href="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/edit" class="btn btn-outline-primary" title="Редактировать">
✏️ <i class="bi bi-pencil"></i>
</a> </a>
<form method="post" action="<?= SITE_URL ?>/chapters/<?= $chapter['id'] ?>/delete" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить эту главу? Это действие нельзя отменить.');"> <button type="button" class="btn btn-outline-danger delete-chapter-btn"
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> data-chapter-id="<?= $chapter['id'] ?>"
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить"> data-csrf="<?= generate_csrf_token() ?>"
🗑️ title="Удалить">
<i class="bi bi-trash"></i>
</button> </button>
</form>
</div> </div>
</td> </td>
<input type="hidden" name="order[]" value="<?= $chapter['id'] ?>">
</tr> </tr>
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</div> </div>
</form>
<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> </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)): ?>
<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>
<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 endif; ?>
<?php include 'views/layouts/footer.php'; ?> <?php include 'views/layouts/footer.php'; ?>
+78 -42
View File
@@ -4,104 +4,140 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($page_title) ?></title> <title><?= e($page_title) ?></title>
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/pico.min.css"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css">
<style> <style>
body { body {
padding: 20px; padding: 20px;
max-width: 800px; background-color: #f8f9fa;
margin: 0 auto;
line-height: 1.6; line-height: 1.6;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
} }
.content { .preview-container {
margin-top: 2rem; 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-top: 1.5em;
margin-bottom: 0.5em; margin-bottom: 0.5em;
} }
h1 { border-bottom: 2px solid var(--primary); padding-bottom: 0.3em; } .chapter-content h1 {
h2 { border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; } border-bottom: 2px solid var(--bs-primary);
p { 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; margin-bottom: 1em;
text-align: justify; text-align: justify;
text-indent: 2em;
} }
code { .chapter-content code {
background: var(--card-background-color); background: var(--bs-light);
padding: 2px 4px; padding: 2px 4px;
border-radius: 3px; border-radius: 3px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
} }
pre { .chapter-content pre {
background: var(--card-background-color); background: var(--bs-dark);
color: var(--bs-light);
padding: 1rem; padding: 1rem;
border-radius: 5px; border-radius: 5px;
overflow-x: auto; overflow-x: auto;
border-left: 4px solid var(--primary); border-left: 4px solid var(--bs-primary);
} }
pre code { .chapter-content pre code {
background: none; background: none;
padding: 0; padding: 0;
display: block; display: block;
color: inherit;
} }
blockquote { .chapter-content blockquote {
border-left: 4px solid var(--border-color); border-left: 4px solid var(--bs-border-color);
padding-left: 1rem; padding-left: 1rem;
margin-left: 0; margin-left: 0;
color: var(--muted-color); color: var(--bs-secondary);
font-style: italic; font-style: italic;
background: var(--bs-light);
padding: 1rem;
border-radius: 0 0.5rem 0.5rem 0;
} }
strong { font-weight: bold; } .chapter-content strong { font-weight: bold; }
em { font-style: italic; } .chapter-content em { font-style: italic; }
u { text-decoration: underline; } .chapter-content u { text-decoration: underline; }
del { text-decoration: line-through; } .chapter-content del { text-decoration: line-through; }
.dialogue { .chapter-content .dialogue {
margin-left: 2rem; margin-left: 2rem;
font-style: italic; font-style: italic;
color: #2c5aa0; color: #2c5aa0;
} }
table { .chapter-content table {
border-collapse: collapse; border-collapse: collapse;
width: 100%; width: 100%;
margin: 1rem 0; margin: 1rem 0;
} }
table, th, td { .chapter-content table, .chapter-content th, .chapter-content td {
border: 1px solid var(--border-color); border: 1px solid var(--bs-border-color);
} }
th, td { .chapter-content th, .chapter-content td {
padding: 8px 12px; padding: 8px 12px;
text-align: left; text-align: left;
} }
th { .chapter-content th {
background: var(--card-background-color); background: var(--bs-light);
} }
ul, ol { .chapter-content ul, .chapter-content ol {
margin-bottom: 1rem; margin-bottom: 1rem;
padding-left: 2rem; padding-left: 2rem;
} }
li { .chapter-content li {
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
img { .chapter-content img {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
</style> </style>
</head> </head>
<body> <body>
<header> <div class="preview-container">
<h1><?= e($title) ?></h1> <header class="chapter-header text-center">
<hr> <h1 class="display-6"><?= e($title) ?></h1>
</header> </header>
<main class="content">
<main class="chapter-content">
<?= $content ?> <?= $content ?>
</main> </main>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border-color);">
<small>Сгенерировано <?= date('d.m.Y H:i') ?> | Предпросмотр</small> <footer class="mt-5 pt-4 border-top text-center">
<br> <div class="d-flex justify-content-center gap-2 flex-wrap">
<a href="javascript:window.close()" class="button secondary">Закрыть</a> <button onclick="window.print()" class="btn btn-primary">
<a href="javascript:window.print()" class="button">Печать</a> <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> </footer>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body> </body>
</html> </html>
+274 -84
View File
@@ -3,118 +3,308 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<h1>Панель управления</h1> <div class="container">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="grid" style="margin-bottom: 2rem;"> <h1 class="h2">Панель управления</h1>
<article style="text-align: center;"> <div class="btn-group">
<h2>📚 Книги</h2> <a href="<?= SITE_URL ?>/books/create" class="btn btn-primary">
<div style="font-size: 2rem; font-weight: bold; color: var(--primary);"> <i class="bi bi-plus-circle"></i> Новая книга
<?= $total_books ?> </a>
<a href="<?= SITE_URL ?>/series/create" class="btn btn-outline-primary">
<i class="bi bi-collection"></i> Новая серия
</a>
</div> </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>
<div class="grid"> <!-- Статистика -->
<div> <div class="row g-4 mb-5">
<h2>Недавние книги</h2> <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>
</div>
<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)): ?> <?php if (!empty($recent_books)): ?>
<div class="list-group list-group-flush">
<?php foreach ($recent_books as $book): ?> <?php foreach ($recent_books as $book): ?>
<article style="margin-bottom: 1em; padding-top: 0.5em;"> <div class="list-group-item px-0">
<h3 style="margin-bottom: 0.5rem; margin-top: 0.5em;"> <div class="d-flex justify-content-between align-items-start mb-2">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit"> <h6 class="mb-1">
<a href="<?= SITE_URL ?>/books/<?= $book['id'] ?>/edit" class="text-decoration-none">
<?= e($book['title']) ?> <?= e($book['title']) ?>
</a> </a>
</h3> </h6>
<?php if ($book['genre']): ?> <span class="badge <?= $book['published'] ? 'bg-success' : 'bg-warning' ?>">
<p style="margin: 0; color: var(--muted-color); font-size:small;"><em><?= e($book['genre']) ?></em></p> <?= $book['published'] ? 'Опубликована' : 'Черновик' ?>
<?php endif; ?> </span>
<?php if ($book['description']): ?> </div>
<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)): ?> <?php if ($book['genre']): ?>
<div style="text-align: center; margin-top: 1rem;"> <p class="text-muted small mb-1"><em><?= e($book['genre']) ?></em></p>
<a href="<?= SITE_URL ?>/books" class="button">Все книги</a> <?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> </div>
<?php endif; ?> <?php endif; ?>
<?php else: ?> <?php else: ?>
<article> <div class="text-center py-4">
<p>У вас пока нет книг.</p> <i class="bi bi-journal-bookmark fs-1 text-muted"></i>
<a href="<?= SITE_URL ?>/books/create" class="button">Создать первую книгу</a> <p class="text-muted mt-2">У вас пока нет книг</p>
</article> <a href="<?= SITE_URL ?>/books/create" class="btn btn-primary">Создать первую книгу</a>
</div>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div>
</div>
<div> <!-- Мои серии и быстрые действия -->
<h2>Мои серии</h2> <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)): ?> <?php if (!empty($series)): ?>
<div class="list-group list-group-flush">
<?php foreach ($series as $ser): ?> <?php foreach ($series as $ser): ?>
<article> <div class="list-group-item px-0">
<h3 style="margin-bottom: 0.5rem;"> <div class="d-flex justify-content-between align-items-start mb-2">
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/edit"> <h6 class="mb-1">
<a href="<?= SITE_URL ?>/series/<?= $ser['id'] ?>/edit" class="text-decoration-none">
<?= e($ser['title']) ?> <?= e($ser['title']) ?>
</a> </a>
</h3> </h6>
<span class="badge bg-primary"><?= $ser['book_count'] ?> книг</span>
</div>
<?php if ($ser['description']): ?> <?php if ($ser['description']): ?>
<p><?= e(mb_strimwidth($ser['description'], 0, 100, '...')) ?></p> <p class="text-muted small mb-2"><?= e(mb_strimwidth($ser['description'], 0, 100, '...')) ?></p>
<?php endif; ?> <?php endif; ?>
<footer>
<small> <div class="d-flex justify-content-between align-items-center">
<small class="text-muted">
Книг: <?= $ser['book_count'] ?> | Книг: <?= $ser['book_count'] ?> |
Слов: <?= $ser['total_words'] ?> Слов: <?= $ser['total_words'] ?>
</small> </small>
</footer> <div class="btn-group btn-group-sm">
</article> <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; ?> <?php endforeach; ?>
<div style="text-align: center; margin-top: 1rem;">
<a href="<?= SITE_URL ?>/series" class="button">Все серии</a>
</div> </div>
<?php else: ?> <?php else: ?>
<article> <div class="text-center py-4">
<p>У вас пока нет серий.</p> <i class="bi bi-collection fs-1 text-muted"></i>
<a href="<?= SITE_URL ?>/series/create" class="button">Создать первую серию</a> <p class="text-muted mt-2">У вас пока нет серий</p>
</article> <a href="<?= SITE_URL ?>/series/create" class="btn btn-primary">Создать первую серию</a>
</div>
<?php endif; ?> <?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>
<h2 style="margin-top: 2rem;">Быстрые действия</h2> <!-- Дополнительная статистика -->
<div class="button-group"> <div class="row mt-4">
<a href="<?= SITE_URL ?>/books/create" class="button">📖 Новая книга</a> <div class="col-12">
<a href="<?= SITE_URL ?>/series/create" class="button secondary">📚 Новая серия</a> <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> </div>
</div> </div>
+23 -7
View File
@@ -3,18 +3,34 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div class="container" style="text-align: center; padding: 4rem 1rem;"> <div class="container">
<h1>404 - Страница не найдена</h1> <div class="row justify-content-center">
<p style="font-size: 1.2rem; margin-bottom: 2rem;"> <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> </p>
<div style="display: flex; gap: 10px; justify-content: center; flex-wrap: wrap;"> <div class="d-flex gap-3 justify-content-center flex-wrap">
<a href="<?= SITE_URL ?>/" class="button">🏠 На главную</a> <a href="<?= SITE_URL ?>/" class="btn btn-primary btn-lg">
<a href="<?= SITE_URL ?>/books" class="button secondary">📚 К книгам</a> <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()): ?> <?php if (!is_logged_in()): ?>
<a href="<?= SITE_URL ?>/login" class="button secondary">🔑 Войти</a> <a href="<?= SITE_URL ?>/login" class="btn btn-outline-primary btn-lg">
<i class="bi bi-box-arrow-in-right"></i> Войти
</a>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div> </div>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?> <?php include 'views/layouts/footer.php'; ?>
+77 -7
View File
@@ -1,18 +1,88 @@
<?php <?php
// views/layouts/footer.php // views/layouts/footer.php
?> ?>
</div>
</main> </main>
<footer class="container" style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--muted-border-color);"> <footer class="bg-light mt-5 py-4 border-top">
<small> <div class="container">
&copy; <?= date('Y') ?> <?= e(APP_NAME) ?>. <div class="row align-items-center">
<?php if (is_logged_in()): ?> <div class="col-md-6">
<a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">Моя публичная страница</a> <small class="text-muted">
<?php endif; ?> &copy; <?= date('Y') ?> <?= e(APP_NAME) ?>
</small> </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> </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> <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 // Глобальные функции JavaScript
function confirmAction(message) { function confirmAction(message) {
return confirm(message || 'Вы уверены?'); return confirm(message || 'Вы уверены?');
+148 -29
View File
@@ -1,5 +1,36 @@
<?php <?php
// views/layouts/header.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> <!DOCTYPE html>
<html lang="ru"> <html lang="ru">
@@ -7,68 +38,156 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($page_title ?? 'Web Writer') ?></title> <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 rel="stylesheet" href="<?= SITE_URL ?>/assets/css/style.css">
<link href="<?= SITE_URL ?>/assets/css/quill.snow.css" rel="stylesheet"> <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> </head>
<body> <body>
<nav class="container-fluid"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<ul> <div class="container">
<li><strong><a href="<?= SITE_URL ?>/"><?= e(APP_NAME) ?></a></strong></li> <a class="navbar-brand" href="<?= SITE_URL ?>/"><?= e(APP_NAME) ?></a>
</ul>
<ul> <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()): ?> <?php if (is_logged_in()): ?>
<li><a href="<?= SITE_URL ?>/dashboard">📊 Панель управления</a></li> <li class="nav-item">
<li><a href="<?= SITE_URL ?>/books">📚 Мои книги</a></li> <a class="nav-link" href="<?= SITE_URL ?>/dashboard">
<li><a href="<?= SITE_URL ?>/series">📑 Серии</a></li> <i class="bi bi-speedometer2"></i> Панель управления
<li> </a>
<details role="list" dir="rtl"> </li>
<summary aria-haspopup="listbox" role="link"> <li class="nav-item">
👤 <?= e($_SESSION['display_name']) ?> <a class="nav-link" href="<?= SITE_URL ?>/books">
</summary> <i class="bi bi-journal-bookmark"></i> Мои книги
<ul role="listbox"> </a>
<li><a href="<?= SITE_URL ?>/profile">⚙️ Профиль</a></li> </li>
<li><a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank">👤 Моя публичная страница</a></li> <li class="nav-item">
<?php if ($_SESSION['user_id'] == 1): // Проверка на администратора ?> <a class="nav-link" href="<?= SITE_URL ?>/series">
<li><a href="<?= SITE_URL ?>/admin/users">👥 Управление пользователями</a></li> <i class="bi bi-collection"></i> Серии
</a>
</li>
<?php endif; ?> <?php endif; ?>
<li><a href="<?= SITE_URL ?>/logout">🚪 Выход</a></li>
</ul> </ul>
</details>
<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>
</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> </li>
<?php else: ?> <?php else: ?>
<li><a href="<?= SITE_URL ?>/login">🔑 Вход</a></li> <li class="nav-item">
<li><a href="<?= SITE_URL ?>/register">📝 Регистрация</a></li> <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; ?> <?php endif; ?>
</ul> </ul>
</div>
</div>
</nav> </nav>
<main class="container"> <main class="main-container">
<div class="container mt-4">
<?php if (isset($_SESSION['success'])): ?> <?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success"> <div class="alert alert-success alert-dismissible fade show">
<?= e($_SESSION['success']) ?> <?= e($_SESSION['success']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['success']); ?> <?php unset($_SESSION['success']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION['error'])): ?> <?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error"> <div class="alert alert-danger alert-dismissible fade show">
<?= e($_SESSION['error']) ?> <?= e($_SESSION['error']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['error']); ?> <?php unset($_SESSION['error']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION['warning'])): ?> <?php if (isset($_SESSION['warning'])): ?>
<div class="alert alert-warning"> <div class="alert alert-warning alert-dismissible fade show">
<?= e($_SESSION['warning']) ?> <?= e($_SESSION['warning']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['warning']); ?> <?php unset($_SESSION['warning']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (isset($_SESSION['info'])): ?> <?php if (isset($_SESSION['info'])): ?>
<div class="alert alert-info"> <div class="alert alert-info alert-dismissible fade show">
<?= e($_SESSION['info']) ?> <?= e($_SESSION['info']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<?php unset($_SESSION['info']); ?> <?php unset($_SESSION['info']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
+39 -25
View File
@@ -3,49 +3,59 @@
include 'views/layouts/header.php'; 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): ?> <?php if (isset($error) && $error): ?>
<div class="alert alert-error"> <div class="alert alert-danger">
<?= e($error) ?> <?= e($error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="card">
<div class="card-body">
<form method="post"> <form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="max-width: 100%; margin-bottom: 1rem;"> <div class="mb-3">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="title" class="form-label">Название серии *</label>
Название серии * <input type="text" class="form-control" id="title" name="title"
</label>
<input type="text" id="title" name="title"
value="<?= e($_POST['title'] ?? '') ?>" value="<?= e($_POST['title'] ?? '') ?>"
placeholder="Введите название серии" placeholder="Введите название серии" required>
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>
<div style="display: flex; gap: 10px; flex-wrap: wrap;"> <div class="mb-4">
<button type="submit" class="contrast"> <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> </button>
<a href="<?= SITE_URL ?>/series" role="button" class="secondary"> <a href="<?= SITE_URL ?>/series" class="btn btn-outline-danger">
❌ Отмена <i class="bi bi-x-circle"></i> Отмена
</a> </a>
</div> </div>
</form> </form>
</div>
</div>
<div style="margin-top: 2rem; padding: 1rem; background: var(--card-background-color); border-radius: 5px;"> <div class="card mt-4">
<h3>Что такое серия?</h3> <div class="card-header">
<h5 class="card-title mb-0">Что такое серия?</h5>
</div>
<div class="card-body">
<p>Серия позволяет объединить несколько книг в одну тематическую коллекцию. Это полезно для:</p> <p>Серия позволяет объединить несколько книг в одну тематическую коллекцию. Это полезно для:</p>
<ul> <ul>
<li>Циклов книг с общим сюжетом</li> <li>Циклов книг с общим сюжетом</li>
@@ -54,5 +64,9 @@ include 'views/layouts/header.php';
</ul> </ul>
<p>Вы сможете добавить книги в серию после её создания.</p> <p>Вы сможете добавить книги в серию после её создания.</p>
</div> </div>
</div>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?> <?php include 'views/layouts/footer.php'; ?>
+95 -92
View File
@@ -2,60 +2,89 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<h1>Редактирование серии: <?= e($series['title']) ?></h1> <div class="container">
<article> <div class="d-flex justify-content-between align-items-center mb-4">
<h2>Основная информация</h2> <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 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"> <form method="post" action="/series/<?= $series['id'] ?>/edit">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<label for="title"> <div class="mb-3">
Название серии * <label for="title" class="form-label">Название серии *</label>
<input type="text" id="title" name="title" value="<?= e($series['title']) ?>" required> <input type="text" class="form-control" id="title" name="title"
</label> value="<?= e($series['title']) ?>" required>
</div>
<label for="description"> <div class="mb-3">
Описание серии <label for="description" class="form-label">Описание серии</label>
<textarea id="description" name="description" rows="4"><?= e($series['description'] ?? '') ?></textarea> <textarea class="form-control" id="description" name="description"
</label> rows="4"><?= e($series['description'] ?? '') ?></textarea>
</div>
<button type="submit" class="primary-btn">Сохранить изменения</button> <button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle"></i> Сохранить изменения
</button>
</form> </form>
</article> </div>
<div class="grid"> </div>
<div>
<div class="card">
<article> <div class="card-header">
<h2>Добавить книгу в серию</h2> <h5 class="card-title mb-0">Добавить книгу в серию</h5>
</div>
<div class="card-body">
<?php if (!empty($available_books)): ?> <?php if (!empty($available_books)): ?>
<form method="post" action="/series/<?= $series['id'] ?>/add-book"> <form method="post" action="/series/<?= $series['id'] ?>/add-book">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<label for="book_id">
Выберите книгу <div class="mb-3">
<select id="book_id" name="book_id" required> <label for="book_id" class="form-label">Выберите книгу</label>
<select class="form-select" id="book_id" name="book_id" required>
<option value="">-- Выберите книгу --</option> <option value="">-- Выберите книгу --</option>
<?php foreach ($available_books as $book): ?> <?php foreach ($available_books as $book): ?>
<option value="<?= $book['id'] ?>"><?= e($book['title']) ?></option> <option value="<?= $book['id'] ?>"><?= e($book['title']) ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
</label>
<label for="sort_order">
Порядковый номер в серии
<input type="number" id="sort_order" name="sort_order" value="<?= count($books_in_series) + 1 ?>" min="1">
</label>
<button type="submit" class="secondary-btn">Добавить в серию</button>
</form>
<?php else: ?>
<p>Все ваши книги уже добавлены в эту серию или у вас нет доступных книг.</p>
<a href="/books/create" class="primary-btn" role="button">Создать новую книгу</a>
<?php endif; ?>
</article>
</div> </div>
<div> <div class="mb-3">
<article> <label for="sort_order" class="form-label">Порядковый номер в серии</label>
<h2>Книги в серии (<?= count($books_in_series) ?>)</h2> <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)): ?> <?php if (!empty($books_in_series)): ?>
<div id="series-books-list"> <div id="series-books-list">
<form id="reorder-form" method="post" action="/series/<?= $series['id'] ?>/update-order"> <form id="reorder-form" method="post" action="/series/<?= $series['id'] ?>/update-order">
@@ -63,54 +92,57 @@ include 'views/layouts/header.php';
<div class="books-list"> <div class="books-list">
<?php foreach ($books_in_series as $index => $book): ?> <?php foreach ($books_in_series as $index => $book): ?>
<div class="book-item" data-book-id="<?= $book['id'] ?>"> <div class="book-item border rounded p-3 mb-2 d-flex align-items-center"
<div class="book-drag-handle" style="cursor: move;">☰</div> data-book-id="<?= $book['id'] ?>">
<div class="book-info"> <div class="book-drag-handle text-muted me-3" style="cursor: move;">
<strong><?= e($book['title']) ?></strong> <i class="bi bi-grip-vertical"></i>
<small>Порядок: <?= $book['sort_order_in_series'] ?></small>
</div> </div>
<div class="book-actions" style="display: grid; min-width: 2rem; margin-top: 1rem;"> <div class="book-info flex-grow-1">
<a href="/books/<?= $book['id'] ?>/edit" class="compact-button" role="button" style="margin-top: 0em;">✏️</a> <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'] ?>" <form method="post" action="/series/<?= $series['id'] ?>/remove-book/<?= $book['id'] ?>"
style="display: inline;" onsubmit="return confirm('Удалить книгу из серии?')"> onsubmit="return confirm('Удалить книгу из серии?')" class="d-inline">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button red-btn" style="margin-top: 0em;">🗑️</button> <button type="submit" class="btn btn-outline-danger" title="Удалить из серии">
<i class="bi bi-trash"></i>
</button>
</form> </form>
</div> </div>
</div>
<input type="hidden" name="order[]" value="<?= $book['id'] ?>"> <input type="hidden" name="order[]" value="<?= $book['id'] ?>">
</div> </div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<button type="submit" class="secondary-btn" id="save-order-btn" style="display: none;"> <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> </button>
</form> </form>
</div> </div>
<?php else: ?> <?php else: ?>
<p>В этой серии пока нет книг. Добавьте книги с помощью формы слева.</p> <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; ?> <?php endif; ?>
</article> </div>
</div>
</div>
</div> </div>
</div> </div>
<style> <style>
.books-list {
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.book-item { .book-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid #f0f0f0;
background: white;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
} background: white;
.book-item:last-child {
border-bottom: none;
} }
.book-item:hover { .book-item:hover {
@@ -126,30 +158,8 @@ include 'views/layouts/header.php';
} }
.book-drag-handle { .book-drag-handle {
padding: 0 10px;
color: #666;
font-size: 1.2rem; 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> </style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.0/Sortable.min.js"></script> <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 секунды после изменения // Автосохранение порядка через 2 секунды после изменения
let saveTimeout; let saveTimeout;
saveOrderBtn.addEventListener('click', function(e) {
e.preventDefault();
clearTimeout(saveTimeout);
document.getElementById('reorder-form').submit();
});
// Автоматическое сохранение при изменении порядка
booksList.addEventListener('sortupdate', function() { booksList.addEventListener('sortupdate', function() {
clearTimeout(saveTimeout); clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => { saveTimeout = setTimeout(() => {
+59 -44
View File
@@ -2,82 +2,97 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div style="display: block; justify-content: between; align-items: center; margin-bottom: 2rem; flex-wrap: wrap; gap: 1rem;"> <div class="container">
<h1 style="margin: 0;">Мои серии книг</h1> <div class="d-flex justify-content-between align-items-center mb-4">
<a href="/series/create" class="action-button primary" role="button">➕ Создать серию</a> <h1 class="h2">Мои серии книг</h1>
<a href="/series/create" class="btn btn-primary">
<i class="bi bi-plus-circle"></i> Создать серию
</a>
</div> </div>
<?php if (empty($series)): ?> <?php if (empty($series)): ?>
<article class="series-empty-state"> <div class="text-center py-5">
<div class="series-empty-icon">📚</div> <div class="mb-4">
<h2>Пока нет серий</h2> <i class="bi bi-collection fs-1 text-muted"></i>
<p style="color: #666; margin-bottom: 2rem;"> </div>
<h3 class="h4 text-muted">Пока нет серий</h3>
<p class="text-muted mb-4">
Создайте свою первую серию, чтобы организовать книги в циклы и сериалы. Создайте свою первую серию, чтобы организовать книги в циклы и сериалы.
</p> </p>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;"> <div class="d-flex gap-2 justify-content-center">
<a href="/series/create" class="action-button primary" role="button">Создать серию</a> <a href="/series/create" class="btn btn-primary">Создать серию</a>
<a href="/books" class="action-button secondary">Перейти к книгам</a> <a href="/books" class="btn btn-outline-secondary">Перейти к книгам</a>
</div>
</div> </div>
</article>
<?php else: ?> <?php else: ?>
<div class="series-grid"> <div class="row g-4">
<?php foreach ($series as $ser): ?> <?php foreach ($series as $ser): ?>
<article class="series-card"> <div class="col-lg-6">
<div class="series-header"> <div class="card h-100">
<h3 class="series-title"> <div class="card-body">
<a href="/series/<?= $ser['id'] ?>/edit"><?= e($ser['title']) ?></a> <div class="d-flex justify-content-between align-items-start mb-3">
</h3> <h5 class="card-title">
<div class="series-meta"> <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'])) ?> Создана <?= date('d.m.Y', strtotime($ser['created_at'])) ?>
<?php if ($ser['updated_at'] != $ser['created_at']): ?> <?php if ($ser['updated_at'] != $ser['created_at']): ?>
• Обновлена <?= date('d.m.Y', strtotime($ser['updated_at'])) ?> • Обновлена <?= date('d.m.Y', strtotime($ser['updated_at'])) ?>
<?php endif; ?> <?php endif; ?>
</div> </div>
</div>
<?php if (!empty($ser['description'])): ?> <?php if (!empty($ser['description'])): ?>
<div class="series-description"> <p class="card-text"><?= e($ser['description']) ?></p>
<?= e($ser['description']) ?>
</div>
<?php endif; ?> <?php endif; ?>
<div class="series-stats-grid"> <div class="row text-center mb-3">
<div class="series-stat"> <div class="col-4">
<span class="series-stat-number"><?= $ser['book_count'] ?? 0 ?></span> <div class="border-end">
<span class="series-stat-label">книг</span> <div class="fw-bold text-primary"><?= $ser['book_count'] ?? 0 ?></div>
<small class="text-muted">книг</small>
</div> </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>
<div class="series-stat"> <div class="col-4">
<span class="series-stat-number"> <div class="border-end">
<?php <div class="fw-bold text-success"><?= number_format($ser['total_words'] ?? 0) ?></div>
$avg_words = $ser['book_count'] > 0 ? round($ser['total_words'] / $ser['book_count']) : 0; <small class="text-muted">слов</small>
echo number_format($avg_words); </div>
?> </div>
</span> <div class="col-4">
<span class="series-stat-label">слов/книга</span> <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> </div>
<div class="series-actions" style="display:grid;"> <div class="d-grid gap-2">
<a href="/series/<?= $ser['id'] ?>/edit" class="compact-button primary-btn" role="button"> <a href="/series/<?= $ser['id'] ?>/edit" class="btn btn-outline-primary btn-sm">
✏️ Управление <i class="bi bi-pencil"></i> Управление
</a> </a>
<a href="/series/<?= $ser['id'] ?>/view" class="compact-button secondary-btn" target="_blank" role="button"> <a href="/series/<?= $ser['id'] ?>/view" class="btn btn-outline-success btn-sm" target="_blank">
👁️ Публично <i class="bi bi-eye"></i> Публично
</a> </a>
<form method="post" action="/series/<?= $ser['id'] ?>/delete" <form method="post" action="/series/<?= $ser['id'] ?>/delete"
onsubmit="return confirm('Удалить серию? Книги останутся, но будут удалены из серии.')"> onsubmit="return confirm('Удалить серию? Книги останутся, но будут удалены из серии.')">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button red-btn">🗑️ Удалить</button> <button type="submit" class="btn btn-outline-danger btn-sm">
<i class="bi bi-trash"></i> Удалить
</button>
</form> </form>
</div> </div>
</article> </div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div>
<?php <?php
include 'views/layouts/footer.php'; include 'views/layouts/footer.php';
+81 -57
View File
@@ -3,119 +3,143 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div class="container"> <div class="container py-4">
<article style="max-width: 800px; margin: 0 auto;"> <div class="row justify-content-center">
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid var(--muted-border-color); padding-bottom: 1rem;"> <div class="col-lg-10">
<h1 style="margin-bottom: 0.5rem;"><?= e($series['title']) ?></h1> <article class="card border-0 shadow-sm">
<p style="color: var(--muted-color); font-style: italic; margin-bottom: 0.5rem;"> <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'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a> <a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>" class="text-decoration-none">
<?= e($author['display_name'] ?: $author['username']) ?>
</a>
</p> </p>
<?php if ($series['description']): ?> <?php if ($series['description']): ?>
<div style="background: var(--card-background-color); padding: 1rem; border-radius: 5px; margin: 1rem 0; text-align: left;"> <div class="bg-light p-4 rounded mb-4">
<?= e($series['description']) ?> <?= e($series['description']) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div style="display: flex; justify-content: center; gap: 1rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);"> <div class="d-flex justify-content-center gap-4 flex-wrap">
<span>Книг: <?= count($books) ?></span> <div class="text-center">
<span>Глав: <?= $total_chapters ?></span> <div class="h4 text-primary mb-0"><?= count($books) ?></div>
<span>Слов: <?= $total_words ?></span> <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> </div>
</header> </header>
<?php if (empty($books)): ?> <?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;"> <div class="text-center py-5">
<h3>В этой серии пока нет опубликованных книг</h3> <i class="bi bi-collection fs-1 text-muted"></i>
<p>Автор еще не опубликовал книги из этой серии</p> <h3 class="h4 text-muted mt-3">В этой серии пока нет опубликованных книг</h3>
<p class="text-muted">Автор еще не опубликовал книги из этой серии</p>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="series-books"> <div class="mb-5">
<h2 style="text-align: center; margin-bottom: 2rem;">Книги серии</h2> <h2 class="h3 text-center mb-4">
<i class="bi bi-book"></i> Книги серии
</h2>
<?php foreach ($books as $book): ?> <?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;"> <div class="card mb-4">
<div class="card-body">
<div class="row align-items-start">
<?php if ($book['cover_image']): ?> <?php if ($book['cover_image']): ?>
<div style="flex-shrink: 0;"> <div class="col-md-3 text-center mb-3 mb-md-0">
<img src="<?= COVERS_URL . e($book['cover_image']) ?>" <img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>" alt="<?= e($book['title']) ?>"
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);" class="img-fluid rounded shadow"
style="max-height: 200px;"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
</div> </div>
<div class="col-md-9">
<?php else: ?> <?php else: ?>
<div style="flex-shrink: 0;"> <div class="col-12">
<div class="cover-placeholder" style="width: 120px; height: 160px; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); border-radius: 4px; display: flex; align-items: center; justify-content: center; color: white; font-size: 2rem;">
📚
</div>
</div>
<?php endif; ?> <?php endif; ?>
<div class="d-flex justify-content-between align-items-start mb-2">
<div style="flex: 1;"> <h3 class="h4 mb-0">
<h3 style="margin-top: 0;">
<?php if ($book['sort_order_in_series']): ?> <?php if ($book['sort_order_in_series']): ?>
<small style="color: var(--muted-color);">Книга <?= $book['sort_order_in_series'] ?></small><br> <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; ?> <?php endif; ?>
<?= e($book['title']) ?> <?= e($book['title']) ?>
</h3> </h3>
<?php if (!empty($book['is_short_story'])): ?>
<span class="text-success">(рассказ)</span>
<?php endif; ?>
</div>
<?php if ($book['genre']): ?> <?php if ($book['genre']): ?>
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p> <p class="text-muted mb-2"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?> <?php endif; ?>
<?php if ($book['description']): ?> <?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p> <p class="mb-3"><?= nl2br(e($book['description'])) ?></p>
<?php endif; ?> <?php endif; ?>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;"> <div class="d-flex justify-content-between align-items-center">
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button"> <div>
Читать <a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="btn btn-primary">
<i class="bi bi-book"></i> Читать
</a> </a>
</div>
<div class="text-end">
<?php <?php
$bookModel = new Book($pdo); $book_stats = $book_model->getBookStats($book['id'], true);
$book_stats = $bookModel->getBookStats($book['id'], true);
?> ?>
<small class="text-muted">
<small style="color: var(--muted-color);"> <?php if (!empty($book_stats['is_short_story'])): ?>
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | Слов: <?= $book_stats['total_words'] ?? 0 ?> <?= number_format($book_stats['total_words'] ?? 0) ?> слов
<?php else: ?>
Глав: <?= $book_stats['chapter_count'] ?? 0 ?> | <?= number_format($book_stats['total_words'] ?? 0) ?> слов
<?php endif; ?>
</small> </small>
</div> </div>
</div> </div>
</article> </div>
</div>
</div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;"> <footer class="text-center mt-5 pt-4 border-top">
<p style="color: var(--muted-color);"> <p class="text-muted">
Серия создана в <?= e(APP_NAME) ?> • Серия создана в <?= e(APP_NAME) ?> •
Автор: <a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a> Автор: <a href="<?= SITE_URL ?>/author/<?= $author['id'] ?>"><?= e($author['display_name'] ?: $author['username']) ?></a>
</p> </p>
</footer> </footer>
</div>
</article> </article>
</div> </div>
</div>
</div>
<style> <style>
.series-books article { .card {
transition: transform 0.2s ease, box-shadow 0.2s ease; 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); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1); box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
}
@media (max-width: 768px) {
.series-books article {
flex-direction: column;
text-align: center;
}
.series-books .book-cover {
align-self: center;
}
} }
</style> </style>
+82 -82
View File
@@ -3,139 +3,139 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<h1>Мой профиль</h1> <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 if ($message): ?> <?php if ($message): ?>
<div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-error' : 'alert-success' ?>"> <div class="alert <?= strpos($message, 'Ошибка') !== false ? 'alert-danger' : 'alert-success' ?>">
<?= e($message) ?> <?= e($message) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="grid"> <div class="row">
<article> <div class="col-lg-8">
<h2>Основная информация</h2> <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"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;"> <div class="mb-3">
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="username" class="form-label">Имя пользователя (нельзя изменить)</label>
Имя пользователя (нельзя изменить) <input type="text" class="form-control" id="username" value="<?= e($user['username']) ?>" disabled>
</label>
<input type="text" id="username" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
</div> </div>
<div style="margin-bottom: 1rem;"> <div class="mb-3">
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="display_name" class="form-label">Отображаемое имя *</label>
Отображаемое имя * <input type="text" class="form-control" id="display_name" name="display_name"
</label> value="<?= e($user['display_name'] ?? $user['username']) ?>" required>
<input type="text" id="display_name" name="display_name"
value="<?= e($user['display_name'] ?? $user['username']) ?>"
style="width: 100%;" required>
</div> </div>
<div style="margin-bottom: 1.5rem;"> <div class="mb-3">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="email" class="form-label">Email</label>
Email <input type="email" class="form-control" id="email" name="email"
</label> value="<?= e($user['email'] ?? '') ?>">
<input type="email" id="email" name="email"
value="<?= e($user['email'] ?? '') ?>"
style="width: 100%;">
</div> </div>
<div style="margin-bottom: 1.5rem;"> <div class="mb-4">
<label for="bio" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="bio" class="form-label">О себе</label>
О себе (отображается на вашей публичной странице) <textarea class="form-control" id="bio" name="bio"
</label>
<textarea id="bio" name="bio"
placeholder="Расскажите о себе, своих интересах, стиле письма..." placeholder="Расскажите о себе, своих интересах, стиле письма..."
rows="6" rows="6"><?= e($user['bio'] ?? '') ?></textarea>
style="width: 100%;"><?= e($user['bio'] ?? '') ?></textarea> <div class="form-text">
<small style="color: var(--muted-color);"> Отображается на вашей публичной странице. Поддерживается Markdown форматирование.
Поддерживается Markdown форматирование </div>
</small>
</div> </div>
<div class="profile-buttons"> <button type="submit" class="btn btn-primary">
<button type="submit" class="profile-button primary"> <i class="bi bi-check-circle"></i> Сохранить изменения
💾 Сохранить изменения
</button> </button>
<a href="<?= SITE_URL ?>/dashboard" class="profile-button secondary">
↩️ Назад
</a>
</div>
</form> </form>
</article> </div>
</div>
</div>
<article> <div class="col-lg-4">
<h2>Аватарка</h2> <div class="card mb-4">
<div class="card-header">
<div style="text-align: center; margin-bottom: 1.5rem;"> <h5 class="card-title mb-0">Аватарка</h5>
</div>
<div class="card-body text-center">
<?php if (!empty($user['avatar'])): ?> <?php if (!empty($user['avatar'])): ?>
<img src="<?= AVATARS_URL . e($user['avatar']) ?>" <img src="<?= AVATARS_URL . e($user['avatar']) ?>"
alt="Аватарка" alt="Аватарка"
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid var(--primary);" class="rounded-circle mb-3 border border-3 border-primary"
style="width: 150px; height: 150px; object-fit: cover;"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
<?php else: ?> <?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;"> <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) ?> <?= mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
</span>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>"> <input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;"> <div class="mb-3">
<label for="avatar" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="avatar" class="form-label">Загрузить новую аватарку</label>
Загрузить новую аватарку <input type="file" class="form-control" id="avatar" name="avatar"
</label> accept="image/jpeg, image/png, image/gif, image/webp">
<input type="file" id="avatar" name="avatar" <div class="form-text">
accept="image/jpeg, image/png, image/gif, image/webp"
style="height: 2.6rem;">
<small style="color: var(--muted-color);">
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB. Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB.
Рекомендуемый размер: 200×200 пикселей. </div>
</small>
<?php if (!empty($avatar_error)): ?> <?php if (!empty($avatar_error)): ?>
<div style="color: #d32f2f; margin-top: 0.5rem;"> <div class="alert alert-danger mt-2">
❌ <?= e($avatar_error) ?> <i class="bi bi-exclamation-triangle"></i> <?= e($avatar_error) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div style="display: flex; gap: 10px;"> <div class="d-grid gap-2">
<button type="submit" class="contrast" style="flex: 1;"> <button type="submit" class="btn btn-primary">
📤 Загрузить аватарку <i class="bi bi-upload"></i> Загрузить аватарку
</button> </button>
<?php if (!empty($user['avatar'])): ?> <?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 type="submit" name="delete_avatar" value="1" class="btn btn-outline-danger">
🗑️ Удалить аватарку <i class="bi bi-trash"></i> Удалить аватарку
</button> </button>
<?php endif; ?> <?php endif; ?>
</div> </div>
</form> </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> </div>
<?php endif; ?>
</article>
</div> </div>
<article> <div class="card">
<h3>Информация об аккаунте</h3> <div class="card-header">
<p><a href="<?= SITE_URL ?>/author/<?= $_SESSION['user_id'] ?>" target="_blank" class="adaptive-button secondary"> <h5 class="card-title mb-0">Информация об аккаунте</h5>
👁️ Посмотреть мою публичную страницу </div>
</a></p> <div class="card-body">
<p><strong>Дата регистрации:</strong> <?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></p> <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>
<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']): ?> <?php if ($user['last_login']): ?>
<p><strong>Последний вход:</strong> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></p> <p><strong>Последний вход:</strong><br>
<small class="text-muted"><?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></small></p>
<?php endif; ?> <?php endif; ?>
</article> </div>
</div>
</div>
</div>
</div>
<?php include 'views/layouts/footer.php'; ?> <?php include 'views/layouts/footer.php'; ?>
+80 -81
View File
@@ -3,153 +3,152 @@
include 'views/layouts/header.php'; include 'views/layouts/header.php';
?> ?>
<div class="container" style="width:100%; margin-left: 0em; margin-right: 0em auto;"> <div class="container py-4">
<article> <div class="row justify-content-center">
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid var(--muted-border-color); padding-bottom: 1rem;"> <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 style="margin-bottom: 1rem;"> <div class="mb-4">
<?php if (!empty($user['avatar'])): ?> <?php if (!empty($user['avatar'])): ?>
<img src="<?= AVATARS_URL . e($user['avatar']) ?>" <img src="<?= AVATARS_URL . e($user['avatar']) ?>"
alt="<?= e($user['display_name'] ?: $user['username']) ?>" alt="<?= e($user['display_name'] ?: $user['username']) ?>"
style="width: 150px; height: 150px; border-radius: 50%; border: 3px solid var(--primary); object-fit: cover;" class="rounded-circle border border-4 border-primary"
style="width: 150px; height: 150px; object-fit: cover;"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
<?php else: ?> <?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;"> <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) ?> <?= mb_substr(e($user['display_name'] ?: $user['username']), 0, 1) ?>
</span>
</div> </div>
<?php endif; ?> <?php endif; ?>
</div> </div>
<h1 style="margin-bottom: 0.5rem;"><?= e($user['display_name'] ?: $user['username']) ?></h1> <h1 class="display-5 mb-2"><?= e($user['display_name'] ?: $user['username']) ?></h1>
<!-- Биография автора --> <!-- Биография автора -->
<?php if (!empty($user['bio'])): ?> <?php if (!empty($user['bio'])): ?>
<div style="background: var(--card-background-color); padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;"> <div class="bg-light p-4 rounded mb-4">
<?= e($user['bio']) ?> <?= e($user['bio']) ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Статистика автора --> <!-- Статистика автора -->
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: var(--muted-color);"> <div class="d-flex justify-content-center gap-4 flex-wrap">
<div style="text-align: center;"> <div class="text-center">
<div style="font-size: 1.5em; font-weight: bold; color: var(--primary);"><?= $total_books ?></div> <div class="h3 text-primary mb-0"><?= $total_books ?></div>
<div>Книг</div> <small class="text-muted">Книг</small>
</div> </div>
<div style="text-align: center;"> <div class="text-center">
<div style="font-size: 1.5em; font-weight: bold; color: var(--success);"><?= $total_chapters ?></div> <div class="h3 text-success mb-0"><?= $total_chapters ?></div>
<div>Глав</div> <small class="text-muted">Глав</small>
</div> </div>
<div style="text-align: center;"> <div class="text-center">
<div style="font-size: 1.5em; font-weight: bold; color: var(--warning);"><?= $total_words ?></div> <div class="h3 text-warning mb-0"><?= $total_words ?></div>
<div>Слов</div> <small class="text-muted">Слов</small>
</div> </div>
</div> </div>
</header> </header>
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2> <h2 class="h3 text-center mb-4">Публикации автора</h2>
<?php if (empty($books)): ?> <?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: var(--card-background-color); border-radius: 5px;"> <div class="text-center py-5">
<h3>У этого автора пока нет опубликованных книг</h3> <i class="bi bi-journal-bookmark fs-1 text-muted"></i>
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p> <h3 class="h4 text-muted mt-3">У этого автора пока нет опубликованных книг</h3>
<p class="text-muted">Следите за обновлениями, скоро здесь появятся новые произведения!</p>
</div> </div>
<?php else: ?> <?php else: ?>
<div class="author-books"> <div class="row g-4">
<?php foreach ($books as $book): ?> <?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;"> <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']): ?> <?php if ($book['cover_image']): ?>
<div style="flex-shrink: 0;">
<img src="<?= COVERS_URL . e($book['cover_image']) ?>" <img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>" alt="<?= e($book['title']) ?>"
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid var(--border-color);" class="rounded me-3"
style="width: 60px; height: 80px; object-fit: cover;"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
</div>
<?php else: ?> <?php else: ?>
<div style="flex-shrink: 0;"> <div class="bg-primary bg-gradient rounded d-flex align-items-center justify-content-center me-3"
<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;"> style="width: 60px; height: 80px;">
📚 <i class="bi bi-book text-white"></i>
</div>
</div> </div>
<?php endif; ?> <?php endif; ?>
<div class="flex-grow-1">
<div style="flex: 1;"> <h5 class="card-title mb-0">
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3> <?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']): ?> <?php if ($book['genre']): ?>
<p style="color: var(--muted-color); margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p> <p class="text-muted small mb-1"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?> <?php endif; ?>
</div>
</div>
<?php if ($book['description']): ?> <?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p> <p class="card-text text-muted small mb-3">
<?= nl2br(e($book['description'])) ?>
</p>
<?php endif; ?> <?php endif; ?>
<?php <?php
$book_stats = $bookModel->getBookStats($book['id'], true); $book_stats = $book_model->getBookStats($book['id'], true);
$chapter_count = $book_stats['chapter_count'] ?? 0; $chapter_count = $book_stats['chapter_count'] ?? 0;
$word_count = $book_stats['total_words'] ?? 0; $word_count = $book_stats['total_words'] ?? 0;
?> ?>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;"> <div class="d-flex justify-content-between align-items-center">
<a href="<?= SITE_URL ?>/book/<?= e($book['share_token']) ?>" class="adaptive-button"> <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> </a>
<small class="text-muted">
<small style="color: var(--muted-color);"> <?php if (!empty($book['is_short_story'])): ?>
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?> (рассказ) <?= number_format($word_count) ?> слов
<?php else: ?>
Глав: <?= $chapter_count ?> | <?= number_format($word_count) ?> слов
<?php endif; ?>
</small> </small>
</div> </div>
</div> </div>
</article> </div>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid var(--muted-border-color); text-align: center;"> <footer class="text-center mt-5 pt-4 border-top">
<p style="color: var(--muted-color);"> <p class="text-muted">
Страница автора создана в <?= e(APP_NAME) ?> • Страница автора создана в <?= e(APP_NAME) ?> • <?= date('Y') ?>
<?= date('Y') ?>
</p> </p>
</footer> </footer>
</div>
</article> </article>
</div> </div>
</div>
</div>
<style> <style>
.author-books article { .card {
transition: transform 0.2s ease, box-shadow 0.2s ease; 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); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1); box-shadow: 0 4px 12px rgba(0,0,0,0.1) !important;
}
.cover-placeholder {
width: 120px;
height: 160px;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
}
@media (max-width: 768px) {
.author-books article {
flex-direction: column;
text-align: center;
}
.author-books .book-cover {
align-self: center;
}
header .author-stats {
flex-direction: column;
gap: 1rem;
}
} }
</style> </style>