version 1.0

This commit is contained in:
mirivlad 2025-11-21 17:10:05 +08:00
parent b904e92da9
commit 2f9fba663a
655 changed files with 2417 additions and 374 deletions

View File

@ -1,4 +1,6 @@
/* style.css */
/* style.css - оптимизированная версия */
/* Базовые стили */
h1, h2, h3, h4, h5, h6 {
margin-bottom: 1rem;
}
@ -22,6 +24,18 @@ input:not([type="checkbox"], [type="radio"]), select {
height: 2em;
}
/* Убираем конфликтующие стили Pico CSS */
article header, article footer {
margin: 0;
padding: 0;
}
article > header, article > footer {
margin: 0;
padding: 0;
}
/* Уведомления */
.alert {
padding: 1rem;
margin: 1rem 0;
@ -40,6 +54,7 @@ input:not([type="checkbox"], [type="radio"]), select {
border: 1px solid #c8e6c9;
}
/* Кнопки */
.compact-button {
padding: 3px 8px !important;
font-size: 0.85rem;
@ -62,23 +77,6 @@ input:not([type="checkbox"], [type="radio"]), select {
opacity: 0.9;
}
.compact-table {
width: 100%;
font-size: 0.9rem;
border-collapse: collapse;
}
.compact-table th,
.compact-table td {
padding: 6px 8px;
border-bottom: 1px solid #eee;
}
.compact-table th {
background: #f5f5f5;
font-weight: bold;
}
.button-group {
display: flex;
gap: 5px;
@ -181,23 +179,66 @@ input:not([type="checkbox"], [type="radio"]), select {
color: var(--primary-inverse);
}
@media (max-width: 768px) {
.adaptive-button {
flex: 1 1 calc(50% - 10px);
min-width: calc(50% - 10px);
font-size: 0.8rem;
padding: 10px 8px !important;
}
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
text-decoration: none;
border: 1px solid;
border-radius: 4px;
cursor: pointer;
box-sizing: border-box;
height: 44px;
min-width: 140px;
white-space: nowrap;
transition: all 0.3s ease;
text-align: center;
}
@media (max-width: 480px) {
.adaptive-button {
flex: 1 1 100%;
min-width: 100%;
}
.action-button.primary {
background: #007bff;
border-color: #007bff;
color: #fff;
}
/* Стили для Markdown редактора */
.action-button.primary:hover {
opacity: 0.9;
}
.action-button.delete {
margin-top: 1rem;
background: #ff4444;
border-color: #ff4444;
color: white;
}
.action-button.delete:hover {
background: #dd3333;
border-color: #dd3333;
color: white;
}
/* Таблицы */
.compact-table {
width: 100%;
font-size: 0.9rem;
border-collapse: collapse;
}
.compact-table th,
.compact-table td {
padding: 6px 8px;
border-bottom: 1px solid #eee;
}
.compact-table th {
background: #f5f5f5;
font-weight: bold;
}
/* Markdown редактор */
#content {
transition: all 0.3s ease;
border: 1px solid #ddd;
@ -220,9 +261,31 @@ input:not([type="checkbox"], [type="radio"]), select {
background-color: #fff;
}
/* Стили для полноэкранного режима */
/* Кастомный скроллбар для редактора */
#content::-webkit-scrollbar {
width: 8px;
}
#content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
#content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
#content {
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
/* Элементы управления редактором */
.editor-controls {
position: sticky !important;
top: 10px !important;
@ -256,11 +319,9 @@ input:not([type="checkbox"], [type="radio"]), select {
box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important;
}
/* Стили для полноэкранных кнопок */
/* Полноэкранный режим редактора */
#fullscreen-controls {
position: fixed !important;
top: 15px !important;
right: 15px !important;
z-index: 9999 !important;
display: flex !important;
gap: 5px !important;
@ -288,135 +349,7 @@ input:not([type="checkbox"], [type="radio"]), select {
box-shadow: 0 4px 8px rgba(0,0,0,0.4) !important;
}
/* Стили для полноэкранного режима - упрощенные для мобильных */
#content.mobile-fullscreen {
position: fixed !important;
top: 50px !important;
left: 0 !important;
width: 100vw !important;
height: calc(100vh - 100px) !important; /* Оставляем место для клавиатуры */
z-index: 9998 !important;
background-color: white !important;
border: 2px solid #007bff !important;
border-radius: 0 !important;
font-size: 18px !important;
padding: 15px !important;
margin: 0 !important;
box-sizing: border-box !important;
resize: none !important;
box-shadow: none !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
}
/* Стили для ПК в полноэкранном режиме */
#content.desktop-fullscreen {
position: fixed !important;
top: 5vh !important;
left: 5vw !important;
width: 90vw !important;
height: 90vh !important;
z-index: 9998 !important;
background-color: white !important;
border: 2px solid #007bff !important;
border-radius: 8px !important;
font-size: 16px !important;
padding: 20px !important;
margin: 0 !important;
box-sizing: border-box !important;
resize: none !important;
box-shadow: 0 0 20px rgba(0,0,0,0.3) !important;
}
/* Улучшенные стили для кнопок в полноэкранном режиме */
#fullscreen-controls {
position: fixed !important;
z-index: 9999 !important;
display: flex !important;
gap: 5px !important;
}
/* Для мобильных устройств */
@media (max-width: 768px) {
#fullscreen-controls {
top: 10px !important;
right: 10px !important;
}
#fullscreen-controls button {
width: 60px !important;
height: 60px !important;
font-size: 24px !important;
border: 2px solid #ddd !important;
}
.editor-controls button {
width: 50px !important;
height: 50px !important;
font-size: 20px !important;
}
/* Улучшенный полноэкранный режим для мобильных с учетом клавиатуры */
#content.mobile-fullscreen {
padding: 15px !important;
font-size: 16px !important;
}
/* Дополнительные стили для очень маленьких экранов */
@media (max-width: 480px) {
#fullscreen-controls button {
width: 55px !important;
height: 55px !important;
font-size: 22px !important;
}
#content.mobile-fullscreen {
padding: 10px !important;
font-size: 16px !important;
}
}
}
/* Для десктопов */
@media (min-width: 769px) {
#fullscreen-controls {
top: 15px !important;
right: 15px !important;
}
#fullscreen-controls button {
width: 50px !important;
height: 50px !important;
font-size: 20px !important;
}
}
/* Кастомный скроллбар */
#content::-webkit-scrollbar {
width: 8px;
}
#content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
#content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4px;
}
#content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Для Firefox */
#content {
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
/* Стили для отображения контента книг */
.book-content {
line-height: 1.7;
font-family: Georgia, serif;
@ -515,109 +448,13 @@ input:not([type="checkbox"], [type="radio"]), select {
background: #f9f9f9;
}
/* Стили для диалогов */
.dialogue {
margin-left: 2rem;
font-style: italic;
color: #2c5aa0;
}
/* Адаптивность для мобильных */
@media (max-width: 768px) {
.book-content {
font-size: 16px;
line-height: 1.6;
}
.book-content h1 {
font-size: 1.6em;
}
.book-content h2 {
font-size: 1.4em;
}
.book-content h3 {
font-size: 1.2em;
}
.book-content pre {
font-size: 14px;
}
}
/* Стили для кнопок действий */
.action-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.75rem 1.5rem;
font-size: 0.9rem;
text-decoration: none;
border: 1px solid;
border-radius: 4px;
cursor: pointer;
box-sizing: border-box;
height: 44px;
min-width: 140px;
white-space: nowrap;
transition: all 0.3s ease;
text-align: center;
}
.action-button.primary {
background: #007bff;
border-color: #007bff;
color: #fff;
}
.action-button.primary:hover {
opacity: 0.9;
}
.action-button.delete {
margin-top: 1rem;
background: #ff4444;
border-color: #ff4444;
color: white;
}
.action-button.delete:hover {
background: #dd3333;
border-color: #dd3333;
color: white;
}
/* Адаптивность для мобильных */
@media (max-width: 768px) {
.action-button {
padding: 0.6rem 1rem;
font-size: 0.85rem;
min-width: 120px;
height: 42px;
}
}
@media (max-width: 480px) {
.action-button {
padding: 0.5rem 0.8rem;
font-size: 0.8rem;
min-width: 110px;
height: 40px;
}
/* На очень маленьких экранах делаем кнопки блочными */
.action-buttons-container {
flex-direction: column;
width: 100%;
}
.action-buttons-container .action-button {
width: 100%;
min-width: auto;
}
}
/* assets/css/style.css - добавить */
/* Обложки и медиа */
.book-cover {
transition: transform 0.3s ease;
}
@ -639,17 +476,276 @@ input:not([type="checkbox"], [type="radio"]), select {
margin: 0 auto 1rem;
}
/* Стили для страницы автора */
.author-books .book-cover {
transition: transform 0.3s ease;
/* Аватарки и профиль */
.avatar-container {
text-align: center;
margin-bottom: 1.5rem;
}
.author-books .book-cover:hover {
transform: scale(1.05);
.avatar {
width: 150px;
height: 150px;
border-radius: 50%;
border: 3px solid #007bff;
object-fit: cover;
}
/* Адаптивность для страницы автора */
.avatar-placeholder {
width: 150px;
height: 150px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 3rem;
margin: 0 auto;
}
.author-bio {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin: 1rem 0;
line-height: 1.6;
}
.author-bio h1, .author-bio h2, .author-bio h3 {
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.author-bio p {
margin-bottom: 1rem;
}
.author-bio ul, .author-bio ol {
margin-bottom: 1rem;
padding-left: 2rem;
}
.author-bio blockquote {
border-left: 4px solid #007bff;
padding-left: 1rem;
margin-left: 0;
color: #555;
font-style: italic;
}
/* Статистика */
.author-stats {
display: flex;
justify-content: center;
gap: 2rem;
flex-wrap: wrap;
margin: 1rem 0;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 1.5em;
font-weight: bold;
color: #007bff;
}
.stat-label {
font-size: 0.9em;
color: #666;
}
/* Серии книг */
.series-books article {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
}
.series-books article:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.series-info {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.series-badge {
display: inline-block;
background: #007bff;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
margin-left: 0.5rem;
}
/* Dashboard */
.dashboard-buttons {
display: flex;
gap: 10px;
margin-top: 1rem;
flex-wrap: nowrap;
}
.dashboard-button {
flex: 1;
text-align: center;
padding: 0.75rem 0.5rem;
text-decoration: none;
border: 1px solid var(--secondary);
border-radius: 4px;
background: var(--secondary);
color: var(--secondary-inverse);
font-size: 0.9rem;
transition: all 0.3s ease;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
.dashboard-button:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.dashboard-button.new {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-inverse);
flex: 0.7;
}
.stats-list {
margin-top: 1rem;
}
.stats-list p {
margin: 0.5rem 0;
padding: 0.3rem 0;
border-bottom: 1px solid #f0f0f0;
}
.stats-list p:last-child {
border-bottom: none;
}
.series-stats {
margin-top: 1rem;
padding: 1rem;
background: #f8f9fa;
border-radius: 5px;
border-left: 4px solid #6f42c1;
}
.series-stats p {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.dashboard-section {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.dashboard-item {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
padding: 1rem;
}
.dashboard-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.welcome-message {
text-align: center;
padding: 3rem;
background: #f9f9f9;
border-radius: 8px;
margin-top: 2rem;
}
.welcome-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 1.5rem;
}
.action-buttons {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.action-buttons .compact-button {
flex: 1;
min-width: 80px;
text-align: center;
font-size: 0.8rem;
padding: 0.3rem 0.5rem;
}
/* Улучшения для grid в dashboard */
.grid {
gap: 1rem;
}
.grid article {
margin: 0;
padding: 1.5rem;
}
/* Адаптивность */
@media (max-width: 768px) {
.adaptive-button {
flex: 1 1 calc(50% - 10px);
min-width: calc(50% - 10px);
font-size: 0.8rem;
padding: 10px 8px !important;
}
.action-button {
padding: 0.6rem 1rem;
font-size: 0.85rem;
min-width: 120px;
height: 42px;
}
.book-content {
font-size: 16px;
line-height: 1.6;
}
.book-content h1 {
font-size: 1.6em;
}
.book-content h2 {
font-size: 1.4em;
}
.book-content h3 {
font-size: 1.2em;
}
.book-content pre {
font-size: 14px;
}
.author-books article {
flex-direction: column;
}
@ -657,4 +753,175 @@ input:not([type="checkbox"], [type="radio"]), select {
.author-books .book-cover {
align-self: center;
}
.avatar, .avatar-placeholder {
width: 120px;
height: 120px;
font-size: 2.5rem;
}
.author-stats {
gap: 1rem;
}
.stat-number {
font-size: 1.3em;
}
.dashboard-buttons {
flex-direction: column;
gap: 8px;
}
.dashboard-button {
flex: none;
width: 100%;
}
.dashboard-button.new {
flex: none;
width: 100%;
}
.welcome-buttons {
flex-direction: column;
align-items: center;
}
.welcome-buttons a {
width: 100%;
max-width: 250px;
}
.action-buttons {
flex-direction: column;
}
.action-buttons .compact-button {
width: 100%;
}
#fullscreen-controls {
top: 10px !important;
right: 10px !important;
}
#fullscreen-controls button {
width: 60px !important;
height: 60px !important;
font-size: 24px !important;
border: 2px solid #ddd !important;
}
.editor-controls button {
width: 50px !important;
height: 50px !important;
font-size: 20px !important;
}
}
@media (max-width: 480px) {
.adaptive-button {
flex: 1 1 100%;
min-width: 100%;
}
.action-button {
padding: 0.5rem 0.8rem;
font-size: 0.8rem;
min-width: 110px;
height: 40px;
}
.action-buttons-container {
flex-direction: column;
width: 100%;
}
.action-buttons-container .action-button {
width: 100%;
min-width: auto;
}
.avatar, .avatar-placeholder {
width: 100px;
height: 100px;
font-size: 2rem;
}
.author-stats {
flex-direction: column;
gap: 0.5rem;
}
.dashboard-item {
padding: 0.8rem;
}
.dashboard-button {
font-size: 0.85rem;
padding: 0.6rem 0.4rem;
}
.welcome-message {
padding: 2rem 1rem;
}
#fullscreen-controls button {
width: 55px !important;
height: 55px !important;
font-size: 22px !important;
}
}
@media (min-width: 769px) {
#fullscreen-controls {
top: 15px !important;
right: 15px !important;
}
#fullscreen-controls button {
width: 50px !important;
height: 50px !important;
font-size: 20px !important;
}
}
/* Полноэкранные режимы редактора */
#content.mobile-fullscreen {
position: fixed !important;
top: 50px !important;
left: 0 !important;
width: 100vw !important;
height: calc(100vh - 100px) !important;
z-index: 9998 !important;
background-color: white !important;
border: 2px solid #007bff !important;
border-radius: 0 !important;
font-size: 18px !important;
padding: 15px !important;
margin: 0 !important;
box-sizing: border-box !important;
resize: none !important;
box-shadow: none !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
}
#content.desktop-fullscreen {
position: fixed !important;
top: 5vh !important;
left: 5vw !important;
width: 90vw !important;
height: 90vh !important;
z-index: 9998 !important;
background-color: white !important;
border: 2px solid #007bff !important;
border-radius: 8px !important;
font-size: 16px !important;
padding: 20px !important;
margin: 0 !important;
box-sizing: border-box !important;
resize: none !important;
box-shadow: 0 0 20px rgba(0,0,0,0.3) !important;
}

