mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 12:31:09 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 961bf95af7 | |||
| 64a46645c5 | |||
| e7f2987eea | |||
| 85b531e6e1 | |||
| 345ff8f23f | |||
| e64d3da7f4 | |||
| 719631158d |
@@ -1,3 +1,7 @@
|
|||||||
|
# DEFAULT_ADMIN_USERNAME = "admin"
|
||||||
|
# DEFAULT_ADMIN_EMAIL = "admin@example.com"
|
||||||
|
# DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch"
|
||||||
|
|
||||||
POSTGRES_HOST = "localhost"
|
POSTGRES_HOST = "localhost"
|
||||||
POSTGRES_PORT = "5432"
|
POSTGRES_PORT = "5432"
|
||||||
POSTGRES_USER = "postgres"
|
POSTGRES_USER = "postgres"
|
||||||
|
|||||||
@@ -142,3 +142,5 @@ erDiagram
|
|||||||
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных.
|
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных.
|
||||||
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах.
|
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах.
|
||||||
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker.
|
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker.
|
||||||
|
- **Tailwind**: CSS-фреймворк, позволяющий стилизовать веб-интерфейсы, применяя готовые низкоуровневые классы.
|
||||||
|
- **Cash**: Микро JavaScript-библиотека, созданная как очень быстрая и компактная альтернатива jQuery.
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Конфигурация
|
||||||
|
USERNAME = "sys-admin"
|
||||||
|
PASSWORD = "wTKPVqTIMqzXL2EZxYz80w"
|
||||||
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryAPI:
|
||||||
|
def __init__(self, base_url: str):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.token: Optional[str] = None
|
||||||
|
self.session = requests.Session()
|
||||||
|
|
||||||
|
def login(self, username: str, password: str) -> bool:
|
||||||
|
"""Авторизация и получение токена"""
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.base_url}/api/auth/token",
|
||||||
|
data={"username": username, "password": password},
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
self.token = response.json()["access_token"]
|
||||||
|
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
|
||||||
|
print(f"✓ Авторизация успешна для пользователя: {username}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"✗ Ошибка авторизации: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def register(self, username: str, email: str, password: str, full_name: str = None) -> bool:
|
||||||
|
"""Регистрация нового пользователя"""
|
||||||
|
data = {
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"password": password
|
||||||
|
}
|
||||||
|
if full_name:
|
||||||
|
data["full_name"] = full_name
|
||||||
|
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.base_url}/api/auth/register",
|
||||||
|
json=data
|
||||||
|
)
|
||||||
|
if response.status_code == 201:
|
||||||
|
print(f"✓ Пользователь {username} зарегистрирован")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"✗ Ошибка регистрации: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def create_author(self, name: str) -> Optional[int]:
|
||||||
|
"""Создание автора"""
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.base_url}/api/authors/",
|
||||||
|
json={"name": name}
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
author_id = response.json()["id"]
|
||||||
|
print(f" ✓ Автор создан: {name} (ID: {author_id})")
|
||||||
|
return author_id
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка создания автора {name}: {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_book(self, title: str, description: str) -> Optional[int]:
|
||||||
|
"""Создание книги"""
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.base_url}/api/books/",
|
||||||
|
json={"title": title, "description": description}
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
book_id = response.json()["id"]
|
||||||
|
print(f" ✓ Книга создана: {title} (ID: {book_id})")
|
||||||
|
return book_id
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка создания книги {title}: {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_genre(self, name: str) -> Optional[int]:
|
||||||
|
"""Создание жанра"""
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.base_url}/api/genres/",
|
||||||
|
json={"name": name}
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
genre_id = response.json()["id"]
|
||||||
|
print(f" ✓ Жанр создан: {name} (ID: {genre_id})")
|
||||||
|
return genre_id
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка создания жанра {name}: {response.text}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def link_author_book(self, author_id: int, book_id: int) -> bool:
|
||||||
|
"""Связь автора и книги"""
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.base_url}/api/relationships/author-book",
|
||||||
|
params={"author_id": author_id, "book_id": book_id}
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f" ↔ Связь автор-книга: {author_id} ↔ {book_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка связи автор-книга: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def link_genre_book(self, genre_id: int, book_id: int) -> bool:
|
||||||
|
"""Связь жанра и книги"""
|
||||||
|
response = self.session.post(
|
||||||
|
f"{self.base_url}/api/relationships/genre-book",
|
||||||
|
params={"genre_id": genre_id, "book_id": book_id}
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f" ↔ Связь жанр-книга: {genre_id} ↔ {book_id}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f" ✗ Ошибка связи жанр-книга: {response.text}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
api = LibraryAPI(BASE_URL)
|
||||||
|
|
||||||
|
# Авторизация
|
||||||
|
if not api.login(USERNAME, PASSWORD):
|
||||||
|
print("Не удалось авторизоваться. Проверьте логин и пароль.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# === АВТОРЫ (12 авторов) ===
|
||||||
|
print("\n📚 Создание авторов...")
|
||||||
|
authors_data = [
|
||||||
|
"Лев Толстой",
|
||||||
|
"Фёдор Достоевский",
|
||||||
|
"Антон Чехов",
|
||||||
|
"Александр Пушкин",
|
||||||
|
"Михаил Булгаков",
|
||||||
|
"Николай Гоголь",
|
||||||
|
"Иван Тургенев",
|
||||||
|
"Борис Пастернак",
|
||||||
|
"Михаил Лермонтов",
|
||||||
|
"Александр Солженицын",
|
||||||
|
"Максим Горький",
|
||||||
|
"Иван Бунин"
|
||||||
|
]
|
||||||
|
|
||||||
|
authors = {}
|
||||||
|
for name in authors_data:
|
||||||
|
author_id = api.create_author(name)
|
||||||
|
if author_id:
|
||||||
|
authors[name] = author_id
|
||||||
|
|
||||||
|
# === ЖАНРЫ (8 жанров) ===
|
||||||
|
print("\n🏷️ Создание жанров...")
|
||||||
|
genres_data = [
|
||||||
|
"Роман",
|
||||||
|
"Повесть",
|
||||||
|
"Рассказ",
|
||||||
|
"Поэзия",
|
||||||
|
"Драма",
|
||||||
|
"Философская проза",
|
||||||
|
"Историческая проза",
|
||||||
|
"Сатира"
|
||||||
|
]
|
||||||
|
|
||||||
|
genres = {}
|
||||||
|
for name in genres_data:
|
||||||
|
genre_id = api.create_genre(name)
|
||||||
|
if genre_id:
|
||||||
|
genres[name] = genre_id
|
||||||
|
|
||||||
|
# === КНИГИ (25 книг) ===
|
||||||
|
print("\n📖 Создание книг...")
|
||||||
|
books_data = [
|
||||||
|
{
|
||||||
|
"title": "Война и мир",
|
||||||
|
"description": "Роман-эпопея Льва Толстого, описывающий русское общество в эпоху войн против Наполеона в 1805—1812 годах. Одно из величайших произведений мировой литературы.",
|
||||||
|
"authors": ["Лев Толстой"],
|
||||||
|
"genres": ["Роман", "Историческая проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Анна Каренина",
|
||||||
|
"description": "Роман Льва Толстого о трагической любви замужней дамы Анны Карениной к блестящему офицеру Вронскому. История страсти, ревности и роковых решений.",
|
||||||
|
"authors": ["Лев Толстой"],
|
||||||
|
"genres": ["Роман", "Драма"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Преступление и наказание",
|
||||||
|
"description": "Социально-психологический роман Фёдора Достоевского о бедном студенте Раскольникове, совершившем убийство и мучающемся угрызениями совести.",
|
||||||
|
"authors": ["Фёдор Достоевский"],
|
||||||
|
"genres": ["Роман", "Философская проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Братья Карамазовы",
|
||||||
|
"description": "Последний роман Достоевского, история семьи Карамазовых, затрагивающая глубокие вопросы веры, свободы воли и морали.",
|
||||||
|
"authors": ["Фёдор Достоевский"],
|
||||||
|
"genres": ["Роман", "Философская проза", "Драма"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Идиот",
|
||||||
|
"description": "Роман о князе Мышкине — человеке с чистой душой, который сталкивается с жестокостью и корыстью петербургского общества.",
|
||||||
|
"authors": ["Фёдор Достоевский"],
|
||||||
|
"genres": ["Роман", "Философская проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Вишнёвый сад",
|
||||||
|
"description": "Пьеса Антона Чехова о разорении дворянского гнезда и продаже родового имения с вишнёвым садом.",
|
||||||
|
"authors": ["Антон Чехов"],
|
||||||
|
"genres": ["Драма"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Чайка",
|
||||||
|
"description": "Пьеса Чехова о любви, искусстве и несбывшихся мечтах, разворачивающаяся в усадьбе на берегу озера.",
|
||||||
|
"authors": ["Антон Чехов"],
|
||||||
|
"genres": ["Драма"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Палата № 6",
|
||||||
|
"description": "Повесть о враче психиатрической больницы, который начинает сомневаться в границах между нормой и безумием.",
|
||||||
|
"authors": ["Антон Чехов"],
|
||||||
|
"genres": ["Повесть", "Философская проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Евгений Онегин",
|
||||||
|
"description": "Роман в стихах Александра Пушкина — энциклопедия русской жизни начала XIX века и история несчастной любви.",
|
||||||
|
"authors": ["Александр Пушкин"],
|
||||||
|
"genres": ["Роман", "Поэзия"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Капитанская дочка",
|
||||||
|
"description": "Исторический роман Пушкина о событиях Пугачёвского восстания, любви и чести.",
|
||||||
|
"authors": ["Александр Пушкин"],
|
||||||
|
"genres": ["Роман", "Историческая проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Пиковая дама",
|
||||||
|
"description": "Повесть о молодом офицере Германне, одержимом желанием узнать тайну трёх карт.",
|
||||||
|
"authors": ["Александр Пушкин"],
|
||||||
|
"genres": ["Повесть"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Мастер и Маргарита",
|
||||||
|
"description": "Роман Михаила Булгакова о визите дьявола в Москву 1930-х годов, переплетённый с историей Понтия Пилата.",
|
||||||
|
"authors": ["Михаил Булгаков"],
|
||||||
|
"genres": ["Роман", "Сатира", "Философская проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Собачье сердце",
|
||||||
|
"description": "Повесть-сатира о профессоре Преображенском, превратившем бродячего пса в человека.",
|
||||||
|
"authors": ["Михаил Булгаков"],
|
||||||
|
"genres": ["Повесть", "Сатира"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Белая гвардия",
|
||||||
|
"description": "Роман о семье Турбиных в Киеве во время Гражданской войны 1918-1919 годов.",
|
||||||
|
"authors": ["Михаил Булгаков"],
|
||||||
|
"genres": ["Роман", "Историческая проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Мёртвые души",
|
||||||
|
"description": "Поэма Николая Гоголя о похождениях Чичикова, скупающего «мёртвые души» крепостных крестьян.",
|
||||||
|
"authors": ["Николай Гоголь"],
|
||||||
|
"genres": ["Роман", "Сатира"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Ревизор",
|
||||||
|
"description": "Комедия о чиновниках уездного города, принявших проезжего за ревизора из Петербурга.",
|
||||||
|
"authors": ["Николай Гоголь"],
|
||||||
|
"genres": ["Драма", "Сатира"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Шинель",
|
||||||
|
"description": "Повесть о маленьком человеке — титулярном советнике Акакии Башмачкине и его мечте о новой шинели.",
|
||||||
|
"authors": ["Николай Гоголь"],
|
||||||
|
"genres": ["Повесть"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Отцы и дети",
|
||||||
|
"description": "Роман Ивана Тургенева о конфликте поколений и нигилизме на примере Евгения Базарова.",
|
||||||
|
"authors": ["Иван Тургенев"],
|
||||||
|
"genres": ["Роман", "Философская проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Записки охотника",
|
||||||
|
"description": "Цикл рассказов Тургенева о русской деревне и крестьянах, написанный с глубоким сочувствием к народу.",
|
||||||
|
"authors": ["Иван Тургенев"],
|
||||||
|
"genres": ["Рассказ"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Доктор Живаго",
|
||||||
|
"description": "Роман Бориса Пастернака о судьбе русского интеллигента в эпоху революции и Гражданской войны.",
|
||||||
|
"authors": ["Борис Пастернак"],
|
||||||
|
"genres": ["Роман", "Историческая проза", "Поэзия"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Герой нашего времени",
|
||||||
|
"description": "Роман Михаила Лермонтова о Печорине — «лишнем человеке», скучающем и разочарованном в жизни.",
|
||||||
|
"authors": ["Михаил Лермонтов"],
|
||||||
|
"genres": ["Роман", "Философская проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Архипелаг ГУЛАГ",
|
||||||
|
"description": "Документально-художественное исследование Александра Солженицына о системе советских лагерей.",
|
||||||
|
"authors": ["Александр Солженицын"],
|
||||||
|
"genres": ["Историческая проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Один день Ивана Денисовича",
|
||||||
|
"description": "Повесть о одном дне заключённого советского лагеря, положившая начало лагерной прозе.",
|
||||||
|
"authors": ["Александр Солженицын"],
|
||||||
|
"genres": ["Повесть", "Историческая проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "На дне",
|
||||||
|
"description": "Пьеса Максима Горького о жителях ночлежки для бездомных — людях, оказавшихся на дне жизни.",
|
||||||
|
"authors": ["Максим Горький"],
|
||||||
|
"genres": ["Драма", "Философская проза"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Тёмные аллеи",
|
||||||
|
"description": "Сборник рассказов Ивана Бунина о любви — трагической, мимолётной и прекрасной.",
|
||||||
|
"authors": ["Иван Бунин"],
|
||||||
|
"genres": ["Рассказ"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
books = {}
|
||||||
|
for book in books_data:
|
||||||
|
book_id = api.create_book(book["title"], book["description"])
|
||||||
|
if book_id:
|
||||||
|
books[book["title"]] = {
|
||||||
|
"id": book_id,
|
||||||
|
"authors": book["authors"],
|
||||||
|
"genres": book["genres"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# === СОЗДАНИЕ СВЯЗЕЙ ===
|
||||||
|
print("\n🔗 Создание связей...")
|
||||||
|
|
||||||
|
for book_title, book_info in books.items():
|
||||||
|
book_id = book_info["id"]
|
||||||
|
|
||||||
|
# Связи с авторами
|
||||||
|
for author_name in book_info["authors"]:
|
||||||
|
if author_name in authors:
|
||||||
|
api.link_author_book(authors[author_name], book_id)
|
||||||
|
|
||||||
|
# Связи с жанрами
|
||||||
|
for genre_name in book_info["genres"]:
|
||||||
|
if genre_name in genres:
|
||||||
|
api.link_genre_book(genres[genre_name], book_id)
|
||||||
|
|
||||||
|
# === ИТОГИ ===
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print("📊 ИТОГИ:")
|
||||||
|
print(f" • Авторов создано: {len(authors)}")
|
||||||
|
print(f" • Жанров создано: {len(genres)}")
|
||||||
|
print(f" • Книг создано: {len(books)}")
|
||||||
|
print("=" * 50)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+20
-9
@@ -11,7 +11,7 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from library_service.models.db import Role, User
|
from library_service.models.db import Role, User
|
||||||
from library_service.models.dto import TokenData
|
from library_service.models.dto import TokenData
|
||||||
from library_service.settings import get_session
|
from library_service.settings import get_session, get_logger
|
||||||
|
|
||||||
|
|
||||||
# Конфигурация из переменных окружения
|
# Конфигурация из переменных окружения
|
||||||
@@ -20,11 +20,15 @@ SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
|||||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||||
|
|
||||||
# Хэширование паролей
|
|
||||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
# Получение логгера
|
||||||
|
logger = get_logger("uvicorn")
|
||||||
|
|
||||||
# OAuth2 схема
|
# OAuth2 схема
|
||||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||||
|
|
||||||
|
# Хэширование паролей
|
||||||
|
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
@@ -161,7 +165,7 @@ def seed_roles(session: Session) -> dict[str, Role]:
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(role)
|
session.refresh(role)
|
||||||
roles[role_data["name"]] = role
|
roles[role_data["name"]] = role
|
||||||
print(f"[+] Created role: {role_data['name']}")
|
logger.info(f"[+] Created role: {role_data['name']}")
|
||||||
|
|
||||||
return roles
|
return roles
|
||||||
|
|
||||||
@@ -173,18 +177,18 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
|
|||||||
).all()
|
).all()
|
||||||
|
|
||||||
if existing_admins:
|
if existing_admins:
|
||||||
print(f"[*] Admin already exists: {existing_admins[0].username}")
|
logger.info(f"[=] Admin already exists: {existing_admins[0].username}, skipping creation")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||||
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
|
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
|
||||||
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
|
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
generated = False
|
||||||
if not admin_password:
|
if not admin_password:
|
||||||
import secrets
|
import secrets
|
||||||
admin_password = secrets.token_urlsafe(16)
|
admin_password = secrets.token_urlsafe(16)
|
||||||
print(f"[!] Generated admin password: {admin_password}")
|
generated = True
|
||||||
print("[!] Please save this password and set DEFAULT_ADMIN_PASSWORD env var")
|
|
||||||
|
|
||||||
admin_user = User(
|
admin_user = User(
|
||||||
username=admin_username,
|
username=admin_username,
|
||||||
@@ -200,7 +204,14 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
|
|||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(admin_user)
|
session.refresh(admin_user)
|
||||||
|
|
||||||
print(f"[+] Created admin user: {admin_username}")
|
logger.info(f"[+] Created admin user: {admin_username}")
|
||||||
|
|
||||||
|
if generated:
|
||||||
|
logger.warning("=" * 50)
|
||||||
|
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
|
||||||
|
logger.warning("[!] Save this password! It won't be shown again!")
|
||||||
|
logger.warning("=" * 50)
|
||||||
|
|
||||||
return admin_user
|
return admin_user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+28
-12
@@ -6,27 +6,43 @@ from alembic import command
|
|||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from .auth import run_seeds
|
||||||
from .routers import api_router
|
from .routers import api_router
|
||||||
from .settings import engine, get_app
|
from .settings import engine, get_app, get_logger
|
||||||
|
|
||||||
app = get_app()
|
|
||||||
alembic_cfg = Config("alembic.ini")
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Жизененый цикл сервиса"""
|
"""Жизненный цикл сервиса"""
|
||||||
print("[+] Initializing...")
|
logger = get_logger("uvicorn")
|
||||||
|
logger.info("[+] Initializing database...")
|
||||||
|
|
||||||
# Настройка базы данных
|
try:
|
||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
alembic_cfg.attributes["connection"] = connection
|
alembic_cfg = Config("alembic.ini")
|
||||||
command.upgrade(alembic_cfg, "head")
|
alembic_cfg.attributes["configure_logging"] = False
|
||||||
|
alembic_cfg.attributes["connection"] = connection
|
||||||
|
command.upgrade(alembic_cfg, "head")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[-] Migration failed: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
print("[+] Starting...")
|
logger.info("[+] Running seeds...")
|
||||||
|
try:
|
||||||
|
with Session(engine) as session:
|
||||||
|
run_seeds(session)
|
||||||
|
logger.info("[+] Database setup completed.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[-] Seeding failed: {e}")
|
||||||
|
|
||||||
|
logger.info("[+] Starting application...")
|
||||||
yield # Обработка запросов
|
yield # Обработка запросов
|
||||||
print("[+] Application shutdown")
|
logger.info("[+] Application shutdown")
|
||||||
|
|
||||||
|
|
||||||
|
app = get_app(lifespan)
|
||||||
|
|
||||||
|
|
||||||
# Подключение маршрутов
|
# Подключение маршрутов
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from sqlmodel import Session, select, col, func
|
|||||||
|
|
||||||
from library_service.auth import RequireAuth
|
from library_service.auth import RequireAuth
|
||||||
from library_service.settings import get_session
|
from library_service.settings import get_session
|
||||||
from library_service.models.db import Author, AuthorBookLink, Book
|
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink
|
||||||
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate
|
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
|
||||||
from library_service.models.dto.combined import (
|
from library_service.models.dto.combined import (
|
||||||
BookWithAuthorsAndGenres,
|
BookWithAuthorsAndGenres,
|
||||||
BookFilteredList
|
BookFilteredList
|
||||||
@@ -17,6 +17,54 @@ from library_service.models.dto.combined import (
|
|||||||
router = APIRouter(prefix="/books", tags=["books"])
|
router = APIRouter(prefix="/books", tags=["books"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/filter",
|
||||||
|
response_model=BookFilteredList,
|
||||||
|
summary="Фильтрация книг",
|
||||||
|
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
|
||||||
|
)
|
||||||
|
def filter_books(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
|
||||||
|
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
||||||
|
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
||||||
|
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||||
|
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
|
||||||
|
):
|
||||||
|
"""Эндпоинт получения отфильтрованного списка книг"""
|
||||||
|
statement = select(Book).distinct()
|
||||||
|
|
||||||
|
if q:
|
||||||
|
statement = statement.where(
|
||||||
|
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
if author_ids:
|
||||||
|
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
|
||||||
|
|
||||||
|
if genre_ids:
|
||||||
|
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
|
||||||
|
|
||||||
|
total_statement = select(func.count()).select_from(statement.subquery())
|
||||||
|
total = session.exec(total_statement).one()
|
||||||
|
|
||||||
|
offset = (page - 1) * size
|
||||||
|
statement = statement.offset(offset).limit(size)
|
||||||
|
results = session.exec(statement).all()
|
||||||
|
|
||||||
|
books_with_data = []
|
||||||
|
for db_book in results:
|
||||||
|
books_with_data.append(
|
||||||
|
BookWithAuthorsAndGenres(
|
||||||
|
**db_book.model_dump(),
|
||||||
|
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
|
||||||
|
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BookFilteredList(books=books_with_data, total=total)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/",
|
"/",
|
||||||
response_model=Book,
|
response_model=Book,
|
||||||
@@ -127,51 +175,3 @@ def delete_book(
|
|||||||
session.delete(book)
|
session.delete(book)
|
||||||
session.commit()
|
session.commit()
|
||||||
return book_read
|
return book_read
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/filter",
|
|
||||||
response_model=BookFilteredList,
|
|
||||||
summary="Фильтрация книг",
|
|
||||||
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
|
|
||||||
)
|
|
||||||
def filter_books(
|
|
||||||
session: Session = Depends(get_session),
|
|
||||||
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
|
|
||||||
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
|
||||||
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
|
||||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
|
||||||
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
|
|
||||||
):
|
|
||||||
"""Эндпоинт получения отфильтрованного списка книг"""
|
|
||||||
statement = select(Book).distinct()
|
|
||||||
|
|
||||||
if q:
|
|
||||||
statement = statement.where(
|
|
||||||
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
|
|
||||||
)
|
|
||||||
|
|
||||||
if author_ids:
|
|
||||||
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
|
|
||||||
|
|
||||||
if genre_ids:
|
|
||||||
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
|
|
||||||
|
|
||||||
total_statement = select(func.count()).select_from(statement.subquery())
|
|
||||||
total = session.exec(total_statement).one()
|
|
||||||
|
|
||||||
offset = (page - 1) * size
|
|
||||||
statement = statement.offset(offset).limit(size)
|
|
||||||
results = session.exec(statement).all()
|
|
||||||
|
|
||||||
books_with_data = []
|
|
||||||
for db_book in results:
|
|
||||||
books_with_data.append(
|
|
||||||
BookWithAuthorsAndGenres(
|
|
||||||
**db_book.model_dump(),
|
|
||||||
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
|
|
||||||
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return BookFilteredList(books=books_with_data, total=total)
|
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ from fastapi import APIRouter, Request
|
|||||||
from fastapi.params import Depends
|
from fastapi.params import Depends
|
||||||
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
from library_service.settings import get_app
|
from library_service.settings import get_app, get_session
|
||||||
|
from library_service.models.db import Author, Book, Genre, User
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(tags=["misc"])
|
router = APIRouter(tags=["misc"])
|
||||||
@@ -22,33 +24,33 @@ def get_info(app) -> Dict:
|
|||||||
"app_info": {
|
"app_info": {
|
||||||
"title": app.title,
|
"title": app.title,
|
||||||
"version": app.version,
|
"version": app.version,
|
||||||
"description": app.description,
|
"description": app.description.rsplit('|', 1)[0],
|
||||||
},
|
},
|
||||||
"server_time": datetime.now().isoformat(),
|
"server_time": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", include_in_schema=False)
|
@router.get("/", include_in_schema=False)
|
||||||
async def root(request: Request, app=Depends(get_app)):
|
async def root(request: Request, app=Depends(lambda: get_app())):
|
||||||
"""Эндпоинт главной страницы"""
|
"""Эндпоинт главной страницы"""
|
||||||
return RedirectResponse("/books")
|
return templates.TemplateResponse(request, "index.html", get_info(app))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/books", include_in_schema=False)
|
@router.get("/books", include_in_schema=False)
|
||||||
async def books(request: Request, app=Depends(get_app)):
|
async def books(request: Request, app=Depends(lambda: get_app())):
|
||||||
"""Эндпоинт страницы выбора книг"""
|
"""Эндпоинт страницы выбора книг"""
|
||||||
return templates.TemplateResponse(request, "books.html", get_info(app))
|
return templates.TemplateResponse(request, "books.html", get_info(app))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/auth", include_in_schema=False)
|
@router.get("/auth", include_in_schema=False)
|
||||||
async def root(request: Request, app=Depends(get_app)):
|
async def auth(request: Request, app=Depends(lambda: get_app())):
|
||||||
"""Эндпоинт страницы авторизации"""
|
"""Эндпоинт страницы авторизации"""
|
||||||
return templates.TemplateResponse(request, "auth.html", get_info(app))
|
return templates.TemplateResponse(request, "auth.html", get_info(app))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api", include_in_schema=False)
|
@router.get("/api", include_in_schema=False)
|
||||||
async def root(request: Request, app=Depends(get_app)):
|
async def api(request: Request, app=Depends(lambda: get_app())):
|
||||||
"""Страница с сылками на документацию API"""
|
"""Страница с сылками на документацию API"""
|
||||||
return templates.TemplateResponse(request, "api.html", get_info(app))
|
return templates.TemplateResponse(request, "api.html", get_info(app))
|
||||||
|
|
||||||
@@ -70,8 +72,27 @@ async def favicon():
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/api/info",
|
"/api/info",
|
||||||
summary="Информация о сервисе",
|
summary="Информация о сервисе",
|
||||||
description="Возвращает информацию о системе",
|
description="Возвращает общую информацию о системе",
|
||||||
)
|
)
|
||||||
async def api_info(app=Depends(get_app)):
|
async def api_info(app=Depends(lambda: get_app())):
|
||||||
"""Эндпоинт информации об API"""
|
"""Эндпоинт информации об API"""
|
||||||
return JSONResponse(content=get_info(app))
|
return JSONResponse(content=get_info(app))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/stats",
|
||||||
|
summary="Статистика сервиса",
|
||||||
|
description="Возвращает статистическую информацию о системе",
|
||||||
|
)
|
||||||
|
async def api_stats(session: Session = Depends(get_session)):
|
||||||
|
"""Эндпоинт стстистики системы"""
|
||||||
|
authors = select(func.count()).select_from(Author)
|
||||||
|
books = select(func.count()).select_from(Book)
|
||||||
|
genres = select(func.count()).select_from(Genre)
|
||||||
|
users = select(func.count()).select_from(User)
|
||||||
|
return JSONResponse(content={
|
||||||
|
"authors": session.exec(authors).one(),
|
||||||
|
"books": session.exec(books).one(),
|
||||||
|
"genres": session.exec(genres).one(),
|
||||||
|
"users": session.exec(users).one(),
|
||||||
|
})
|
||||||
|
|||||||
+43
-35
@@ -1,5 +1,5 @@
|
|||||||
"""Модуль настроек проекта"""
|
"""Модуль настроек проекта"""
|
||||||
import os
|
import os, logging
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
@@ -12,39 +12,42 @@ with open("pyproject.toml", 'r', encoding='utf-8') as f:
|
|||||||
config = load(f)
|
config = load(f)
|
||||||
|
|
||||||
|
|
||||||
def get_app() -> FastAPI:
|
def get_app(lifespan=None, /) -> FastAPI:
|
||||||
"""Dependency, для получение экземплярра FastAPI application"""
|
"""Dependency для получения экземпляра FastAPI application"""
|
||||||
return FastAPI(
|
if not hasattr(get_app, 'instance'):
|
||||||
title=config["tool"]["poetry"]["name"],
|
get_app.instance = FastAPI(
|
||||||
description=config["tool"]["poetry"]["description"],
|
title=config["tool"]["poetry"]["name"],
|
||||||
version=config["tool"]["poetry"]["version"],
|
description=config["tool"]["poetry"]["description"] + " | [Вернутьсяна главную](/)",
|
||||||
openapi_tags=[
|
version=config["tool"]["poetry"]["version"],
|
||||||
{
|
lifespan=lifespan,
|
||||||
"name": "authentication",
|
openapi_tags=[
|
||||||
"description": "Авторизация пользователя."
|
{
|
||||||
},
|
"name": "authentication",
|
||||||
{
|
"description": "Авторизация пользователя."
|
||||||
"name": "authors",
|
},
|
||||||
"description": "Действия с авторами.",
|
{
|
||||||
},
|
"name": "authors",
|
||||||
{
|
"description": "Действия с авторами.",
|
||||||
"name": "books",
|
},
|
||||||
"description": "Действия с книгами.",
|
{
|
||||||
},
|
"name": "books",
|
||||||
{
|
"description": "Действия с книгами.",
|
||||||
"name": "genres",
|
},
|
||||||
"description": "Действия с жанрами.",
|
{
|
||||||
},
|
"name": "genres",
|
||||||
{
|
"description": "Действия с жанрами.",
|
||||||
"name": "relations",
|
},
|
||||||
"description": "Действия с связями.",
|
{
|
||||||
},
|
"name": "relations",
|
||||||
{
|
"description": "Действия с связями.",
|
||||||
"name": "misc",
|
},
|
||||||
"description": "Прочие.",
|
{
|
||||||
},
|
"name": "misc",
|
||||||
],
|
"description": "Прочие.",
|
||||||
)
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return get_app.instance
|
||||||
|
|
||||||
|
|
||||||
HOST = os.getenv("POSTGRES_HOST")
|
HOST = os.getenv("POSTGRES_HOST")
|
||||||
@@ -57,10 +60,15 @@ if not USER or not PASSWORD or not DATABASE or not HOST:
|
|||||||
raise ValueError("Missing environment variables")
|
raise ValueError("Missing environment variables")
|
||||||
|
|
||||||
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
|
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
|
||||||
engine = create_engine(POSTGRES_DATABASE_URL, echo=True, future=True)
|
engine = create_engine(POSTGRES_DATABASE_URL, echo=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
def get_session():
|
def get_session():
|
||||||
"""Dependency, для получение сессии БД"""
|
"""Dependency, для получение сессии БД"""
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str = "uvicorn"):
|
||||||
|
"""Dependency, для получение логгера"""
|
||||||
|
return logging.getLogger(name)
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg
|
||||||
|
width="800px"
|
||||||
|
height="800px"
|
||||||
|
viewBox="0 0 15 15"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
fill-rule="evenodd"
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
|
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
|
||||||
fill="#000000"
|
fill="#000000"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
+440
-97
@@ -1,4 +1,10 @@
|
|||||||
$(document).ready(function () {
|
$(document).ready(function () {
|
||||||
|
let selectedAuthors = new Map(); // Map<id, name>
|
||||||
|
let selectedGenres = new Map(); // Map<id, name>
|
||||||
|
let currentPage = 1;
|
||||||
|
let pageSize = 20;
|
||||||
|
let totalBooks = 0;
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch("/api/authors").then((response) => response.json()),
|
fetch("/api/authors").then((response) => response.json()),
|
||||||
fetch("/api/genres").then((response) => response.json()),
|
fetch("/api/genres").then((response) => response.json()),
|
||||||
@@ -6,113 +12,448 @@ $(document).ready(function () {
|
|||||||
.then(([authorsData, genresData]) => {
|
.then(([authorsData, genresData]) => {
|
||||||
const $dropdown = $("#author-dropdown");
|
const $dropdown = $("#author-dropdown");
|
||||||
authorsData.authors.forEach((author) => {
|
authorsData.authors.forEach((author) => {
|
||||||
const $div = $("<div>", {
|
$("<div>")
|
||||||
class: "p-2 hover:bg-gray-100 cursor-pointer",
|
.addClass("p-2 hover:bg-gray-100 cursor-pointer author-item")
|
||||||
"data-value": author.name,
|
.attr("data-id", author.id)
|
||||||
text: author.name,
|
.attr("data-name", author.name)
|
||||||
});
|
.text(author.name)
|
||||||
$dropdown.append($div);
|
.appendTo($dropdown);
|
||||||
});
|
});
|
||||||
|
|
||||||
const $list = $("#genres-list");
|
const $list = $("#genres-list");
|
||||||
genresData.genres.forEach((genre) => {
|
genresData.genres.forEach((genre) => {
|
||||||
const $li = $("<li>", { class: "mb-1" });
|
$("<li>")
|
||||||
$li.html(`
|
.addClass("mb-1")
|
||||||
<label class="custom-checkbox flex items-center">
|
.html(
|
||||||
<input type="checkbox" />
|
`<label class="custom-checkbox flex items-center">
|
||||||
<span class="checkmark"></span>
|
<input type="checkbox" data-id="${genre.id}" data-name="${genre.name}" />
|
||||||
${genre.name}
|
<span class="checkmark"></span>
|
||||||
</label>
|
${genre.name}
|
||||||
`);
|
</label>`,
|
||||||
$list.append($li);
|
)
|
||||||
|
.appendTo($list);
|
||||||
});
|
});
|
||||||
|
|
||||||
initializeAuthorDropdown();
|
initializeAuthorDropdown();
|
||||||
|
initializeFilters();
|
||||||
|
|
||||||
|
// Загружаем книги при старте
|
||||||
|
loadBooks();
|
||||||
})
|
})
|
||||||
.catch((error) => console.error("Error loading data:", error));
|
.catch((error) => console.error("Error loading data:", error));
|
||||||
|
|
||||||
function initializeAuthorDropdown() {
|
// === Функция загрузки книг ===
|
||||||
const $authorSearchInput = $("#author-search-input");
|
function loadBooks() {
|
||||||
const $authorDropdown = $("#author-dropdown");
|
const searchQuery = $("#book-search-input").val().trim();
|
||||||
const $selectedAuthorsContainer = $("#selected-authors-container");
|
|
||||||
const $dropdownItems = $authorDropdown.find("[data-value]");
|
|
||||||
let selectedAuthors = new Set();
|
|
||||||
|
|
||||||
const updateDropdownHighlights = () => {
|
// Формируем URL с параметрами
|
||||||
$dropdownItems.each(function () {
|
const params = new URLSearchParams();
|
||||||
const value = $(this).data("value");
|
|
||||||
$(this).toggleClass("bg-gray-200", selectedAuthors.has(value));
|
// Добавляем поиск (минимум 3 символа)
|
||||||
|
if (searchQuery.length >= 3) {
|
||||||
|
params.append("q", searchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем авторов
|
||||||
|
selectedAuthors.forEach((name, id) => {
|
||||||
|
params.append("author_ids", id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем жанры
|
||||||
|
selectedGenres.forEach((name, id) => {
|
||||||
|
params.append("genre_ids", id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пагинация
|
||||||
|
params.append("page", currentPage);
|
||||||
|
params.append("size", pageSize);
|
||||||
|
|
||||||
|
const url = `/api/books/filter?${params.toString()}`;
|
||||||
|
|
||||||
|
// Показываем индикатор загрузки
|
||||||
|
showLoadingState();
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
totalBooks = data.total;
|
||||||
|
renderBooks(data.books);
|
||||||
|
renderPagination();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error loading books:", error);
|
||||||
|
showErrorState();
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const renderSelectedAuthors = () => {
|
|
||||||
$selectedAuthorsContainer.children().not("#author-search-input").remove();
|
|
||||||
|
|
||||||
selectedAuthors.forEach((author) => {
|
|
||||||
const $authorChip = $("<span>", {
|
|
||||||
class:
|
|
||||||
"flex items-center bg-gray-200 text-gray-800 text-sm font-medium px-2.5 py-0.5 rounded-full",
|
|
||||||
});
|
|
||||||
$authorChip.html(`
|
|
||||||
${author}
|
|
||||||
<button type="button" class="ml-1 inline-flex items-center p-0.5 text-sm text-gray-400 bg-transparent rounded-sm hover:bg-gray-200 hover:text-gray-900" data-author="${author}">
|
|
||||||
<svg class="w-2 h-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
|
|
||||||
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
|
||||||
</svg>
|
|
||||||
<span class="sr-only">Remove author</span>
|
|
||||||
</button>
|
|
||||||
`);
|
|
||||||
$selectedAuthorsContainer.append($authorChip);
|
|
||||||
});
|
|
||||||
updateDropdownHighlights();
|
|
||||||
};
|
|
||||||
|
|
||||||
$authorSearchInput.on("focus", () => {
|
|
||||||
$authorDropdown.removeClass("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
$authorSearchInput.on("input", function () {
|
|
||||||
const query = $(this).val().toLowerCase();
|
|
||||||
$dropdownItems.each(function () {
|
|
||||||
const text = $(this).text().toLowerCase();
|
|
||||||
$(this).css("display", text.includes(query) ? "block" : "none");
|
|
||||||
});
|
|
||||||
$authorDropdown.removeClass("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("click", function (event) {
|
|
||||||
if (
|
|
||||||
!$selectedAuthorsContainer.is(event.target) &&
|
|
||||||
!$selectedAuthorsContainer.has(event.target).length &&
|
|
||||||
!$authorDropdown.is(event.target) &&
|
|
||||||
!$authorDropdown.has(event.target).length
|
|
||||||
) {
|
|
||||||
$authorDropdown.addClass("hidden");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$authorDropdown.on("click", "[data-value]", function () {
|
|
||||||
const selectedValue = $(this).data("value");
|
|
||||||
if (selectedAuthors.has(selectedValue)) {
|
|
||||||
selectedAuthors.delete(selectedValue);
|
|
||||||
} else {
|
|
||||||
selectedAuthors.add(selectedValue);
|
|
||||||
}
|
|
||||||
$authorSearchInput.val("");
|
|
||||||
renderSelectedAuthors();
|
|
||||||
$authorSearchInput.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
$selectedAuthorsContainer.on("click", "button", function () {
|
|
||||||
const authorToRemove = $(this).data("author");
|
|
||||||
selectedAuthors.delete(authorToRemove);
|
|
||||||
renderSelectedAuthors();
|
|
||||||
$authorSearchInput.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
renderSelectedAuthors();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Отображение книг ===
|
||||||
|
function renderBooks(books) {
|
||||||
|
const $container = $("#books-container");
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
if (books.length === 0) {
|
||||||
|
$container.html(`
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Книги не найдены</h3>
|
||||||
|
<p class="text-gray-500">Попробуйте изменить параметры поиска или фильтры</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
books.forEach((book) => {
|
||||||
|
const authorsText =
|
||||||
|
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||||
|
const genresText =
|
||||||
|
book.genres.map((g) => g.name).join(", ") || "Без жанра";
|
||||||
|
|
||||||
|
const $bookCard = $(`
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow-md mb-4 hover:shadow-lg transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-bold mb-1 text-gray-900 hover:text-blue-600 transition-colors">
|
||||||
|
${escapeHtml(book.title)}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-2">
|
||||||
|
<span class="font-medium">Авторы:</span> ${escapeHtml(authorsText)}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-700 text-sm mb-2">
|
||||||
|
${escapeHtml(book.description || "Описание отсутствует")}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
${book.genres
|
||||||
|
.map(
|
||||||
|
(g) => `
|
||||||
|
<span class="inline-block bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full">
|
||||||
|
${escapeHtml(g.name)}
|
||||||
|
</span>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$container.append($bookCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик клика на карточку книги
|
||||||
|
$container.on("click", ".book-card", function () {
|
||||||
|
const bookId = $(this).data("id");
|
||||||
|
window.location.href = `/books/${bookId}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Пагинация ===
|
||||||
|
function renderPagination() {
|
||||||
|
// Удаляем старую пагинацию
|
||||||
|
$("#pagination-container").remove();
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalBooks / pageSize);
|
||||||
|
|
||||||
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
|
const $pagination = $(`
|
||||||
|
<div id="pagination-container" class="flex justify-center items-center gap-2 mt-6 mb-4">
|
||||||
|
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="page-numbers" class="flex gap-1"></div>
|
||||||
|
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const $pageNumbers = $pagination.find("#page-numbers");
|
||||||
|
|
||||||
|
// Генерируем номера страниц
|
||||||
|
const pages = generatePageNumbers(currentPage, totalPages);
|
||||||
|
|
||||||
|
pages.forEach((page) => {
|
||||||
|
if (page === "...") {
|
||||||
|
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
|
||||||
|
} else {
|
||||||
|
const isActive = page === currentPage;
|
||||||
|
$pageNumbers.append(`
|
||||||
|
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
|
||||||
|
${page}
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#books-container").after($pagination);
|
||||||
|
|
||||||
|
// Обработчики пагинации
|
||||||
|
$("#prev-page").on("click", function () {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
loadBooks();
|
||||||
|
scrollToTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#next-page").on("click", function () {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
loadBooks();
|
||||||
|
scrollToTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(".page-btn").on("click", function () {
|
||||||
|
const page = parseInt($(this).data("page"));
|
||||||
|
if (page !== currentPage) {
|
||||||
|
currentPage = page;
|
||||||
|
loadBooks();
|
||||||
|
scrollToTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePageNumbers(current, total) {
|
||||||
|
const pages = [];
|
||||||
|
const delta = 2;
|
||||||
|
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
if (
|
||||||
|
i === 1 ||
|
||||||
|
i === total ||
|
||||||
|
(i >= current - delta && i <= current + delta)
|
||||||
|
) {
|
||||||
|
pages.push(i);
|
||||||
|
} else if (pages[pages.length - 1] !== "...") {
|
||||||
|
pages.push("...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToTop() {
|
||||||
|
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Состояния загрузки ===
|
||||||
|
function showLoadingState() {
|
||||||
|
const $container = $("#books-container");
|
||||||
|
$container.html(`
|
||||||
|
<div class="space-y-4">
|
||||||
|
${Array(3)
|
||||||
|
.fill()
|
||||||
|
.map(
|
||||||
|
() => `
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||||
|
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="h-6 bg-gray-200 rounded-full w-16"></div>
|
||||||
|
<div class="h-6 bg-gray-200 rounded-full w-20"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorState() {
|
||||||
|
const $container = $("#books-container");
|
||||||
|
$container.html(`
|
||||||
|
<div class="bg-red-50 p-8 rounded-lg shadow-md text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
|
||||||
|
<p class="text-red-700 mb-4">Не удалось загрузить список книг</p>
|
||||||
|
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$("#retry-btn").on("click", loadBooks);
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Экранирование HTML ===
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Dropdown авторов ===
|
||||||
|
function initializeAuthorDropdown() {
|
||||||
|
const $input = $("#author-search-input");
|
||||||
|
const $dropdown = $("#author-dropdown");
|
||||||
|
const $container = $("#selected-authors-container");
|
||||||
|
|
||||||
|
function updateHighlights() {
|
||||||
|
$dropdown.find(".author-item").each(function () {
|
||||||
|
const id = $(this).attr("data-id");
|
||||||
|
const isSelected = selectedAuthors.has(parseInt(id));
|
||||||
|
$(this)
|
||||||
|
.toggleClass("bg-gray-300 text-gray-600", isSelected)
|
||||||
|
.toggleClass("hover:bg-gray-100", !isSelected);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterDropdown(query) {
|
||||||
|
const lowerQuery = query.toLowerCase();
|
||||||
|
$dropdown.find(".author-item").each(function () {
|
||||||
|
$(this).toggle($(this).text().toLowerCase().includes(lowerQuery));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChips() {
|
||||||
|
$container.find(".author-chip").remove();
|
||||||
|
selectedAuthors.forEach((name, id) => {
|
||||||
|
$(`<span class="author-chip flex items-center bg-gray-500 text-white text-sm font-medium px-2.5 py-0.5 rounded-full">
|
||||||
|
${escapeHtml(name)}
|
||||||
|
<button type="button" class="remove-author ml-1.5 inline-flex items-center p-0.5 text-gray-200 hover:text-white hover:bg-gray-600 rounded-full" data-id="${id}">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 14 14">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>`).insertBefore($input);
|
||||||
|
});
|
||||||
|
updateHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAuthor(id, name) {
|
||||||
|
id = parseInt(id);
|
||||||
|
if (selectedAuthors.has(id)) {
|
||||||
|
selectedAuthors.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedAuthors.add(id, name);
|
||||||
|
selectedAuthors.set(id, name);
|
||||||
|
}
|
||||||
|
$input.val("");
|
||||||
|
filterDropdown("");
|
||||||
|
renderChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
$input.on("focus", () => $dropdown.removeClass("hidden"));
|
||||||
|
|
||||||
|
$input.on("input", function () {
|
||||||
|
filterDropdown($(this).val().toLowerCase());
|
||||||
|
$dropdown.removeClass("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", (e) => {
|
||||||
|
if (
|
||||||
|
!$(e.target).closest("#selected-authors-container, #author-dropdown")
|
||||||
|
.length
|
||||||
|
) {
|
||||||
|
$dropdown.addClass("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$dropdown.on("click", ".author-item", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleAuthor($(this).attr("data-id"), $(this).attr("data-name"));
|
||||||
|
$input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.on("click", ".remove-author", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
selectedAuthors.delete(parseInt($(this).attr("data-id")));
|
||||||
|
renderChips();
|
||||||
|
$input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.on("click", (e) => {
|
||||||
|
if (!$(e.target).closest(".author-chip").length) {
|
||||||
|
$input.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.renderAuthorChips = renderChips;
|
||||||
|
window.updateAuthorHighlights = updateHighlights;
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Инициализация фильтров ===
|
||||||
|
function initializeFilters() {
|
||||||
|
const $bookSearch = $("#book-search-input");
|
||||||
|
const $applyBtn = $("#apply-filters-btn");
|
||||||
|
const $resetBtn = $("#reset-filters-btn");
|
||||||
|
|
||||||
|
// Обработка жанров
|
||||||
|
$("#genres-list").on("change", "input[type='checkbox']", function () {
|
||||||
|
const id = parseInt($(this).attr("data-id"));
|
||||||
|
const name = $(this).attr("data-name");
|
||||||
|
if ($(this).is(":checked")) {
|
||||||
|
selectedGenres.set(id, name);
|
||||||
|
} else {
|
||||||
|
selectedGenres.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Применить фильтры
|
||||||
|
$applyBtn.on("click", function () {
|
||||||
|
currentPage = 1; // Сбрасываем на первую страницу
|
||||||
|
loadBooks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Сбросить фильтры
|
||||||
|
$resetBtn.on("click", function () {
|
||||||
|
$bookSearch.val("");
|
||||||
|
|
||||||
|
selectedAuthors.clear();
|
||||||
|
$("#selected-authors-container .author-chip").remove();
|
||||||
|
if (window.updateAuthorHighlights) window.updateAuthorHighlights();
|
||||||
|
|
||||||
|
selectedGenres.clear();
|
||||||
|
$("#genres-list input[type='checkbox']").prop("checked", false);
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
loadBooks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Поиск с дебаунсом
|
||||||
|
let searchTimeout;
|
||||||
|
$bookSearch.on("input", function () {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
const query = $(this).val().trim();
|
||||||
|
|
||||||
|
// Автопоиск только если >= 3 символов или пусто
|
||||||
|
if (query.length >= 3 || query.length === 0) {
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentPage = 1;
|
||||||
|
loadBooks();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Поиск по Enter
|
||||||
|
$bookSearch.on("keypress", function (e) {
|
||||||
|
if (e.which === 13) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
currentPage = 1;
|
||||||
|
loadBooks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Остальной код (пользователь/авторизация) ===
|
||||||
const $guestLink = $("#guest-link");
|
const $guestLink = $("#guest-link");
|
||||||
const $userBtn = $("#user-btn");
|
const $userBtn = $("#user-btn");
|
||||||
const $userDropdown = $("#user-dropdown");
|
const $userDropdown = $("#user-dropdown");
|
||||||
@@ -185,8 +526,10 @@ $(document).ready(function () {
|
|||||||
const emailHash = sha256(cleanEmail);
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
const avatarImg = document.getElementById('user-avatar');
|
const avatarImg = document.getElementById("user-avatar");
|
||||||
if (avatarImg) { avatarImg.src = avatarUrl; }
|
if (avatarImg) {
|
||||||
|
avatarImg.src = avatarUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
@@ -205,8 +548,8 @@ $(document).ready(function () {
|
|||||||
showUser(user);
|
showUser(user);
|
||||||
updateUserAvatar(user.email);
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
document.getElementById('user-btn').classList.remove('hidden');
|
document.getElementById("user-btn").classList.remove("hidden");
|
||||||
document.getElementById('guest-link').classList.add('hidden');
|
document.getElementById("guest-link").classList.add("hidden");
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
localStorage.removeItem("access_token");
|
localStorage.removeItem("access_token");
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
const svg = document.getElementById("bookSvg");
|
||||||
|
const NS = "http://www.w3.org/2000/svg";
|
||||||
|
|
||||||
|
const svgWidth = 200;
|
||||||
|
const svgHeight = 250;
|
||||||
|
const lineCount = 5;
|
||||||
|
const lineDelay = 16;
|
||||||
|
const bookWidth = 120;
|
||||||
|
const bookHeight = 180;
|
||||||
|
const bookX = (svgWidth - bookWidth) / 2;
|
||||||
|
const bookY = (svgHeight - bookHeight) / 2;
|
||||||
|
const desiredLineSpacing = 8;
|
||||||
|
const baseLineWidth = 2;
|
||||||
|
const maxLineWidth = 10;
|
||||||
|
const maxLineHeight = bookHeight - 24;
|
||||||
|
const innerPaddingX = 10;
|
||||||
|
const appearStagger = 8;
|
||||||
|
|
||||||
|
let lineSpacing;
|
||||||
|
if (lineCount > 1) {
|
||||||
|
const maxSpan = Math.max(0, bookWidth - maxLineWidth - 2 * innerPaddingX);
|
||||||
|
const wishSpan = desiredLineSpacing * (lineCount - 1);
|
||||||
|
const realSpan = Math.min(wishSpan, maxSpan);
|
||||||
|
lineSpacing = realSpan / (lineCount - 1);
|
||||||
|
} else {
|
||||||
|
lineSpacing = 0;
|
||||||
|
}
|
||||||
|
const linesSpan = lineSpacing * (lineCount - 1);
|
||||||
|
|
||||||
|
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
|
||||||
|
const lineStartX = rightBase - linesSpan;
|
||||||
|
|
||||||
|
const leftLimit = bookX + innerPaddingX;
|
||||||
|
|
||||||
|
let phase = 0;
|
||||||
|
let time = 0;
|
||||||
|
|
||||||
|
const baseAppearDuration = 40;
|
||||||
|
const appearDuration = baseAppearDuration + (lineCount - 1) * appearStagger;
|
||||||
|
|
||||||
|
const baseFlipDuration = 120;
|
||||||
|
const flipDuration = baseFlipDuration + (lineCount - 1) * lineDelay;
|
||||||
|
|
||||||
|
const baseDisappearDuration = 40;
|
||||||
|
const disappearDuration =
|
||||||
|
baseDisappearDuration + (lineCount - 1) * appearStagger;
|
||||||
|
|
||||||
|
const pauseDuration = 30;
|
||||||
|
|
||||||
|
const book = document.createElementNS(NS, "rect");
|
||||||
|
book.setAttribute("x", bookX);
|
||||||
|
book.setAttribute("y", bookY);
|
||||||
|
book.setAttribute("width", bookWidth);
|
||||||
|
book.setAttribute("height", bookHeight);
|
||||||
|
book.setAttribute("fill", "#374151");
|
||||||
|
book.setAttribute("rx", "4");
|
||||||
|
svg.appendChild(book);
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const line = document.createElementNS(NS, "rect");
|
||||||
|
line.setAttribute("fill", "#ffffff");
|
||||||
|
line.setAttribute("rx", "1");
|
||||||
|
svg.appendChild(line);
|
||||||
|
|
||||||
|
const baseX = lineStartX + i * lineSpacing;
|
||||||
|
const targetX = leftLimit + i * lineSpacing;
|
||||||
|
const moveDistance = baseX - targetX;
|
||||||
|
|
||||||
|
lines.push({
|
||||||
|
el: line,
|
||||||
|
baseX,
|
||||||
|
targetX,
|
||||||
|
moveDistance,
|
||||||
|
currentX: baseX,
|
||||||
|
width: baseLineWidth,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeInOutQuad(t) {
|
||||||
|
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeOutQuad(t) {
|
||||||
|
return 1 - (1 - t) * (1 - t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeInQuad(t) {
|
||||||
|
return t * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLine(line) {
|
||||||
|
const el = line.el;
|
||||||
|
const centerY = bookY + bookHeight / 2;
|
||||||
|
|
||||||
|
el.setAttribute("x", line.currentX);
|
||||||
|
el.setAttribute("y", centerY - line.height / 2);
|
||||||
|
el.setAttribute("width", line.width);
|
||||||
|
el.setAttribute("height", Math.max(0, line.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
function animateBook() {
|
||||||
|
time++;
|
||||||
|
|
||||||
|
if (phase === 0) {
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const delay = (lineCount - 1 - i) * appearStagger;
|
||||||
|
const localTime = Math.max(0, time - delay);
|
||||||
|
const progress = Math.min(1, localTime / baseAppearDuration);
|
||||||
|
const easedProgress = easeOutQuad(progress);
|
||||||
|
|
||||||
|
lines[i].height = maxLineHeight * easedProgress;
|
||||||
|
lines[i].currentX = lines[i].baseX;
|
||||||
|
lines[i].width = baseLineWidth;
|
||||||
|
updateLine(lines[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time >= appearDuration) {
|
||||||
|
phase = 1;
|
||||||
|
time = 0;
|
||||||
|
}
|
||||||
|
} else if (phase === 1) {
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const delay = i * lineDelay;
|
||||||
|
const localTime = Math.max(0, time - delay);
|
||||||
|
const progress = Math.min(1, localTime / baseFlipDuration);
|
||||||
|
|
||||||
|
const moveProgress = easeInOutQuad(progress);
|
||||||
|
lines[i].currentX = lines[i].baseX - lines[i].moveDistance * moveProgress;
|
||||||
|
|
||||||
|
const widthProgress =
|
||||||
|
progress < 0.5
|
||||||
|
? easeOutQuad(progress * 2)
|
||||||
|
: 1 - easeInQuad((progress - 0.5) * 2);
|
||||||
|
|
||||||
|
lines[i].width =
|
||||||
|
baseLineWidth + (maxLineWidth - baseLineWidth) * widthProgress;
|
||||||
|
|
||||||
|
updateLine(lines[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time >= flipDuration) {
|
||||||
|
phase = 2;
|
||||||
|
time = 0;
|
||||||
|
}
|
||||||
|
} else if (phase === 2) {
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const delay = (lineCount - 1 - i) * appearStagger;
|
||||||
|
const localTime = Math.max(0, time - delay);
|
||||||
|
const progress = Math.min(1, localTime / baseDisappearDuration);
|
||||||
|
const easedProgress = easeInQuad(progress);
|
||||||
|
|
||||||
|
lines[i].height = maxLineHeight * (1 - easedProgress);
|
||||||
|
updateLine(lines[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time >= disappearDuration + pauseDuration) {
|
||||||
|
phase = 0;
|
||||||
|
time = 0;
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
lines[i].currentX = lines[i].baseX;
|
||||||
|
lines[i].width = baseLineWidth;
|
||||||
|
lines[i].height = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(animateBook);
|
||||||
|
}
|
||||||
|
|
||||||
|
animateBook();
|
||||||
|
|
||||||
|
function animateCounter(element, target, duration = 2000) {
|
||||||
|
const start = 0;
|
||||||
|
const startTime = performance.now();
|
||||||
|
|
||||||
|
function update(currentTime) {
|
||||||
|
const elapsed = currentTime - startTime;
|
||||||
|
const progress = Math.min(elapsed / duration, 1);
|
||||||
|
|
||||||
|
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||||
|
const current = Math.floor(start + (target - start) * easedProgress);
|
||||||
|
|
||||||
|
element.textContent = current.toLocaleString("ru-RU");
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
} else {
|
||||||
|
element.textContent = target.toLocaleString("ru-RU");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/stats");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Ошибка загрузки статистики");
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await response.json();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
const booksEl = document.getElementById("stat-books");
|
||||||
|
const authorsEl = document.getElementById("stat-authors");
|
||||||
|
const genresEl = document.getElementById("stat-genres");
|
||||||
|
const usersEl = document.getElementById("stat-users");
|
||||||
|
|
||||||
|
if (booksEl) {
|
||||||
|
animateCounter(booksEl, stats.books, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (authorsEl) {
|
||||||
|
animateCounter(authorsEl, stats.authors, 1500);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (genresEl) {
|
||||||
|
animateCounter(genresEl, stats.genres, 1500);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (usersEl) {
|
||||||
|
animateCounter(usersEl, stats.users, 1500);
|
||||||
|
}
|
||||||
|
}, 450);
|
||||||
|
}, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки статистики:", error);
|
||||||
|
|
||||||
|
document.getElementById("stat-books").textContent = "—";
|
||||||
|
document.getElementById("stat-authors").textContent = "—";
|
||||||
|
document.getElementById("stat-genres").textContent = "—";
|
||||||
|
document.getElementById("stat-users").textContent = "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeStatCards() {
|
||||||
|
const cards = document.querySelectorAll(".stat-card");
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setTimeout(() => {
|
||||||
|
entry.target.classList.add("animate-fade-in");
|
||||||
|
entry.target.style.opacity = "1";
|
||||||
|
entry.target.style.transform = "translateY(0)";
|
||||||
|
}, index * 100);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 },
|
||||||
|
);
|
||||||
|
|
||||||
|
cards.forEach((card) => {
|
||||||
|
card.style.opacity = "0";
|
||||||
|
card.style.transform = "translateY(20px)";
|
||||||
|
card.style.transition = "opacity 0.5s ease, transform 0.5s ease";
|
||||||
|
observer.observe(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
loadStats();
|
||||||
|
observeStatCards();
|
||||||
|
});
|
||||||
@@ -19,7 +19,6 @@ nav ul li a {
|
|||||||
font-size: large;
|
font-size: large;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom checkbox styles */
|
|
||||||
.custom-checkbox {
|
.custom-checkbox {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -40,7 +39,7 @@ nav ul li a {
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
border: 2px solid #d1d5db; /* gray-300 */
|
border: 2px solid #d1d5db;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -48,11 +47,11 @@ nav ul li a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.custom-checkbox:hover input ~ .checkmark {
|
.custom-checkbox:hover input ~ .checkmark {
|
||||||
border-color: #6b7280; /* gray-500 */
|
border-color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-checkbox input:checked ~ .checkmark {
|
.custom-checkbox input:checked ~ .checkmark {
|
||||||
background-color: #6b7280; /* gray-500 */
|
background-color: #6b7280;
|
||||||
border-color: #6b7280;
|
border-color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,12 +113,29 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes shake {
|
@keyframes shake {
|
||||||
0%, 100% { transform: translateX(0); }
|
0%,
|
||||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
100% {
|
||||||
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
10%,
|
||||||
|
30%,
|
||||||
|
50%,
|
||||||
|
70%,
|
||||||
|
90% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
}
|
||||||
|
20%,
|
||||||
|
40%,
|
||||||
|
60%,
|
||||||
|
80% {
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#req-length, #req-upper, #req-lower, #req-digit {
|
#req-length,
|
||||||
|
#req-upper,
|
||||||
|
#req-lower,
|
||||||
|
#req-digit {
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +161,8 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#login-tab, #register-tab {
|
#login-tab,
|
||||||
|
#register-tab {
|
||||||
font-family: "Dited", sans-serif;
|
font-family: "Dited", sans-serif;
|
||||||
letter-spacing: 1.5px;
|
letter-spacing: 1.5px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -156,10 +173,73 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dropdownFade {
|
@keyframes dropdownFade {
|
||||||
from { opacity: 0; transform: translateY(-5px); }
|
from {
|
||||||
to { opacity: 1; transform: translateY(0); }
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#user-arrow.rotate-180 {
|
#user-arrow.rotate-180 {
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-soft {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-soft {
|
||||||
|
animation: pulse-soft 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bookSvg {
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in-up {
|
||||||
|
animation: fadeInUp 0.5s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card svg {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #374151 0%, #6b7280 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
<!-- templates/auth.html -->
|
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} {%
|
||||||
{% extends "base.html" %}
|
block content %}
|
||||||
|
|
||||||
{% block title %}LiB - Авторизация{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex flex-1 items-center justify-center p-4">
|
<div class="flex flex-1 items-center justify-center p-4">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<div class="flex border-b border-gray-200">
|
<div class="flex border-b border-gray-200">
|
||||||
<button type="button" id="login-tab" class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500">Вход</button>
|
<button
|
||||||
<button type="button" id="register-tab" class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600">Регистрация</button>
|
type="button"
|
||||||
|
id="login-tab"
|
||||||
|
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500"
|
||||||
|
>
|
||||||
|
Вход
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="register-tab"
|
||||||
|
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
Регистрация
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login-form" class="p-6">
|
<form id="login-form" class="p-6">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="login-username" class="block text-sm font-medium text-gray-700 mb-2">Имя пользователя</label>
|
<label
|
||||||
|
for="login-username"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Имя пользователя</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="login-username"
|
id="login-username"
|
||||||
@@ -24,9 +36,12 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="login-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
|
<label
|
||||||
|
for="login-password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Пароль</label
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -41,37 +56,77 @@
|
|||||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
onclick="togglePassword(this)"
|
onclick="togglePassword(this)"
|
||||||
>
|
>
|
||||||
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
class="eye-open w-5 h-5"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
class="eye-closed w-5 h-5 hidden"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<label class="custom-checkbox flex items-center text-sm text-gray-600">
|
<label
|
||||||
|
class="custom-checkbox flex items-center text-sm text-gray-600"
|
||||||
|
>
|
||||||
<input type="checkbox" id="remember-me" />
|
<input type="checkbox" id="remember-me" />
|
||||||
<span class="checkmark"></span>Запомнить меня
|
<span class="checkmark"></span>Запомнить меня
|
||||||
</label>
|
</label>
|
||||||
<a href="#" class="text-sm text-gray-500 hover:text-gray-700 transition">Забыли пароль?</a>
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-700 transition"
|
||||||
|
>Забыли пароль?</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div id="login-error" class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"></div>
|
id="login-error"
|
||||||
|
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
||||||
|
></div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
id="login-submit"
|
id="login-submit"
|
||||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
|
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
|
||||||
>Войти</button>
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
<form
|
||||||
<form id="register-form" class="p-6 hidden" onsubmit="return handleRegister(event)">
|
id="register-form"
|
||||||
|
class="p-6 hidden"
|
||||||
|
onsubmit="return handleRegister(event);"
|
||||||
|
>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="register-username" class="block text-sm font-medium text-gray-700 mb-2">Имя пользователя</label>
|
<label
|
||||||
|
for="register-username"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Имя пользователя</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="register-username"
|
id="register-username"
|
||||||
@@ -83,9 +138,12 @@
|
|||||||
maxlength="50"
|
maxlength="50"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="register-email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
<label
|
||||||
|
for="register-email"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Email</label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="register-email"
|
id="register-email"
|
||||||
@@ -95,9 +153,15 @@
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="register-fullname" class="block text-sm font-medium text-gray-700 mb-2">Полное имя <span class="text-gray-400">(необязательно)</span></label>
|
<label
|
||||||
|
for="register-fullname"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Полное имя
|
||||||
|
<span class="text-gray-400"
|
||||||
|
>(необязательно)</span
|
||||||
|
></label
|
||||||
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="register-fullname"
|
id="register-fullname"
|
||||||
@@ -107,9 +171,12 @@
|
|||||||
maxlength="100"
|
maxlength="100"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="register-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
|
<label
|
||||||
|
for="register-password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Пароль</label
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -126,25 +193,61 @@
|
|||||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
onclick="togglePassword(this)"
|
onclick="togglePassword(this)"
|
||||||
>
|
>
|
||||||
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
class="eye-open w-5 h-5"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
class="eye-closed w-5 h-5 hidden"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="h-1 w-full bg-gray-200 rounded-full overflow-hidden">
|
<div
|
||||||
<div id="password-strength-bar" class="h-full w-0 transition-all duration-300"></div>
|
class="h-1 w-full bg-gray-200 rounded-full overflow-hidden"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="password-strength-bar"
|
||||||
|
class="h-full w-0 transition-all duration-300"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p id="password-strength-text" class="text-xs mt-1 text-gray-500"></p>
|
<p
|
||||||
|
id="password-strength-text"
|
||||||
|
class="text-xs mt-1 text-gray-500"
|
||||||
|
></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="register-password-confirm" class="block text-sm font-medium text-gray-700 mb-2">Подтвердите пароль</label>
|
<label
|
||||||
|
for="register-password-confirm"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Подтвердите пароль</label
|
||||||
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
@@ -159,22 +262,55 @@
|
|||||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
onclick="togglePassword(this)"
|
onclick="togglePassword(this)"
|
||||||
>
|
>
|
||||||
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
class="eye-open w-5 h-5"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
|
class="eye-closed w-5 h-5 hidden"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||||
|
></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p id="password-match-error" class="text-xs mt-1 text-red-500 hidden">Пароли не совпадают</p>
|
<p
|
||||||
|
id="password-match-error"
|
||||||
|
class="text-xs mt-1 text-red-500 hidden"
|
||||||
|
>
|
||||||
|
Пароли не совпадают
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
<div id="register-error" class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"></div>
|
id="register-error"
|
||||||
|
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
||||||
<div id="register-success" class="hidden mb-4 p-3 bg-green-100 border border-green-300 text-green-700 rounded-lg text-sm"></div>
|
></div>
|
||||||
|
<div
|
||||||
|
id="register-success"
|
||||||
|
class="hidden mb-4 p-3 bg-green-100 border border-green-300 text-green-700 rounded-lg text-sm"
|
||||||
|
></div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
id="register-submit"
|
id="register-submit"
|
||||||
@@ -186,8 +322,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %} {% block scripts %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script type="text/javascript" src="/static/auth.js"></script>
|
<script type="text/javascript" src="/static/auth.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,66 +1,79 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}LiB - Главная{% endblock %} {% block
|
||||||
|
content %}
|
||||||
{% block title %}LiB - Главная{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="flex flex-1 mt-4 p-4">
|
<div class="flex flex-1 mt-4 p-4">
|
||||||
<aside class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96">
|
<aside
|
||||||
|
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Поиск</h2>
|
||||||
|
<div class="relative mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="book-search-input"
|
||||||
|
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||||
|
placeholder="Поиск книг (мин. 3 символа)..."
|
||||||
|
minlength="3"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
|
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h3 class="font-medium mb-2">Авторы</h3>
|
<h3 class="font-medium mb-2">Авторы</h3>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white" id="selected-authors-container">
|
<div
|
||||||
<input type="text" id="author-search-input" class="flex-grow outline-none bg-transparent" placeholder="Начните вводить..." />
|
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white min-h-[42px]"
|
||||||
|
id="selected-authors-container"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author-search-input"
|
||||||
|
class="flex-grow outline-none bg-transparent min-w-[100px]"
|
||||||
|
placeholder="Начните вводить..."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div id="author-dropdown" class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 hidden max-h-60 overflow-y-auto"></div>
|
<div
|
||||||
|
id="author-dropdown"
|
||||||
|
class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 hidden max-h-60 overflow-y-auto shadow-lg"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h3 class="font-medium mb-2">Жанры</h3>
|
<h3 class="font-medium mb-2">Жанры</h3>
|
||||||
<ul id="genres-list"></ul>
|
<ul id="genres-list" class="max-h-60 overflow-y-auto"></ul>
|
||||||
</div>
|
</div>
|
||||||
<button class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200">
|
<button
|
||||||
|
id="apply-filters-btn"
|
||||||
|
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 mb-2"
|
||||||
|
>
|
||||||
Применить фильтры
|
Применить фильтры
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
id="reset-filters-btn"
|
||||||
|
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200"
|
||||||
|
>
|
||||||
|
Сбросить фильтры
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="results-counter"
|
||||||
|
class="mt-4 text-center text-sm text-gray-500"
|
||||||
|
></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="flex-1">
|
<main class="flex-1">
|
||||||
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
|
<div id="books-container"></div>
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-bold mb-1">Product Title 1</h3>
|
|
||||||
<p class="text-gray-700 text-sm">
|
|
||||||
A short description of the product, highlighting its
|
|
||||||
key features and benefits.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-semibold text-gray-600">$29.99</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-bold mb-1">Product Title 2</h3>
|
|
||||||
<p class="text-gray-700 text-sm">
|
|
||||||
Another great product with amazing features. You'll
|
|
||||||
love it!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-semibold text-blue-600">$49.99</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-bold mb-1">Product Title 3</h3>
|
|
||||||
<p class="text-gray-700 text-sm">
|
|
||||||
This product is a must-have for every modern home.
|
|
||||||
High quality and durable.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span class="text-lg font-semibold text-gray-600">$19.99</span>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %} {% block scripts %}
|
||||||
|
|
||||||
{% block scripts %}
|
|
||||||
<script type="text/javascript" src="/static/books.js"></script>
|
<script type="text/javascript" src="/static/books.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {%
|
||||||
|
block content %}
|
||||||
|
<div class="flex flex-1 items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-4xl">
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div class="text-center py-8 border-b border-gray-200">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-800 mb-2">
|
||||||
|
Добро пожаловать в LiB
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500">Ваша персональная библиотека книг</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-8">
|
||||||
|
<div
|
||||||
|
class="flex flex-col lg:flex-row items-center justify-center gap-12"
|
||||||
|
>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg
|
||||||
|
id="bookSvg"
|
||||||
|
width="400"
|
||||||
|
height="500"
|
||||||
|
viewBox="0 0 200 250"
|
||||||
|
class="drop-shadow-lg"
|
||||||
|
></svg>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
|
<div
|
||||||
|
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 mx-auto text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="stat-books"
|
||||||
|
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||||
|
data-target="0"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 font-medium">
|
||||||
|
Книг
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 mx-auto text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="stat-authors"
|
||||||
|
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||||
|
data-target="0"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 font-medium">
|
||||||
|
Авторов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 mx-auto text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="stat-genres"
|
||||||
|
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||||
|
data-target="0"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 font-medium">
|
||||||
|
Жанров
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<svg
|
||||||
|
class="w-10 h-10 mx-auto text-gray-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
id="stat-users"
|
||||||
|
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
|
||||||
|
data-target="0"
|
||||||
|
>
|
||||||
|
0
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500 font-medium">
|
||||||
|
Пользователей
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="px-8 pb-8">
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<a
|
||||||
|
href="/books"
|
||||||
|
class="inline-flex items-center justify-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition duration-200 font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Смотреть книги
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/authors"
|
||||||
|
class="inline-flex items-center justify-center px-6 py-3 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition duration-200 font-medium shadow-sm hover:shadow-md transform hover:-translate-y-0.5"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Все авторы
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 text-center text-gray-400 text-sm">
|
||||||
|
<p>LiB — Библиотека. Создано с ❤️</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script type="text/javascript" src="/static/index.js"></script>
|
||||||
|
{% endblock %}
|
||||||
+2
-2
@@ -6,7 +6,6 @@ from sqlmodel import SQLModel
|
|||||||
|
|
||||||
from library_service.settings import POSTGRES_DATABASE_URL
|
from library_service.settings import POSTGRES_DATABASE_URL
|
||||||
|
|
||||||
print(POSTGRES_DATABASE_URL)
|
|
||||||
|
|
||||||
# this is the Alembic Config object, which provides
|
# this is the Alembic Config object, which provides
|
||||||
# access to the values within the .ini file in use.
|
# access to the values within the .ini file in use.
|
||||||
@@ -16,7 +15,8 @@ config.set_main_option("sqlalchemy.url", POSTGRES_DATABASE_URL)
|
|||||||
# Interpret the config file for Python logging.
|
# Interpret the config file for Python logging.
|
||||||
# This line sets up loggers basically.
|
# This line sets up loggers basically.
|
||||||
if config.config_file_name is not None:
|
if config.config_file_name is not None:
|
||||||
fileConfig(config.config_file_name)
|
if config.attributes.get("configure_logging", True):
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
# add your model's MetaData object here
|
# add your model's MetaData object here
|
||||||
# for 'autogenerate' support
|
# for 'autogenerate' support
|
||||||
|
|||||||
Reference in New Issue
Block a user