version 1.0

This commit is contained in:
2025-11-21 17:10:05 +08:00
parent b904e92da9
commit 2f9fba663a
655 changed files with 2417 additions and 374 deletions
+534 -267
View File
@@ -1,4 +1,6 @@
/* style.css */ /* style.css - оптимизированная версия */
/* Базовые стили */
h1, h2, h3, h4, h5, h6 { h1, h2, h3, h4, h5, h6 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -22,6 +24,18 @@ input:not([type="checkbox"], [type="radio"]), select {
height: 2em; height: 2em;
} }
/* Убираем конфликтующие стили Pico CSS */
article header, article footer {
margin: 0;
padding: 0;
}
article > header, article > footer {
margin: 0;
padding: 0;
}
/* Уведомления */
.alert { .alert {
padding: 1rem; padding: 1rem;
margin: 1rem 0; margin: 1rem 0;
@@ -40,6 +54,7 @@ input:not([type="checkbox"], [type="radio"]), select {
border: 1px solid #c8e6c9; border: 1px solid #c8e6c9;
} }
/* Кнопки */
.compact-button { .compact-button {
padding: 3px 8px !important; padding: 3px 8px !important;
font-size: 0.85rem; font-size: 0.85rem;
@@ -62,23 +77,6 @@ input:not([type="checkbox"], [type="radio"]), select {
opacity: 0.9; 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 { .button-group {
display: flex; display: flex;
gap: 5px; gap: 5px;
@@ -181,23 +179,66 @@ input:not([type="checkbox"], [type="radio"]), select {
color: var(--primary-inverse); color: var(--primary-inverse);
} }
@media (max-width: 768px) { .action-button {
.adaptive-button { display: inline-flex;
flex: 1 1 calc(50% - 10px); align-items: center;
min-width: calc(50% - 10px); justify-content: center;
font-size: 0.8rem; padding: 0.75rem 1.5rem;
padding: 10px 8px !important; 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) { .action-button.primary {
.adaptive-button { background: #007bff;
flex: 1 1 100%; border-color: #007bff;
min-width: 100%; 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 { #content {
transition: all 0.3s ease; transition: all 0.3s ease;
border: 1px solid #ddd; border: 1px solid #ddd;
@@ -220,9 +261,31 @@ input:not([type="checkbox"], [type="radio"]), select {
background-color: #fff; 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 { .editor-controls {
position: sticky !important; position: sticky !important;
top: 10px !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; box-shadow: 0 4px 8px rgba(0,0,0,0.3) !important;
} }
/* Стили для полноэкранных кнопок */ /* Полноэкранный режим редактора */
#fullscreen-controls { #fullscreen-controls {
position: fixed !important; position: fixed !important;
top: 15px !important;
right: 15px !important;
z-index: 9999 !important; z-index: 9999 !important;
display: flex !important; display: flex !important;
gap: 5px !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; 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 { .book-content {
line-height: 1.7; line-height: 1.7;
font-family: Georgia, serif; font-family: Georgia, serif;
@@ -515,109 +448,13 @@ input:not([type="checkbox"], [type="radio"]), select {
background: #f9f9f9; background: #f9f9f9;
} }
/* Стили для диалогов */
.dialogue { .dialogue {
margin-left: 2rem; margin-left: 2rem;
font-style: italic; font-style: italic;
color: #2c5aa0; 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 { .book-cover {
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
@@ -639,17 +476,276 @@ input:not([type="checkbox"], [type="radio"]), select {
margin: 0 auto 1rem; margin: 0 auto 1rem;
} }
/* Стили для страницы автора */ /* Аватарки и профиль */
.author-books .book-cover { .avatar-container {
transition: transform 0.3s ease; text-align: center;
margin-bottom: 1.5rem;
} }
.author-books .book-cover:hover { .avatar {
transform: scale(1.05); 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) { @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 { .author-books article {
flex-direction: column; flex-direction: column;
} }
@@ -657,4 +753,175 @@ input:not([type="checkbox"], [type="radio"]), select {
.author-books .book-cover { .author-books .book-cover {
align-self: center; 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;
} }
Regular → Executable
+144 -19
View File
@@ -1,6 +1,8 @@
<?php <?php
require_once 'config/config.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); $author_id = (int)($_GET['id'] ?? 0);
if (!$author_id) { if (!$author_id) {
@@ -10,7 +12,7 @@ if (!$author_id) {
exit; 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]); $stmt->execute([$author_id]);
$author = $stmt->fetch(PDO::FETCH_ASSOC); $author = $stmt->fetch(PDO::FETCH_ASSOC);
@@ -24,22 +26,79 @@ if (!$author) {
$bookModel = new Book($pdo); $bookModel = new Book($pdo);
$books = $bookModel->findByUser($author_id, true); // только опубликованные $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']) . ' — публичная страница'; $page_title = ($author['display_name'] ?: $author['username']) . ' — публичная страница';
include 'views/header.php'; 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)): ?> <h1 style="margin-bottom: 0.5rem;"><?= e($author['display_name'] ?: $author['username']) ?></h1>
<p>У этого автора пока нет опубликованных книг.</p>
<?php else: ?> <!-- Биография автора -->
<div class="grid"> <?php if (!empty($author['bio'])): ?>
<?php foreach ($books as $b): ?> <div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin: 1rem 0; text-align: left;">
<article style="display: flex; gap: 1rem; align-items: flex-start;"> <?= $Parsedown->text($author['bio']) ?>
<?php if ($b['cover_image']): ?> </div>
<?php endif; ?>
<!-- Статистика автора -->
<div style="display: flex; justify-content: center; gap: 2rem; flex-wrap: wrap; font-size: 0.9em; color: #666;">
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #007bff;"><?= $total_books ?></div>
<div>Книг</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #28a745;"><?= $total_chapters ?></div>
<div>Глав</div>
</div>
<div style="text-align: center;">
<div style="font-size: 1.5em; font-weight: bold; color: #6f42c1;"><?= $total_words ?></div>
<div>Слов</div>
</div>
</div>
</header>
<h2 style="text-align: center; margin-bottom: 2rem;">Публикации автора</h2>
<?php if (empty($books)): ?>
<div style="text-align: center; padding: 3rem; background: #f9f9f9; border-radius: 5px;">
<h3>У этого автора пока нет опубликованных книг</h3>
<p>Следите за обновлениями, скоро здесь появятся новые произведения!</p>
</div>
<?php else: ?>
<div class="author-books">
<?php foreach ($books as $book): ?>
<article style="display: flex; gap: 1rem; align-items: flex-start; margin-bottom: 2rem; padding: 1.5rem; background: #f8f9fa; border-radius: 8px;">
<?php if ($book['cover_image']): ?>
<div style="flex-shrink: 0;"> <div style="flex-shrink: 0;">
<img src="<?= COVERS_URL . e($b['cover_image']) ?>" <img src="<?= COVERS_URL . e($book['cover_image']) ?>"
alt="<?= e($b['title']) ?>" alt="<?= e($book['title']) ?>"
style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;" style="max-width: 120px; height: auto; border-radius: 4px; border: 1px solid #ddd;"
onerror="this.style.display='none'"> onerror="this.style.display='none'">
</div> </div>
@@ -50,18 +109,84 @@ include 'views/header.php';
<?php endif; ?> <?php endif; ?>
<div style="flex: 1;"> <div style="flex: 1;">
<h3 style="margin-top: 0;"><?= e($b['title']) ?></h3> <h3 style="margin-top: 0;"><?= e($book['title']) ?></h3>
<?php if ($b['genre']): ?>
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($b['genre']) ?></em></p> <?php if ($book['genre']): ?>
<p style="color: #666; margin: 0.5rem 0;"><em><?= e($book['genre']) ?></em></p>
<?php endif; ?> <?php endif; ?>
<?php if ($b['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($b['description'])) ?></p> <?php if ($book['description']): ?>
<p style="margin-bottom: 1rem;"><?= nl2br(e($book['description'])) ?></p>
<?php endif; ?> <?php endif; ?>
<a href="view_book.php?share_token=<?= e($b['share_token']) ?>" class="adaptive-button">Читать</a>
<?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> </div>
</article> </article>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
<?php endif; ?> <?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'; ?> <?php include 'views/footer.php'; ?>
Regular → Executable
View File
Regular → Executable
View File
+37 -1
View File
@@ -36,11 +36,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($title)) { if (empty($title)) {
$_SESSION['error'] = "Название книги обязательно"; $_SESSION['error'] = "Название книги обязательно";
} else { } 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 = [ $data = [
'title' => $title, 'title' => $title,
'description' => $description, 'description' => $description,
'genre' => $genre, '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; $data['published'] = isset($_POST['published']) ? 1 : 0;
@@ -110,7 +121,32 @@ include 'views/header.php';
value="<?= e($book['genre'] ?? $_POST['genre'] ?? '') ?>" value="<?= e($book['genre'] ?? $_POST['genre'] ?? '') ?>"
placeholder="Например: Фантастика, Роман, Детектив..." placeholder="Например: Фантастика, Роман, Детектив..."
style="width: 100%; margin-bottom: 1.5rem;"> 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;"> <div style="margin-bottom: 1.5rem;">
<label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="cover_image" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
+17
View File
@@ -57,6 +57,23 @@ include 'views/header.php';
<?php endif; ?> <?php endif; ?>
<header> <header>
<h3><?= e($book['title']) ?> <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;"> <div style="display: flex; gap: 3px; float:right;">
<a href="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary" title="Редактировать книгу"> <a href="book_edit.php?id=<?= $book['id'] ?>" class="compact-button secondary" title="Редактировать книгу">
✏️ ✏️
Regular → Executable
View File
Generated Regular → Executable
View File
Executable → Regular
+10 -6
View File
@@ -1,5 +1,5 @@
<?php <?php
// config/config.php // config/config.php - автоматически сгенерирован установщиком
// Подключаем функции // Подключаем функции
require_once __DIR__ . '/../includes/functions.php'; require_once __DIR__ . '/../includes/functions.php';
session_start(); session_start();
@@ -10,17 +10,22 @@ define('DB_USER', 'writer_mirv');
define('DB_PASS', 'writer_moloko22'); define('DB_PASS', 'writer_moloko22');
define('DB_NAME', 'writer_app'); define('DB_NAME', 'writer_app');
define('SITE_URL', 'https://writer.mirv.top'); define('SITE_URL', 'https://writer.mirv.top');
//define('BASE_URL', 'http://' . $_SERVER['HTTP_HOST'] . '/'); // Измените на ваш домен
// Настройки приложения // Настройки приложения
define('APP_NAME', 'Web Writer'); define('APP_NAME', 'Web Writer');
define('UPLOAD_PATH', __DIR__ . '/../uploads/'); 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_URL', SITE_URL . '/uploads/avatars/');
// Создаем папку для загрузок, если ее нет // Создаем папку для загрузок, если ее нет
// if (!file_exists(COVERS_PATH)) { if (!file_exists(COVERS_PATH)) {
// mkdir(COVERS_PATH, 0765, true); mkdir(COVERS_PATH, 0755, true);
// } }
if (!file_exists(AVATARS_PATH)) {
mkdir(AVATARS_PATH, 0755, true);
}
// Подключение к базе данных // Подключение к базе данных
try { try {
@@ -31,7 +36,6 @@ try {
die("Ошибка подключения к базе данных"); die("Ошибка подключения к базе данных");
} }
// Автозагрузка моделей // Автозагрузка моделей
spl_autoload_register(function ($class_name) { spl_autoload_register(function ($class_name) {
$model_file = __DIR__ . '/../models/' . $class_name . '.php'; $model_file = __DIR__ . '/../models/' . $class_name . '.php';
+147 -18
View File
@@ -4,7 +4,33 @@ require_login();
$user_id = $_SESSION['user_id']; $user_id = $_SESSION['user_id'];
$bookModel = new Book($pdo); $bookModel = new Book($pdo);
$seriesModel = new Series($pdo);
$books = $bookModel->findByUser($user_id); $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 = "Панель управления"; $page_title = "Панель управления";
include 'views/header.php'; include 'views/header.php';
@@ -12,49 +38,152 @@ include 'views/header.php';
<h1>Добро пожаловать, <?= e($_SESSION['display_name']) ?>!</h1> <h1>Добро пожаловать, <?= e($_SESSION['display_name']) ?>!</h1>
<div style="margin-bottom: 1rem;">
<a href="profile.php" class="adaptive-button secondary">✏️ Редактировать профиль</a>
</div>
<div class="grid"> <div class="grid">
<article> <article>
<h2>📚 Мои книги</h2> <h2>📚 Мои книги</h2>
<p>Управляйте вашими книгами и главами</p> <p>Управляйте вашими книгами и главами</p>
<a href="books.php" role="button"> <div class="dashboard-buttons">
<a href="books.php" role="button" class="dashboard-button">
Мои книги (<?= count($books) ?>) Мои книги (<?= count($books) ?>)
</a> </a>
&nbsp;&nbsp; <a href="book_edit.php" role="button" class="dashboard-button new">
<a href="book_edit.php" role="button"> Новая книга</a> Новая книга
</a>
</div>
</article> </article>
<article> <article>
<h2>📊 Статистика</h2> <h2>📊 Статистика</h2>
<?php <div class="stats-list">
$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> <?= count($books) ?></p>
<p><strong>Глав:</strong> <?= $total_chapters ?></p> <p><strong>Глав:</strong> <?= $total_chapters ?></p>
<p><strong>Всего слов:</strong> <?= $total_words ?></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> </article>
</div> </div>
<?php if (!empty($books)): ?> <?php if (!empty($books)): ?>
<div style="margin-top: 2rem;"> <div class="dashboard-section">
<div class="grid">
<article>
<h2>Недавние книги</h2> <h2>Недавние книги</h2>
<div class="grid">
<?php foreach (array_slice($books, 0, 3) as $book): ?> <?php foreach (array_slice($books, 0, 3) as $book): ?>
<article> <article class="dashboard-item">
<h4><?= e($book['title']) ?></h4> <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> <p>Глав: <?= $book['chapter_count'] ?> | Слов: <?= $book['total_words'] ?></p>
<a href="book_edit.php?id=<?= $book['id'] ?>" role="button" class="secondary"> <div class="action-buttons">
<a href="book_edit.php?id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
Редактировать Редактировать
</a> </a>
<a href="chapters.php?book_id=<?= $book['id'] ?>" role="button" class="compact-button secondary">
Главы
</a>
<a href="view_book.php?share_token=<?= $book['share_token'] ?>" role="button" class="compact-button secondary" target="_blank">
Просмотр
</a>
</div>
</article> </article>
<?php endforeach; ?> <?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> </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>
</div> </div>
<?php endif; ?> <?php endif; ?>
Regular → Executable
View File
+166
View File
@@ -189,4 +189,170 @@ function optimizeImage($file_path) {
imagedestroy($new_image); 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
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>
+1 -1
View File
@@ -29,7 +29,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$_SESSION['user_id'] = $user['id']; $_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username']; $_SESSION['username'] = $user['username'];
$_SESSION['display_name'] = $user['display_name'] ?: $user['username']; $_SESSION['display_name'] = $user['display_name'] ?: $user['username'];
$_SESSION['avatar'] = $user['avatar'] ?? null;
// Обновляем время последнего входа // Обновляем время последнего входа
$userModel->updateLastLogin($user['id']); $userModel->updateLastLogin($user['id']);
+59
View File
@@ -141,5 +141,64 @@ class Book {
$stmt = $this->pdo->prepare("UPDATE books SET cover_image = NULL WHERE id = ?"); $stmt = $this->pdo->prepare("UPDATE books SET cover_image = NULL WHERE id = ?");
return $stmt->execute([$book_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
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);
}
}
?>
+30
View File
@@ -86,5 +86,35 @@ class User {
public function verifyPassword($password, $hash) { public function verifyPassword($password, $hash) {
return password_verify($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);
}
} }
?> ?>
+116 -9
View File
@@ -7,6 +7,7 @@ $userModel = new User($pdo);
$user = $userModel->findById($user_id); $user = $userModel->findById($user_id);
$message = ''; $message = '';
$avatar_error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!verify_csrf_token($_POST['csrf_token'] ?? '')) { if (!verify_csrf_token($_POST['csrf_token'] ?? '')) {
@@ -14,9 +15,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
} else { } else {
$display_name = trim($_POST['display_name'] ?? ''); $display_name = trim($_POST['display_name'] ?? '');
$email = trim($_POST['email'] ?? ''); $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']; $_SESSION['display_name'] = $display_name ?: $user['username'];
$message = "Профиль обновлен"; $message = "Профиль обновлен";
// Обновляем данные пользователя // Обновляем данные пользователя
@@ -39,8 +65,10 @@ include 'views/header.php';
</div> </div>
<?php endif; ?> <?php endif; ?>
<article> <div class="grid">
<form method="post"> <article>
<h2>Основная информация</h2>
<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 style="margin-bottom: 1rem;">
@@ -52,17 +80,33 @@ include 'views/header.php';
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="display_name" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Отображаемое имя Отображаемое имя *
</label> </label>
<input type="text" id="display_name" name="display_name" <input type="text" id="display_name" name="display_name"
value="<?= e($user['display_name'] ?? $user['username']) ?>" style="width: 100%;"> value="<?= e($user['display_name'] ?? $user['username']) ?>"
style="width: 100%;" required>
</div> </div>
<div style="margin-bottom: 1.5rem;"> <div style="margin-bottom: 1.5rem;">
<label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;"> <label for="email" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Email Email
</label> </label>
<input type="email" id="email" name="email" value="<?= e($user['email'] ?? '') ?>" style="width: 100%;"> <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>
<div class="profile-buttons"> <div class="profile-buttons">
@@ -74,11 +118,74 @@ include 'views/header.php';
</a> </a>
</div> </div>
</form> </form>
</article> </article>
<article>
<h2>Аватарка</h2>
<div style="text-align: center; margin-bottom: 1.5rem;">
<?php if (!empty($user['avatar'])): ?>
<img src="<?= AVATARS_URL . e($user['avatar']) ?>"
alt="Аватарка"
style="max-width: 200px; height: auto; border-radius: 50%; border: 3px solid #007bff;"
onerror="this.style.display='none'">
<?php else: ?>
<div style="width: 200px; height: 200px; border-radius: 50%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); display: flex; align-items: center; justify-content: center; color: white; font-size: 4rem; margin: 0 auto;">
<?= mb_substr(e($user['display_name'] ?? $user['username']), 0, 1) ?>
</div>
<?php endif; ?>
</div>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="<?= generate_csrf_token() ?>">
<div style="margin-bottom: 1rem;">
<label for="avatar" style="display: block; margin-bottom: 0.5rem; font-weight: bold;">
Загрузить новую аватарку
</label>
<input type="file" id="avatar" name="avatar"
accept="image/jpeg, image/png, image/gif, image/webp"
style="height: 2.6rem;">
<small style="color: #666;">
Разрешены: JPG, PNG, GIF, WebP. Максимальный размер: 2MB.
Рекомендуемый размер: 200×200 пикселей.
</small>
<?php if (!empty($avatar_error)): ?>
<div style="color: #d32f2f; margin-top: 0.5rem;">
❌ <?= e($avatar_error) ?>
</div>
<?php endif; ?>
</div>
<div style="display: flex; gap: 10px;">
<button type="submit" class="contrast" style="flex: 1;">
📤 Загрузить аватарку
</button>
<?php if (!empty($user['avatar'])): ?>
<button type="submit" name="delete_avatar" value="1" class="secondary" style="flex: 1; background: #ff4444; border-color: #ff4444; color: white;">
🗑️ Удалить аватарку
</button>
<?php endif; ?>
</div>
</form>
<?php if (!empty($user['avatar'])): ?>
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 5px;">
<p style="margin: 0; font-size: 0.9em; color: #666;">
<strong>Примечание:</strong> Аватарка отображается на вашей публичной странице автора
</p>
</div>
<?php endif; ?>
</article>
</div>
<article> <article>
<h3>Информация об аккаунте</h3> <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> <p><strong>Дата регистрации:</strong> <?= date('d.m.Y H:i', strtotime($user['created_at'])) ?></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> <?= date('d.m.Y H:i', strtotime($user['last_login'])) ?></p>
+95
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
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
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

Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
View File
View File
Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
View File
View File
View File
View File
View File
View File
Vendored Regular → Executable
View File
View File
View File
View File
View File
View File
View File
Vendored Regular → Executable
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View File
View 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