191
author.php Normal file → Executable file
View File

@ -1,6 +1,8 @@
<?php
require_once 'config/config.php';
require_once 'models/Book.php';
require_once 'includes/parsedown/ParsedownExtra.php';
$Parsedown = new ParsedownExtra();
$author_id = (int)($_GET['id'] ?? 0);
if (!$author_id) {
@ -10,7 +12,7 @@ if (!$author_id) {
exit;
}
$stmt = $pdo->prepare("SELECT id, username, display_name FROM users WHERE id = ?");
$stmt = $pdo->prepare("SELECT id, username, display_name, avatar, bio FROM users WHERE id = ?");
$stmt->execute([$author_id]);
$author = $stmt->fetch(PDO::FETCH_ASSOC);
@ -24,44 +26,167 @@ if (!$author) {
$bookModel = new Book($pdo);
$books = $bookModel->findByUser($author_id, true); // только опубликованные
// Получаем статистику автора
$total_books = count($books);
$total_words = 0;
$total_chapters = 0;
foreach ($books as $book) {
$book_stats = $bookModel->getBookStats($book['id'], true);
$total_words += $book_stats['total_words'] ?? 0;
$total_chapters += $book_stats['chapter_count'] ?? 0;
}
$page_title = ($author['display_name'] ?: $author['username']) . ' — публичная страница';
include 'views/header.php';
?>
<h1><?= e($author['display_name'] ?: $author['username']) ?></h1>
<div class="container">
<article style="max-width: 800px; margin: 0 auto;">
<header style="text-align: center; margin-bottom: 2rem; border-bottom: 2px solid #eee; padding-bottom: 1rem;">
<!-- Аватарка автора -->
<div style="margin-bottom: 1rem;">
<?php if (!empty($author['avatar'])): ?>
<img src="<?= AVATARS_URL . e($author['avatar']) ?>"
alt="<?= e($author['display_name'] ?: $author['username']) ?>"
style="width: 150px; height: 150px; border-radius: 50%; border: 3px solid #007bff; object-fit: cover;"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width: 150px; height: 150px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 3rem; margin: 0 auto;">
<?= mb_substr(e($author['display_name'] ?: $author['username']), 0, 1) ?>
</div>
<?php endif; ?>
</div>
<?php if (empty($books)): ?>
<p>У этого автора пока нет опубликованных книг.</p>
<?php else: ?>
<div class="grid">
<?php foreach ($books as $b): ?>
<article style="display: flex; gap: 1rem; align-items: flex-start;">
<?php if ($b['cover_image']): ?>
<div style="flex-shrink: 0;">
<img src="<?= COVERS_URL . e($b['cover_image']) ?>"
alt="<?= e($b['title']) ?>"
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;"
onerror="this.style.display='none'">
</div>
<h1 style="margin-bottom: 0.5rem;"><?= e($author['display_name'] ?: $author['username']) ?></h1>
<!-- Биография автора -->
<?php if (!empty($author['bio'])): ?>
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
<?= $Parsedown->text($author['bio']) ?>
</div>
<?php endif; ?>
<!-- Статистика автора -->
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #007bff;"><?= $total_books ?></div>
<div>Книг</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #28a745;"><?= $total_chapters ?></div>
<div>Глав</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #6f42c1;"><?= $total_words ?></div>
<div>Слов</div>
</div>
</div>
</header>
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2>
<?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
<h3>У этого автора пока нет опубликованных книг</h3>
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p>
</div>
<?php else: ?>
<div style="flex-shrink: 0;">
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
</div>
<div class="author-books">
<?php foreach ($books as $book): ?>
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1.5rem; background: #f8f9fa; border-radius: 8px;">
<?php if ($book['cover_image']): ?>
<div style="flex-shrink: 0;">
<img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($book['title']) ?>"
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;"
onerror="this.style.display='none'">
</div>
<?php else: ?>
<div style="flex-shrink: 0;">
<div class="cover-placeholder" style="width: 120px; height: 160px;">📚</div>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
<?php if ($book['genre']): ?>
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?>
<?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
<?php endif; ?>
<?php
$book_stats = $bookModel->getBookStats($book['id'], true);
$chapter_count = $book_stats['chapter_count'] ?? 0;
$word_count = $book_stats['total_words'] ?? 0;
?>
<div style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center;">
<a href="view_book.php?share_token=<?= e($book['share_token']) ?>" class="adaptive-button">
Читать книгу
</a>
<small style="color: #666;">
Глав: <?= $chapter_count ?> | Слов: <?= $word_count ?>
</small>
</div>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<div style="flex: 1;">
<h3 style="margin-top: 0;"><?= e($b['title']) ?></h3>
<?php if ($b['genre']): ?>
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($b['genre']) ?></em></p>
<?php endif; ?>
<?php if ($b['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($b['description'])) ?></p>
<?php endif; ?>
<a href="view_book.php?share_token=<?= e($b['share_token']) ?>" class="adaptive-button">Читать</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 2px solid #eee; text-align: center;">
<p style="color: #666;">
Страница автора создана в <?= e(APP_NAME) ?>
<?= date('Y') ?>
</p>
</footer>
</article>
</div>
<style>
.author-books article {
transition: transform 0.2s ease, box-shadow 0.2s ease;
border: 1px solid #e0e0e0;
}
.author-books article:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.cover-placeholder {
width: 120px;
height: 160px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
}
@media (max-width: 768px) {
.author-books article {
flex-direction: column;
text-align: center;
}
.author-books .book-cover {
align-self: center;
}
header .author-stats {
flex-direction: column;
gap: 1rem;
}
}
</style>
<?php include 'views/footer.php'; ?>

0
book_delete.php Normal file → Executable file
View File

0
book_delete_all.php Normal file → Executable file
View File

View File

@ -36,11 +36,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($title)) {
$_SESSION['error'] = "Название книги обязательно";
} else {
$series_id = !empty($_POST['series_id']) ? (int)$_POST['series_id'] : null;
$sort_order_in_series = !empty($_POST['sort_order_in_series']) ? (int)$_POST['sort_order_in_series'] : null;
// Если серия указана, но порядок нет - генерируем автоматически
if ($series_id && !$sort_order_in_series) {
$seriesModel = new Series($pdo);
$sort_order_in_series = $seriesModel->getNextSortOrder($series_id);
}
$data = [
'title' => $title,
'description' => $description,
'genre' => $genre,
'user_id' => $user_id
'user_id' => $user_id,
'series_id' => $series_id,
'sort_order_in_series' => $sort_order_in_series
];
$data['published'] = isset($_POST['published']) ? 1 : 0;
@ -110,7 +121,32 @@ include 'views/header.php';
value="<?= e($book['genre'] ?? $_POST['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..."
style="width: 100%; margin-bottom: 1.5rem;">
<label for="series_id" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Серия
</label>
<select id="series_id" name="series_id" style="width: 100%; margin-bottom: 1rem;">
<option value="">-- Без серии --</option>
<?php
$seriesModel = new Series($pdo);
$user_series = $seriesModel->findByUser($user_id, false);
foreach ($user_series as $ser):
$selected = ($ser['id'] == ($book['series_id'] ?? 0)) ? 'selected' : '';
?>
<option value="<?= $ser['id'] ?>" <?= $selected ?>>
<?= e($ser['title']) ?>
</option>
<?php endforeach; ?>
</select>
<label for="sort_order_in_series" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Порядок в серии
</label>
<input type="number" id="sort_order_in_series" name="sort_order_in_series"
value="<?= e($book['sort_order_in_series'] ?? '') ?>"
placeholder="Номер по порядку в серии"
min="1"
style="width: 100%; margin-bottom: 1.5rem;">
<!-- ПОЛЕ ДЛЯ ОБЛОЖКИ -->
<div style="margin-bottom: 1.5rem;">
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">

View File

@ -57,6 +57,23 @@ include 'views/header.php';
<?php endif; ?>
<header>
<h3><?= e($book['title']) ?>
<?php if ($book['series_id']): ?>
<?php
$series_stmt = $pdo->prepare("SELECT title FROM series WHERE id = ?");
$series_stmt->execute([$book['series_id']]);
$series_title = $series_stmt->fetch()['title'] ?? '';
?>
<?php if ($series_title): ?>
<div style="margin: 0.3rem 0;">
<small style="color: #007bff;">
📚 Серия: <?= e($series_title) ?>
<?php if ($book['sort_order_in_series']): ?>
(Книга <?= $book['sort_order_in_series'] ?>)
<?php endif; ?>
</small>
</div>
<?php endif; ?>
<?php endif; ?>
<div style="display: flex; gap: 3px; float:right;">
<a href="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary" title="Редактировать книгу">
✏️

0
composer.json Normal file → Executable file
View File

0
composer.lock generated Normal file → Executable file
View File

16
config/config.php Executable file → Normal file
View File

@ -1,5 +1,5 @@
<?php
// config/config.php
// config/config.php - автоматически сгенерирован установщиком
// Подключаем функции
require_once __DIR__ . '/../includes/functions.php';
session_start();
@ -10,17 +10,22 @@ define('DB_USER', 'writer_mirv');
define('DB_PASS', 'writer_moloko22');
define('DB_NAME', 'writer_app');
define('SITE_URL', 'https://writer.mirv.top');
//define('BASE_URL', 'http://' . $_SERVER['HTTP_HOST'] . '/'); // Измените на ваш домен
// Настройки приложения
define('APP_NAME', 'Web Writer');
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
define('COVERS_URL', SITE_URL . '/uploads/covers/');
define('AVATARS_PATH', UPLOAD_PATH . 'avatars/');
define('AVATARS_URL', SITE_URL . '/uploads/avatars/');
// Создаем папку для загрузок, если ее нет
// if (!file_exists(COVERS_PATH)) {
// mkdir(COVERS_PATH, 0765, true);
// }
if (!file_exists(COVERS_PATH)) {
mkdir(COVERS_PATH, 0755, true);
}
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
// Подключение к базе данных
try {
@ -31,7 +36,6 @@ try {
die("Ошибка подключения к базе данных");
}
// Автозагрузка моделей
spl_autoload_register(function ($class_name) {
$model_file = __DIR__ . '/../models/' . $class_name . '.php';

View File

@ -4,7 +4,33 @@ require_login();
$user_id = $_SESSION['user_id'];
$bookModel = new Book($pdo);
$seriesModel = new Series($pdo);
$books = $bookModel->findByUser($user_id);
$series = $seriesModel->findByUser($user_id);
// Статистика по книгам
$total_chapters = 0;
$total_words = 0;
foreach ($books as $book) {
$total_chapters += $book['chapter_count'];
$total_words += $book['total_words'];
}
// Статистика по сериям
$series_stats = [
'total_series' => count($series),
'series_with_books' => 0,
'total_books_in_series' => 0
];
foreach ($series as $ser) {
$series_books = $seriesModel->getBooksInSeries($ser['id']);
$series_stats['total_books_in_series'] += count($series_books);
if (count($series_books) > 0) {
$series_stats['series_with_books']++;
}
}
$page_title = "Панель управления";
include 'views/header.php';
@ -12,49 +38,152 @@ include 'views/header.php';
<h1>Добро пожаловать, <?= e($_SESSION['display_name']) ?>!</h1>
<div style="margin-bottom: 1rem;">
<a href="profile.php" class="adaptive-button secondary">✏️ Редактировать профиль</a>
</div>
<div class="grid">
<article>
<h2>📚 Мои книги</h2>
<p>Управляйте вашими книгами и главами</p>
<a href="books.php" role="button">
Мои книги (<?= count($books) ?>)
</a>
&nbsp;&nbsp;
<a href="book_edit.php" role="button"> Новая книга</a>
<div class="dashboard-buttons">
<a href="books.php" role="button" class="dashboard-button">
Мои книги (<?= count($books) ?>)
</a>
<a href="book_edit.php" role="button" class="dashboard-button new">
Новая книга
</a>
</div>
</article>
<article>
<h2>📊 Статистика</h2>
<?php
$total_chapters = 0;
$total_words = 0;
foreach ($books as $book) {
$total_chapters += $book['chapter_count'];
$total_words += $book['total_words'];
}
?>
<p><strong>Книг:</strong> <?= count($books) ?></p>
<p><strong>Глав:</strong> <?= $total_chapters ?></p>
<p><strong>Всего слов:</strong> <?= $total_words ?></p>
<div class="stats-list">
<p><strong>Книг:</strong> <?= count($books) ?></p>
<p><strong>Глав:</strong> <?= $total_chapters ?></p>
<p><strong>Всего слов:</strong> <?= $total_words ?></p>
<?php if ($total_words > 0): ?>
<p><strong>Средняя глава:</strong> <?= round($total_words / max(1, $total_chapters)) ?> слов</p>
<?php endif; ?>
</div>
</article>
<article>
<h2>📖 Мои серии</h2>
<p>Управляйте сериями книг</p>
<div class="dashboard-buttons">
<a href="series.php" role="button" class="dashboard-button">
Мои серии (<?= $series_stats['total_series'] ?>)
</a>
<a href="series_edit.php" role="button" class="dashboard-button new">
Новая серия
</a>
</div>
<?php if ($series_stats['total_series'] > 0): ?>
<div class="series-stats">
<p><strong>Книг в сериях:</strong> <?= $series_stats['total_books_in_series'] ?></p>
<p><strong>Заполненных серий:</strong> <?= $series_stats['series_with_books'] ?></p>
</div>
<?php endif; ?>
</article>
</div>
<?php if (!empty($books)): ?>
<div style="margin-top: 2rem;">
<div class="dashboard-section">
<h2>Недавние книги</h2>
<div class="grid">
<article>
<h2>Недавние книги</h2>
<?php foreach (array_slice($books, 0, 3) as $book): ?>
<article>
<h4><?= e($book['title']) ?></h4>
<p>Глав: <?= $book['chapter_count'] ?> | Слов: <?= $book['total_words'] ?></p>
<a href="book_edit.php?id=<?= $book['id'] ?>" role="button" class="secondary">
<?php foreach (array_slice($books, 0, 3) as $book): ?>
<article class="dashboard-item">
<h4>
<?= e($book['title']) ?>
<?php if ($book['series_id']): ?>
<?php
$series_stmt = $pdo->prepare("SELECT title FROM series WHERE id = ?");
$series_stmt->execute([$book['series_id']]);
$series_title = $series_stmt->fetch()['title'] ?? '';
?>
<?php if ($series_title): ?>
<br><small style="color: #007bff;">📚 <?= e($series_title) ?></small>
<?php endif; ?>
<?php endif; ?>
</h4>
<p>Глав: <?= $book['chapter_count'] ?> | Слов: <?= $book['total_words'] ?></p>
<div class="action-buttons">
<a href="book_edit.php?id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
Редактировать
</a>
</article>
<?php endforeach; ?>
<a href="chapters.php?book_id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
Главы
</a>
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" role="button" class="compact-button secondary" target="_blank">
Просмотр
</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php if (count($books) > 3): ?>
<div style="text-align: center; margin-top: 1rem;">
<a href="books.php" role="button" class="secondary">📚 Показать все книги (<?= count($books) ?>)</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($series)): ?>
<div class="dashboard-section">
<h2>Недавние серии</h2>
<div class="grid">
<?php foreach (array_slice($series, 0, 3) as $ser): ?>
<article class="dashboard-item">
<h4><?= e($ser['title']) ?></h4>
<?php
$books_in_series = $seriesModel->getBooksInSeries($ser['id']);
$series_words = 0;
$series_chapters = 0;
foreach ($books_in_series as $book) {
$book_stats = $bookModel->getBookStats($book['id']);
$series_words += $book_stats['total_words'] ?? 0;
$series_chapters += $book_stats['chapter_count'] ?? 0;
}
?>
<p>Книг: <?= count($books_in_series) ?> | Глав: <?= $series_chapters ?> | Слов: <?= $series_words ?></p>
<div class="action-buttons">
<a href="series_edit.php?id=<?= $ser['id'] ?>" role="button" class="compact-button secondary">
Редактировать
</a>
<a href="view_series.php?id=<?= $ser['id'] ?>" role="button" class="compact-button secondary" target="_blank">
Просмотр
</a>
</div>
</article>
<?php endforeach; ?>
</div>
<?php if (count($series) > 3): ?>
<div style="text-align: center; margin-top: 1rem;">
<a href="series.php" role="button" class="secondary">📖 Показать все серии (<?= count($series) ?>)</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (empty($books) && empty($series)): ?>
<div class="welcome-message">
<h3>Добро пожаловать в <?= e(APP_NAME) ?>!</h3>
<p>Начните создавать свои литературные произведения</p>
<div class="welcome-buttons">
<a href="book_edit.php" role="button" class="contrast">📖 Создать первую книгу</a>
<a href="series_edit.php" role="button" class="secondary">📚 Создать первую серию</a>
</div>
<div style="margin-top: 1.5rem;">
<a href="profile.php" role="button" class="secondary">✏️ Настроить профиль</a>
</div>
</div>
<?php endif; ?>

0
export_book.php Normal file → Executable file
View File

View File

@ -189,4 +189,170 @@ function optimizeImage($file_path) {
imagedestroy($new_image);
}
}
// В includes/functions.php, после функции handleCoverUpload
function handleAvatarUpload($file, $user_id) {
global $pdo;
// Проверяем папку для загрузок
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
$allowed_types = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'];
$max_size = 2 * 1024 * 1024; // 2MB
// Проверка типа файла
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime_type = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mime_type, $allowed_types)) {
return ['success' => false, 'error' => 'Разрешены только JPG, PNG, GIF и WebP изображения'];
}
// Проверка размера
if ($file['size'] > $max_size) {
return ['success' => false, 'error' => 'Размер изображения не должен превышать 2MB'];
}
// Проверка на ошибки загрузки
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['success' => false, 'error' => 'Ошибка загрузки файла: ' . $file['error']];
}
// Проверка реального типа файла по содержимому
$allowed_signatures = [
'image/jpeg' => "\xFF\xD8\xFF",
'image/png' => "\x89\x50\x4E\x47",
'image/gif' => "GIF",
'image/webp' => "RIFF"
];
$file_content = file_get_contents($file['tmp_name']);
$signature = substr($file_content, 0, 4);
$valid_signature = false;
foreach ($allowed_signatures as $type => $sig) {
if (strpos($signature, $sig) === 0) {
$valid_signature = true;
break;
}
}
if (!$valid_signature) {
return ['success' => false, 'error' => 'Неверный формат изображения'];
}
// Генерация уникального имени файла
$extension = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = 'avatar_' . $user_id . '_' . time() . '.' . $extension;
$file_path = AVATARS_PATH . $filename;
// Удаляем старый аватар если есть
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$old_file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($old_file_path)) {
unlink($old_file_path);
}
}
// Сохраняем новую аватарку
if (move_uploaded_file($file['tmp_name'], $file_path)) {
// Оптимизируем изображение
optimizeAvatar($file_path);
return ['success' => true, 'filename' => $filename];
} else {
return ['success' => false, 'error' => 'Не удалось сохранить файл'];
}
}
function optimizeAvatar($file_path) {
// Оптимизация аватарки - ресайз до 200x200
list($width, $height, $type) = getimagesize($file_path);
$max_size = 200;
if ($width > $max_size || $height > $max_size) {
// Вычисляем новые размеры
$ratio = $width / $height;
if ($ratio > 1) {
$new_width = $max_size;
$new_height = $max_size / $ratio;
} else {
$new_width = $max_size * $ratio;
$new_height = $max_size;
}
// Создаем новое изображение
$new_image = imagecreatetruecolor($new_width, $new_height);
// Загружаем исходное изображение в зависимости от типа
switch ($type) {
case IMAGETYPE_JPEG:
$source = imagecreatefromjpeg($file_path);
break;
case IMAGETYPE_PNG:
$source = imagecreatefrompng($file_path);
// Сохраняем прозрачность для PNG
imagecolortransparent($new_image, imagecolorallocatealpha($new_image, 0, 0, 0, 127));
imagealphablending($new_image, false);
imagesavealpha($new_image, true);
break;
case IMAGETYPE_GIF:
$source = imagecreatefromgif($file_path);
break;
case IMAGETYPE_WEBP:
$source = imagecreatefromwebp($file_path);
break;
default:
return; // Не поддерживаемый тип
}
// Ресайз и сохраняем
imagecopyresampled($new_image, $source, 0, 0, 0, 0, $new_width, $new_height, $width, $height);
switch ($type) {
case IMAGETYPE_JPEG:
imagejpeg($new_image, $file_path, 85);
break;
case IMAGETYPE_PNG:
imagepng($new_image, $file_path, 8);
break;
case IMAGETYPE_GIF:
imagegif($new_image, $file_path);
break;
case IMAGETYPE_WEBP:
imagewebp($new_image, $file_path, 85);
break;
}
// Освобождаем память
imagedestroy($source);
imagedestroy($new_image);
}
}
function deleteUserAvatar($user_id) {
global $pdo;
$userModel = new User($pdo);
$user = $userModel->findById($user_id);
if (!empty($user['avatar'])) {
$file_path = AVATARS_PATH . $user['avatar'];
if (file_exists($file_path)) {
unlink($file_path);
}
// Обновляем запись в БД
$stmt = $pdo->prepare("UPDATE users SET avatar = NULL WHERE id = ?");
return $stmt->execute([$user_id]);
}
return true;
}
?>

440
install.php Normal file
View File

@ -0,0 +1,440 @@
<?php
// install.php - установщик приложения
// Проверяем, не установлено ли приложение уже
if (file_exists('config/config.php')) {
die('Приложение уже установлено. Для переустановки удалите файл config/config.php');
}
// SQL для создания таблиц
$database_sql = <<<SQL
SET FOREIGN_KEY_CHECKS=0;
-- Удаляем существующие таблицы в правильном порядке (сначала дочерние, потом родительские)
DROP TABLE IF EXISTS `user_sessions`;
DROP TABLE IF EXISTS `chapters`;
DROP TABLE IF EXISTS `books`;
DROP TABLE IF EXISTS `series`;
DROP TABLE IF EXISTS `users`;
-- Таблица пользователей
CREATE TABLE `users` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`username` varchar(255) NOT NULL,
`display_name` varchar(255) DEFAULT NULL,
`password_hash` varchar(255) NOT NULL,
`email` varchar(255) DEFAULT NULL,
`avatar` varchar(500) DEFAULT NULL,
`bio` text DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`last_login` timestamp NULL DEFAULT NULL,
`is_active` tinyint(1) DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица серий
CREATE TABLE `series` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`user_id` int(11) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `idx_series_user_id` (`user_id`),
CONSTRAINT `series_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица книг
CREATE TABLE `books` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`title` varchar(255) NOT NULL,
`description` text DEFAULT NULL,
`genre` varchar(100) DEFAULT NULL,
`cover_image` varchar(500) DEFAULT NULL,
`user_id` int(11) NOT NULL,
`series_id` int(11) DEFAULT NULL,
`sort_order_in_series` int(11) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
`share_token` varchar(32) DEFAULT NULL,
`published` tinyint(1) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE KEY `share_token` (`share_token`),
KEY `user_id` (`user_id`),
KEY `series_id` (`series_id`),
KEY `idx_sort_order_in_series` (`sort_order_in_series`),
KEY `idx_books_series_id` (`series_id`),
KEY `idx_books_sort_order` (`sort_order_in_series`),
CONSTRAINT `books_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
CONSTRAINT `books_ibfk_2` FOREIGN KEY (`series_id`) REFERENCES `series` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица глав
CREATE TABLE `chapters` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`book_id` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`content` text NOT NULL,
`word_count` int(11) DEFAULT 0,
`sort_order` int(11) NOT NULL DEFAULT 0,
`status` enum('draft','published') DEFAULT 'draft',
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`updated_at` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(),
PRIMARY KEY (`id`),
KEY `book_id` (`book_id`),
CONSTRAINT `chapters_ibfk_1` FOREIGN KEY (`book_id`) REFERENCES `books` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
-- Таблица сессий пользователей
CREATE TABLE `user_sessions` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL,
`session_token` varchar(64) NOT NULL,
`created_at` timestamp NOT NULL DEFAULT current_timestamp(),
`expires_at` timestamp NOT NULL DEFAULT '0000-00-00 00:00:00',
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
CONSTRAINT `user_sessions_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
SET FOREIGN_KEY_CHECKS=1;
SQL;
$step = $_GET['step'] ?? '1';
$error = '';
$success = '';
// Обработка формы
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($step === '1') {
// Шаг 1: Проверка подключения к БД
$db_host = $_POST['db_host'] ?? 'localhost';
$db_name = $_POST['db_name'] ?? 'writer_app';
$db_user = $_POST['db_user'] ?? '';
$db_pass = $_POST['db_pass'] ?? '';
try {
// Пытаемся подключиться к MySQL
$pdo = new PDO("mysql:host=$db_host", $db_user, $db_pass);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Пытаемся создать базу данных если не существует
$pdo->exec("CREATE DATABASE IF NOT EXISTS `$db_name` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
$pdo->exec("USE `$db_name`");
// Сохраняем данные в сессии для следующего шага
session_start();
$_SESSION['install_data'] = [
'db_host' => $db_host,
'db_name' => $db_name,
'db_user' => $db_user,
'db_pass' => $db_pass
];
header('Location: install.php?step=2');
exit;
} catch (PDOException $e) {
$error = "Ошибка подключения к базе данных: " . $e->getMessage();
}
} elseif ($step === '2') {
// Шаг 2: Создание администратора
session_start();
if (!isset($_SESSION['install_data'])) {
header('Location: install.php?step=1');
exit;
}
$admin_username = $_POST['admin_username'] ?? '';
$admin_password = $_POST['admin_password'] ?? '';
$admin_email = $_POST['admin_email'] ?? '';
$admin_display_name = $_POST['admin_display_name'] ?? $admin_username;
if (empty($admin_username) || empty($admin_password)) {
$error = 'Имя пользователя и пароль администратора обязательны';
} else {
try {
$db = $_SESSION['install_data'];
$pdo = new PDO("mysql:host={$db['db_host']};dbname={$db['db_name']}", $db['db_user'], $db['db_pass']);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Создаем таблицы
$pdo->exec($database_sql);
// Создаем администратора
$password_hash = password_hash($admin_password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare("
INSERT INTO users (username, display_name, password_hash, email, is_active, created_at)
VALUES (?, ?, ?, ?, 1, NOW())
");
$stmt->execute([$admin_username, $admin_display_name, $password_hash, $admin_email]);
// Создаем config.php
$config_content = generate_config($db);
if (file_put_contents('config/config.php', $config_content)) {
// Создаем папки для загрузок
if (!file_exists('uploads/covers')) {
mkdir('uploads/covers', 0755, true);
}
if (!file_exists('uploads/avatars')) {
mkdir('uploads/avatars', 0755, true);
}
$success = 'Установка завершена успешно!';
session_destroy();
} else {
$error = 'Не удалось создать файл config.php. Проверьте права доступа к папке config/';
}
} catch (PDOException $e) {
$error = "Ошибка при установке: " . $e->getMessage();
}
}
}
}
function generate_config($db) {
$site_url = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? "https" : "http") . "://$_SERVER[HTTP_HOST]";
$base_path = str_replace('/install.php', '', $_SERVER['PHP_SELF']);
$site_url .= $base_path;
return <<<EOT
<?php
// config/config.php - автоматически сгенерирован установщиком
// Подключаем функции
require_once __DIR__ . '/../includes/functions.php';
session_start();
// Настройки базы данных
define('DB_HOST', '{$db['db_host']}');
define('DB_USER', '{$db['db_user']}');
define('DB_PASS', '{$db['db_pass']}');
define('DB_NAME', '{$db['db_name']}');
define('SITE_URL', '{$site_url}');
// Настройки приложения
define('APP_NAME', 'Web Writer');
define('UPLOAD_PATH', __DIR__ . '/../uploads/');
define('COVERS_PATH', UPLOAD_PATH . 'covers/');
define('COVERS_URL', SITE_URL . '/uploads/covers/');
define('AVATARS_PATH', UPLOAD_PATH . 'avatars/');
define('AVATARS_URL', SITE_URL . '/uploads/avatars/');
// Создаем папку для загрузок, если ее нет
if (!file_exists(COVERS_PATH)) {
mkdir(COVERS_PATH, 0755, true);
}
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
// Подключение к базе данных
try {
\$pdo = new PDO("mysql:host=" . DB_HOST . ";dbname=" . DB_NAME . ";charset=utf8mb4", DB_USER, DB_PASS);
\$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
} catch(PDOException \$e) {
error_log("DB Error: " . \$e->getMessage());
die("Ошибка подключения к базе данных");
}
// Автозагрузка моделей
spl_autoload_register(function (\$class_name) {
\$model_file = __DIR__ . '/../models/' . \$class_name . '.php';
if (file_exists(\$model_file)) {
require_once \$model_file;
}
});
?>
EOT;
}
?>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Установка Web Writer</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@1.5.10/css/pico.min.css">
<style>
.installation-steps {
display: flex;
justify-content: center;
margin-bottom: 2rem;
}
.step {
padding: 10px 20px;
margin: 0 10px;
border-radius: 20px;
background: #f0f0f0;
color: #666;
}
.step.active {
background: #007bff;
color: white;
}
.install-container {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
}
.alert {
padding: 1rem;
margin: 1rem 0;
border-radius: 5px;
}
.alert-error {
background: #ffebee;
color: #c62828;
border: 1px solid #ffcdd2;
}
.alert-success {
background: #e8f5e8;
color: #2e7d32;
border: 1px solid #c8e6c9;
}
.button-group {
display: flex;
gap: 10px;
margin-top: 1rem;
}
.button-group a,
.button-group button {
flex: 1;
text-align: center;
padding: 0.75rem;
text-decoration: none;
border: 1px solid;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
box-sizing: border-box;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.button-group a {
background: var(--secondary);
border-color: var(--secondary);
color: var(--secondary-inverse);
}
.button-group button {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-inverse);
}
.button-group a:hover,
.button-group button:hover {
opacity: 0.9;
}
</style>
</head>
<body>
<main class="container">
<div class="install-container">
<h1 style="text-align: center;">Установка Web Writer</h1>
<!-- Шаги установки -->
<div class="installation-steps">
<div class="step <?= $step === '1' ? 'active' : '' ?>">1. База данных</div>
<div class="step <?= $step === '2' ? 'active' : '' ?>">2. Администратор</div>
<div class="step <?= $step === '3' ? 'active' : '' ?>">3. Завершение</div>
</div>
<?php if ($error): ?>
<div class="alert alert-error">
<?= htmlspecialchars($error) ?>
</div>
<?php endif; ?>
<?php if ($success): ?>
<div class="alert alert-success">
<?= htmlspecialchars($success) ?>
<div style="margin-top: 1rem;">
<a href="index.php" role="button" class="contrast">Перейти к приложению</a>
</div>
</div>
<?php endif; ?>
<?php if (!$success): ?>
<?php if ($step === '1'): ?>
<!-- Шаг 1: Настройки базы данных -->
<form method="post">
<h3>Настройки базы данных</h3>
<label for="db_host">
Хост БД
<input type="text" id="db_host" name="db_host" value="<?= htmlspecialchars($_POST['db_host'] ?? 'localhost') ?>" required>
</label>
<label for="db_name">
Имя базы данных
<input type="text" id="db_name" name="db_name" value="<?= htmlspecialchars($_POST['db_name'] ?? 'writer_app') ?>" required>
</label>
<label for="db_user">
Пользователь БД
<input type="text" id="db_user" name="db_user" value="<?= htmlspecialchars($_POST['db_user'] ?? '') ?>" required>
</label>
<label for="db_pass">
Пароль БД
<input type="password" id="db_pass" name="db_pass" value="<?= htmlspecialchars($_POST['db_pass'] ?? '') ?>">
</label>
<button type="submit" class="contrast" style="width: 100%;">Продолжить</button>
</form>
<?php elseif ($step === '2'): ?>
<!-- Шаг 2: Создание администратора -->
<form method="post">
<h3>Создание администратора</h3>
<p>Создайте учетную запись администратора для управления приложением.</p>
<label for="admin_username">
Имя пользователя *
<input type="text" id="admin_username" name="admin_username" value="<?= htmlspecialchars($_POST['admin_username'] ?? '') ?>" required>
</label>
<label for="admin_password">
Пароль *
<input type="password" id="admin_password" name="admin_password" required>
</label>
<label for="admin_display_name">
Отображаемое имя
<input type="text" id="admin_display_name" name="admin_display_name" value="<?= htmlspecialchars($_POST['admin_display_name'] ?? '') ?>">
</label>
<label for="admin_email">
Email
<input type="email" id="admin_email" name="admin_email" value="<?= htmlspecialchars($_POST['admin_email'] ?? '') ?>">
</label>
<div class="button-group">
<a href="install.php?step=1">Назад</a>
<button type="submit">Завершить установку</button>
</div>
</form>
<?php endif; ?>
<?php endif; ?>
<?php if ($step === '1' && !$success): ?>
<div style="margin-top: 2rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
<h4>Перед установкой убедитесь, что:</h4>
<ul>
<li>Сервер MySQL запущен и доступен</li>
<li>У вас есть данные для подключения к БД (хост, пользователь, пароль)</li>
<li>Папка <code>config/</code> доступна для записи</li>
<li>Папка <code>uploads/</code> доступна для записи</li>
</ul>
</div>
<?php endif; ?>
</div>
</main>
</body>
</html>

View File

@ -29,7 +29,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
$_SESSION['display_name'] = $user['display_name'] ?: $user['username'];
$_SESSION['avatar'] = $user['avatar'] ?? null;
// Обновляем время последнего входа
$userModel->updateLastLogin($user['id']);

View File

@ -141,5 +141,64 @@ class Book {
$stmt = $this->pdo->prepare("UPDATE books SET cover_image = NULL WHERE id = ?");
return $stmt->execute([$book_id]);
}
public function updateSeriesInfo($book_id, $series_id, $sort_order) {
$stmt = $this->pdo->prepare("UPDATE books SET series_id = ?, sort_order_in_series = ? WHERE id = ?");
return $stmt->execute([$series_id, $sort_order, $book_id]);
}
public function removeFromSeries($book_id) {
$stmt = $this->pdo->prepare("UPDATE books SET series_id = NULL, sort_order_in_series = NULL WHERE id = ?");
return $stmt->execute([$book_id]);
}
public function findBySeries($series_id) {
$stmt = $this->pdo->prepare("
SELECT b.*
FROM books b
WHERE b.series_id = ?
ORDER BY b.sort_order_in_series, b.created_at
");
$stmt->execute([$series_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function reorderSeriesBooks($series_id, $new_order) {
try {
$this->pdo->beginTransaction();
foreach ($new_order as $order => $book_id) {
$stmt = $this->pdo->prepare("UPDATE books SET sort_order_in_series = ? WHERE id = ? AND series_id = ?");
$stmt->execute([$order + 1, $book_id, $series_id]);
}
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
return false;
}
}
public function getBookStats($book_id, $only_published_chapters = false) {
$sql = "
SELECT
COUNT(c.id) as chapter_count,
COALESCE(SUM(c.word_count), 0) as total_words
FROM books b
LEFT JOIN chapters c ON b.id = c.book_id
WHERE b.id = ?
";
if ($only_published_chapters) {
$sql .= " AND c.status = 'published'";
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$book_id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}
?>

161
models/Series.php Normal file
View File

@ -0,0 +1,161 @@
<?php
// models/Series.php
class Series {
private $pdo;
public function __construct($pdo) {
$this->pdo = $pdo;
}
public function findById($id) {
$stmt = $this->pdo->prepare("
SELECT s.*,
COUNT(b.id) as book_count,
COALESCE((
SELECT SUM(c.word_count)
FROM chapters c
JOIN books b2 ON c.book_id = b2.id
WHERE b2.series_id = s.id AND b2.published = 1
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id AND b.published = 1
WHERE s.id = ?
GROUP BY s.id
");
$stmt->execute([$id]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function findByUser($user_id, $include_stats = true) {
if ($include_stats) {
$sql = "
SELECT s.*,
COUNT(b.id) as book_count,
COALESCE((
SELECT SUM(c.word_count)
FROM chapters c
JOIN books b2 ON c.book_id = b2.id
WHERE b2.series_id = s.id AND b2.user_id = ?
), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
WHERE s.user_id = ?
GROUP BY s.id
ORDER BY s.created_at DESC
";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id, $user_id]);
} else {
$sql = "SELECT * FROM series WHERE user_id = ? ORDER BY created_at DESC";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$user_id]);
}
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function create($data) {
$stmt = $this->pdo->prepare("
INSERT INTO series (title, description, user_id)
VALUES (?, ?, ?)
");
return $stmt->execute([
$data['title'],
$data['description'] ?? null,
$data['user_id']
]);
}
public function update($id, $data) {
$stmt = $this->pdo->prepare("
UPDATE series
SET title = ?, description = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND user_id = ?
");
return $stmt->execute([
$data['title'],
$data['description'] ?? null,
$id,
$data['user_id']
]);
}
public function delete($id, $user_id) {
try {
$this->pdo->beginTransaction();
// Убираем серию у всех книг
$stmt = $this->pdo->prepare("UPDATE books SET series_id = NULL, sort_order_in_series = NULL WHERE series_id = ? AND user_id = ?");
$stmt->execute([$id, $user_id]);
// Удаляем саму серию
$stmt = $this->pdo->prepare("DELETE FROM series WHERE id = ? AND user_id = ?");
$result = $stmt->execute([$id, $user_id]);
$this->pdo->commit();
return $result;
} catch (Exception $e) {
$this->pdo->rollBack();
return false;
}
}
public function userOwnsSeries($series_id, $user_id) {
$stmt = $this->pdo->prepare("SELECT id FROM series WHERE id = ? AND user_id = ?");
$stmt->execute([$series_id, $user_id]);
return $stmt->fetch() !== false;
}
public function getBooksInSeries($series_id, $only_published = false) {
$sql = "SELECT * FROM books WHERE series_id = ?";
if ($only_published) {
$sql .= " AND published = 1";
}
$sql .= " ORDER BY sort_order_in_series, created_at";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([$series_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
public function getNextSortOrder($series_id) {
$stmt = $this->pdo->prepare("SELECT MAX(sort_order_in_series) as max_order FROM books WHERE series_id = ?");
$stmt->execute([$series_id]);
$result = $stmt->fetch();
return ($result['max_order'] ?? 0) + 1;
}
// Новый метод для получения статистики по серии
public function getSeriesStats($series_id, $user_id = null) {
$sql = "
SELECT
COUNT(b.id) as book_count,
COALESCE(SUM(stats.chapter_count), 0) as chapter_count,
COALESCE(SUM(stats.total_words), 0) as total_words
FROM series s
LEFT JOIN books b ON s.id = b.series_id
LEFT JOIN (
SELECT
book_id,
COUNT(id) as chapter_count,
SUM(word_count) as total_words
FROM chapters
GROUP BY book_id
) stats ON b.id = stats.book_id
WHERE s.id = ?
";
$params = [$series_id];
if ($user_id) {
$sql .= " AND s.user_id = ?";
$params[] = $user_id;
}
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
}
?>

View File

@ -86,5 +86,35 @@ class User {
public function verifyPassword($password, $hash) {
return password_verify($password, $hash);
}
public function updateAvatar($id, $filename) {
$stmt = $this->pdo->prepare("UPDATE users SET avatar = ? WHERE id = ?");
return $stmt->execute([$filename, $id]);
}
public function updateBio($id, $bio) {
$stmt = $this->pdo->prepare("UPDATE users SET bio = ? WHERE id = ?");
return $stmt->execute([$bio, $id]);
}
public function updateProfile($id, $data) {
$sql = "UPDATE users SET display_name = ?, email = ?, bio = ?";
$params = [
$data['display_name'] ?? '',
$data['email'] ?? null,
$data['bio'] ?? null
];
if (!empty($data['avatar'])) {
$sql .= ", avatar = ?";
$params[] = $data['avatar'];
}
$sql .= " WHERE id = ?";
$params[] = $id;
$stmt = $this->pdo->prepare($sql);
return $stmt->execute($params);
}
}
?>

View File

@ -7,6 +7,7 @@ $userModel = new User($pdo);
$user = $userModel->findById($user_id);
$message = '';
$avatar_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
@ -14,9 +15,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else {
$display_name = trim($_POST['display_name'] ?? '');
$email = trim($_POST['email'] ?? '');
$bio = trim($_POST['bio'] ?? '');
$stmt = $pdo->prepare("UPDATE users SET display_name = ?, email = ? WHERE id = ?");
if ($stmt->execute([$display_name, $email, $user_id])) {
// Обработка загрузки аватарки
if (isset($_FILES['avatar']) && $_FILES['avatar']['error'] === UPLOAD_ERR_OK) {
$avatar_result = handleAvatarUpload($_FILES['avatar'], $user_id);
if ($avatar_result['success']) {
$userModel->updateAvatar($user_id, $avatar_result['filename']);
// Обновляем данные пользователя
$user = $userModel->findById($user_id);
} else {
$avatar_error = $avatar_result['error'];
}
}
// Обработка удаления аватарки
if (isset($_POST['delete_avatar']) && $_POST['delete_avatar'] == '1') {
deleteUserAvatar($user_id);
$user = $userModel->findById($user_id);
}
// Обновляем основные данные
$data = [
'display_name' => $display_name,
'email' => $email,
'bio' => $bio
];
if ($userModel->updateProfile($user_id, $data)) {
$_SESSION['display_name'] = $display_name ?: $user['username'];
$message = "Профиль обновлен";
// Обновляем данные пользователя
@ -39,46 +65,127 @@ include 'views/header.php';
</div>
<?php endif; ?>
<article>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div class="grid">
<article>
<h2>Основная информация</h2>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;">
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Имя пользователя (нельзя изменить)
</label>
<input type="text" id="username" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
<div style="margin-bottom: 1rem;">
<label for="username" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Имя пользователя (нельзя изменить)
</label>
<input type="text" id="username" value="<?= e($user['username']) ?>" disabled style="width: 100%;">
</div>
<div style="margin-bottom: 1rem;">
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Отображаемое имя *
</label>
<input type="text" id="display_name" name="display_name"
value="<?= e($user['display_name'] ?? $user['username']) ?>"
style="width: 100%;" required>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Email
</label>
<input type="email" id="email" name="email"
value="<?= e($user['email'] ?? '') ?>"
style="width: 100%;">
</div>
<div style="margin-bottom: 1.5rem;">
<label for="bio" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
О себе (отображается на вашей публичной странице)
</label>
<textarea id="bio" name="bio"
placeholder="Расскажите о себе, своих интересах, стиле письма..."
rows="6"
style="width: 100%;"><?= e($user['bio'] ?? '') ?></textarea>
<small style="color: #666;">
Поддерживается Markdown форматирование
</small>
</div>
<div class="profile-buttons">
<button type="submit" class="profile-button primary">
💾 Сохранить изменения
</button>
<a href="dashboard.php" class="profile-button secondary">
↩️ Назад
</a>
</div>
</form>
</article>
<article>
<h2>Аватарка</h2>
<div style="text-align: center; margin-bottom: 1.5rem;">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
alt="Аватарка"
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid #007bff;"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width: 200px; height: 200px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 4rem; margin: 0 auto;">
<?= mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
</div>
<?php endif; ?>
</div>
<div style="margin-bottom: 1rem;">
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Отображаемое имя
</label>
<input type="text" id="display_name" name="display_name"
value="<?= e($user['display_name'] ?? $user['username']) ?>" style="width: 100%;">
</div>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1.5rem;">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Email
</label>
<input type="email" id="email" name="email" value="<?= e($user['email'] ?? '') ?>" style="width: 100%;">
</div>
<div style="margin-bottom: 1rem;">
<label for="avatar" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Загрузить новую аватарку
</label>
<input type="file" id="avatar" name="avatar"
accept="image/jpeg, image/png, image/gif, image/webp"
style="height: 2.6rem;">
<small style="color: #666;">
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB.
Рекомендуемый размер: 200×200 пикселей.
</small>
<div class="profile-buttons">
<button type="submit" class="profile-button primary">
💾 Сохранить изменения
</button>
<a href="dashboard.php" class="profile-button secondary">
↩️ Назад
</a>
</div>
</form>
</article>
<?php if (!empty($avatar_error)): ?>
<div style="color: #d32f2f; margin-top: 0.5rem;">
<?= e($avatar_error) ?>
</div>
<?php endif; ?>
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="contrast" style="flex: 1;">
📤 Загрузить аватарку
</button>
<?php if (!empty($user['avatar'])): ?>
<button type="submit" name="delete_avatar" value="1" class="secondary" style="flex: 1; background: #ff4444; border-color: #ff4444; color: white;">
🗑️ Удалить аватарку
</button>
<?php endif; ?>
</div>
</form>
<?php if (!empty($user['avatar'])): ?>
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
<p style="margin: 0; font-size: 0.9em; color: #666;">
<strong>Примечание:</strong> Аватарка отображается на вашей публичной странице автора
</p>
</div>
<?php endif; ?>
</article>
</div>
<article>
<h3>Информация об аккаунте</h3>
<p><a href="author.php?id=<?= $_SESSION['user_id'] ?>" target="_blank">Моя публичная страница</a></p>
<p><a href="author.php?id=<?= $_SESSION['user_id'] ?>" target="_blank" class="adaptive-button secondary">
👁️ Посмотреть мою публичную страницу
</a></p>
<p><strong>Дата регистрации:</strong> <?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></p>
<?php if ($user['last_login']): ?>
<p><strong>Последний вход:</strong> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></p>

95
series.php Normal file
View File

@ -0,0 +1,95 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($pdo);
$series = $seriesModel->findByUser($user_id);
// Получаем статистику для каждой серии отдельно
foreach ($series as &$ser) {
$stats = $seriesModel->getSeriesStats($ser['id'], $user_id);
$ser['book_count'] = $stats['book_count'] ?? 0;
$ser['total_words'] = $stats['total_words'] ?? 0;
}
unset($ser); // разрываем ссылку
$page_title = "Мои серии книг";
include 'views/header.php';
?>
<h1>Мои серии книг</h1>
<?php if (isset($_SESSION['success'])): ?>
<div class="alert alert-success">
<?= e($_SESSION['success']) ?>
<?php unset($_SESSION['success']); ?>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h2 style="margin: 0;">Всего серий: <?= count($series) ?></h2>
<a href="series_edit.php" class="action-button primary"> Новая серия</a>
</div>
<?php if (empty($series)): ?>
<article style="text-align: center; padding: 2rem;">
<h3>У вас пока нет серий книг</h3>
<p>Создайте свою первую серию для организации книг!</p>
<a href="series_edit.php" role="button">📚 Создать первую серию</a>
</article>
<?php else: ?>
<div class="grid">
<?php foreach ($series as $ser): ?>
<article>
<header>
<h3>
<?= e($ser['title']) ?>
<div style="display: flex; gap: 3px; float:right;">
<a href="series_edit.php?id=<?= $ser['id'] ?>" class="compact-button secondary" title="Редактировать серию">
✏️
</a>
<a href="view_series.php?id=<?= $ser['id'] ?>" class="compact-button secondary" title="Просмотреть серию">
👁️
</a>
<form method="post" action="series_delete.php" style="display: inline;" onsubmit="return confirm('Вы уверены, что хотите удалить серию «<?= e($ser['title']) ?>»? Книги останутся, но будут убраны из серии.');">
<input type="hidden" name="series_id" value="<?= $ser['id'] ?>">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<button type="submit" class="compact-button secondary" style="background: #ff4444; border-color: #ff4444; color: white;" title="Удалить серию">
🗑️
</button>
</form>
</div>
</h3>
</header>
<?php if ($ser['description']): ?>
<p><?= e(mb_strimwidth($ser['description'], 0, 200, '...')) ?></p>
<?php endif; ?>
<footer>
<div>
<small>
Книг: <?= $ser['book_count'] ?> |
Слов: <?= $ser['total_words'] ?>
</small>
</div>
<div style="margin-top: 0.5rem;">
<a href="view_series.php?id=<?= $ser['id'] ?>" class="adaptive-button secondary">
📖 Смотреть книги
</a>
</div>
</footer>
</article>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php include 'views/footer.php'; ?>

39
series_delete.php Normal file
View File

@ -0,0 +1,39 @@
<?php
require_once 'config/config.php';
require_login();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
$_SESSION['error'] = "Неверный метод запроса";
redirect('series.php');
}
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect('series.php');
}
$series_id = $_POST['series_id'] ?? null;
$user_id = $_SESSION['user_id'];
if (!$series_id) {
$_SESSION['error'] = "Не указана серия для удаления";
redirect('series.php');
}
$seriesModel = new Series($pdo);
if (!$seriesModel->userOwnsSeries($series_id, $user_id)) {
$_SESSION['error'] = "У вас нет доступа к этой серии";
redirect('series.php');
}
$series = $seriesModel->findById($series_id);
if ($seriesModel->delete($series_id, $user_id)) {
$_SESSION['success'] = "Серия «" . e($series['title']) . "» успешно удалена";
} else {
$_SESSION['error'] = "Ошибка при удалении серии";
}
redirect('series.php');
?>

179
series_edit.php Normal file
View File

@ -0,0 +1,179 @@
<?php
require_once 'config/config.php';
require_login();
$user_id = $_SESSION['user_id'];
$seriesModel = new Series($pdo);
$series_id = $_GET['id'] ?? null;
$series = null;
$is_edit = false;
if ($series_id) {
$series = $seriesModel->findById($series_id);
if (!$series || !$seriesModel->userOwnsSeries($series_id, $user_id)) {
$_SESSION['error'] = "Серия не найдена или у вас нет доступа";
redirect('series.php');
}
$is_edit = true;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
$_SESSION['error'] = "Ошибка безопасности";
redirect($is_edit ? "series_edit.php?id=$series_id" : 'series_edit.php');
}
$title = trim($_POST['title'] ?? '');
$description = trim($_POST['description'] ?? '');
if (empty($title)) {
$_SESSION['error'] = "Название серии обязательно";
} else {
$data = [
'title' => $title,
'description' => $description,
'user_id' => $user_id
];
if ($is_edit) {
$success = $seriesModel->update($series_id, $data);
$message = $success ? "Серия успешно обновлена" : "Ошибка при обновлении серии";
} else {
$success = $seriesModel->create($data);
$message = $success ? "Серия успешно создана" : "Ошибка при создании серии";
if ($success) {
$new_series_id = $pdo->lastInsertId();
redirect("series_edit.php?id=$new_series_id");
}
}
if ($success) {
$_SESSION['success'] = $message;
redirect('series.php');
} else {
$_SESSION['error'] = $message;
}
}
}
$page_title = $is_edit ? "Редактирование серии" : "Создание новой серии";
include 'views/header.php';
?>
<h1><?= $is_edit ? "Редактирование серии" : "Создание новой серии" ?></h1>
<?php if (isset($_SESSION['error'])): ?>
<div class="alert alert-error">
<?= e($_SESSION['error']) ?>
<?php unset($_SESSION['error']); ?>
</div>
<?php endif; ?>
<form method="post">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="max-width: 100%; margin-bottom: 1rem;">
<label for="title" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Название серии *
</label>
<input type="text" id="title" name="title"
value="<?= e($series['title'] ?? $_POST['title'] ?? '') ?>"
placeholder="Введите название серии"
style="width: 100%; margin-bottom: 1.5rem;"
required>
<label for="description" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Описание серии
</label>
<textarea id="description" name="description"
placeholder="Описание сюжета серии, общая концепция..."
rows="6"
style="width: 100;"><?= e($series['description'] ?? $_POST['description'] ?? '') ?></textarea>
</div>
<div style="display: flex; gap: 10px; flex-wrap: wrap;">
<button type="submit" class="contrast">
<?= $is_edit ? '💾 Сохранить изменения' : '📚 Создать серию' ?>
</button>
<a href="series.php" role="button" class="secondary">
Отмена
</a>
</div>
</form>
<?php if ($is_edit): ?>
<div style="margin-top: 3rem;">
<h3>Книги в этой серии</h3>
<?php
$bookModel = new Book($pdo);
$books_in_series = $bookModel->findBySeries($series_id);
// Вычисляем общую статистику
$total_chapters = 0;
$total_words = 0;
foreach ($books_in_series as $book) {
$stats = $bookModel->getBookStats($book['id']);
$total_chapters += $stats['chapter_count'] ?? 0;
$total_words += $stats['total_words'] ?? 0;
}
?>
<?php if (empty($books_in_series)): ?>
<div style="text-align: center; padding: 2rem; background: #f9f9f9; border-radius: 5px;">
<p>В этой серии пока нет книг.</p>
<a href="books.php" class="adaptive-button">📚 Добавить книги</a>
</div>
<?php else: ?>
<div style="overflow-x: auto;">
<table class="compact-table">
<thead>
<tr>
<th style="width: 10%;">Порядок</th>
<th style="width: 40%;">Название книги</th>
<th style="width: 20%;">Жанр</th>
<th style="width: 15%;">Статус</th>
<th style="width: 15%;">Действия</th>
</tr>
</thead>
<tbody>
<?php foreach ($books_in_series as $book): ?>
<tr>
<td><?= $book['sort_order_in_series'] ?></td>
<td>
<strong><?= e($book['title']) ?></strong>
<?php if ($book['description']): ?>
<br><small style="color: #666;"><?= e(mb_strimwidth($book['description'], 0, 100, '...')) ?></small>
<?php endif; ?>
</td>
<td><?= e($book['genre']) ?></td>
<td>
<span style="color: <?= $book['published'] ? 'green' : 'orange' ?>">
<?= $book['published'] ? '✅ Опубликована' : '📝 Черновик' ?>
</span>
</td>
<td>
<a href="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary">
Редактировать
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div style="margin-top: 1rem; padding: 0.5rem; background: #f5f5f5; border-radius: 3px;">
<strong>Статистика серии:</strong>
Книг: <?= count($books_in_series) ?> |
Глав: <?= $total_chapters ?> |
Слов: <?= $total_words ?>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php include 'views/footer.php'; ?>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

0
vendor/autoload.php vendored Normal file → Executable file
View File

0
vendor/composer/ClassLoader.php vendored Normal file → Executable file
View File

0
vendor/composer/InstalledVersions.php vendored Normal file → Executable file
View File

0
vendor/composer/LICENSE vendored Normal file → Executable file
View File

0
vendor/composer/autoload_classmap.php vendored Normal file → Executable file
View File

0
vendor/composer/autoload_namespaces.php vendored Normal file → Executable file
View File

0
vendor/composer/autoload_psr4.php vendored Normal file → Executable file
View File

0
vendor/composer/autoload_real.php vendored Normal file → Executable file
View File

0
vendor/composer/autoload_static.php vendored Normal file → Executable file
View File

0
vendor/composer/index.php vendored Normal file → Executable file
View File

0
vendor/composer/installed.json vendored Normal file → Executable file
View File

0
vendor/composer/installed.php vendored Normal file → Executable file
View File

0
vendor/composer/platform_check.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/.github/dependabot.yml vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/.github/workflows/deploy.yml vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/.github/workflows/php.yml vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/.gitignore vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/.php-cs-fixer.dist.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/LICENSE vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/README.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/composer.json vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/assets/mathjax.js vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/changes/0.1.0.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/changes/0.2.0.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/changes/0.3.0.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/credits.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/index.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/install.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/elements/fraction.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/elements/identifier.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/elements/numeric.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/elements/operator.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/elements/row.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/elements/semantics.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/elements/superscript.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/readers.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/docs/usage/writers.md vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/mkdocs.yml vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/phpstan.neon.dist vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/phpunit.xml.dist vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/roave-bc-check.yaml vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/AbstractElement.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/AbstractGroupElement.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/Fraction.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/Identifier.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/Numeric.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/Operator.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/Row.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/Semantics.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Element/Superscript.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Exception/InvalidInputException.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Exception/MathException.php vendored Normal file → Executable file
View File

View File

0
vendor/phpoffice/math/src/Math/Exception/SecurityException.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Math.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Reader/MathML.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Reader/OfficeMathML.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Reader/ReaderInterface.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Reader/Security/XmlScanner.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Writer/MathML.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Writer/OfficeMathML.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/src/Math/Writer/WriterInterface.php vendored Normal file → Executable file
View File

View File

0
vendor/phpoffice/math/tests/Math/Element/FractionTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Element/IdentifierTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Element/NumericTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Element/OperatorTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Element/SemanticsTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Element/SuperscriptTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Reader/MathMLTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Reader/OfficeMathMLTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Writer/MathMLTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Writer/OfficeMathMLTest.php vendored Normal file → Executable file
View File

0
vendor/phpoffice/math/tests/Math/Writer/WriterTestCase.php vendored Normal file → Executable file
View File

View File

View File

View File

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