mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09b7cb17a5 | |||
| 3473c31f73 | |||
| a3203d713d | |||
| 961bf95af7 | |||
| 64a46645c5 | |||
| e7f2987eea | |||
| 85b531e6e1 | |||
| 345ff8f23f | |||
| e64d3da7f4 | |||
| 719631158d | |||
| f6ac03a869 | |||
| 16a043843a |
@@ -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,356 @@
|
|||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Конфигурация
|
||||||
|
USERNAME = "admin"
|
||||||
|
PASSWORD = "7WaVlcj8EWzEbbdab9kqRw"
|
||||||
|
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
|
||||||
|
|
||||||
|
print("\n📚 Создание авторов...")
|
||||||
|
authors_data = [
|
||||||
|
"Лев Толстой",
|
||||||
|
"Фёдор Достоевский",
|
||||||
|
"Антон Чехов",
|
||||||
|
"Александр Пушкин",
|
||||||
|
"Михаил Булгаков",
|
||||||
|
"Николай Гоголь",
|
||||||
|
"Иван Тургенев",
|
||||||
|
"Борис Пастернак",
|
||||||
|
"Михаил Лермонтов",
|
||||||
|
"Александр Солженицын",
|
||||||
|
"Максим Горький",
|
||||||
|
"Иван Бунин"
|
||||||
|
]
|
||||||
|
|
||||||
|
authors = {}
|
||||||
|
for name in authors_data:
|
||||||
|
author_id = api.create_author(name)
|
||||||
|
if author_id:
|
||||||
|
authors[name] = author_id
|
||||||
|
|
||||||
|
print("\n🏷️ Создание жанров...")
|
||||||
|
genres_data = [
|
||||||
|
"Роман",
|
||||||
|
"Повесть",
|
||||||
|
"Рассказ",
|
||||||
|
"Поэзия",
|
||||||
|
"Драма",
|
||||||
|
"Философская проза",
|
||||||
|
"Историческая проза",
|
||||||
|
"Сатира"
|
||||||
|
]
|
||||||
|
|
||||||
|
genres = {}
|
||||||
|
for name in genres_data:
|
||||||
|
genre_id = api.create_genre(name)
|
||||||
|
if genre_id:
|
||||||
|
genres[name] = genre_id
|
||||||
|
|
||||||
|
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()
|
||||||
+22
-11
@@ -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:
|
||||||
@@ -143,8 +147,8 @@ def seed_roles(session: Session) -> dict[str, Role]:
|
|||||||
"""Создаёт роли по умолчанию, если их нет."""
|
"""Создаёт роли по умолчанию, если их нет."""
|
||||||
default_roles = [
|
default_roles = [
|
||||||
{"name": "admin", "description": "Администратор системы"},
|
{"name": "admin", "description": "Администратор системы"},
|
||||||
{"name": "moderator", "description": "Модератор"},
|
{"name": "librarian", "description": "Библиотекарь"},
|
||||||
{"name": "user", "description": "Обычный пользователь"},
|
{"name": "member", "description": "Посетитель библиотеки"},
|
||||||
]
|
]
|
||||||
|
|
||||||
roles = {}
|
roles = {}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-12
@@ -1,31 +1,51 @@
|
|||||||
"""Основной модуль"""
|
"""Основной модуль"""
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic import command
|
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 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)
|
||||||
|
|
||||||
|
|
||||||
# Подключение маршрутов
|
# Подключение маршрутов
|
||||||
app.include_router(api_router)
|
app.include_router(api_router)
|
||||||
|
static_path = Path(__file__).parent / "static"
|
||||||
|
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
"""Модуль моделей"""
|
"""Модуль моделей"""
|
||||||
from .db import *
|
from .db import *
|
||||||
from .dto import *
|
from .dto import *
|
||||||
|
from .enums import *
|
||||||
@@ -5,6 +5,7 @@ from sqlmodel import Field, Relationship
|
|||||||
|
|
||||||
from library_service.models.dto.book import BookBase
|
from library_service.models.dto.book import BookBase
|
||||||
from library_service.models.db.links import AuthorBookLink, GenreBookLink
|
from library_service.models.db.links import AuthorBookLink, GenreBookLink
|
||||||
|
from library_service.models.enums import BookStatus
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .author import Author
|
from .author import Author
|
||||||
@@ -14,9 +15,11 @@ if TYPE_CHECKING:
|
|||||||
class Book(BookBase, table=True):
|
class Book(BookBase, table=True):
|
||||||
"""Модель книги в базе данных"""
|
"""Модель книги в базе данных"""
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||||
|
status: BookStatus = Field(default=BookStatus.ACTIVE)
|
||||||
authors: List["Author"] = Relationship(
|
authors: List["Author"] = Relationship(
|
||||||
back_populates="books", link_model=AuthorBookLink
|
back_populates="books", link_model=AuthorBookLink
|
||||||
)
|
)
|
||||||
genres: List["Genre"] = Relationship(
|
genres: List["Genre"] = Relationship(
|
||||||
back_populates="books", link_model=GenreBookLink
|
back_populates="books", link_model=GenreBookLink
|
||||||
)
|
)
|
||||||
|
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Модуль связей между сущностями в БД"""
|
"""Модуль связей между сущностями в БД"""
|
||||||
|
from datetime import datetime
|
||||||
from sqlmodel import SQLModel, Field
|
from sqlmodel import SQLModel, Field
|
||||||
|
|
||||||
|
|
||||||
@@ -22,3 +23,20 @@ class UserRoleLink(SQLModel, table=True):
|
|||||||
|
|
||||||
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
||||||
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
|
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class BookUserLink(SQLModel, table=True):
|
||||||
|
"""
|
||||||
|
Модель истории выдачи книг (Loan).
|
||||||
|
Связывает книгу и пользователя с фиксацией времени.
|
||||||
|
"""
|
||||||
|
__tablename__ = "book_loans"
|
||||||
|
|
||||||
|
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||||
|
|
||||||
|
book_id: int = Field(foreign_key="book.id")
|
||||||
|
user_id: int = Field(foreign_key="users.id")
|
||||||
|
|
||||||
|
borrowed_at: datetime = Field(default_factory=datetime.utcnow)
|
||||||
|
due_date: datetime
|
||||||
|
returned_at: datetime | None = Field(default=None)
|
||||||
@@ -16,5 +16,4 @@ class Role(RoleBase, table=True):
|
|||||||
|
|
||||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||||
|
|
||||||
# Связи
|
|
||||||
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
|
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
|
||||||
|
|||||||
@@ -26,3 +26,4 @@ class User(UserBase, table=True):
|
|||||||
|
|
||||||
# Связи
|
# Связи
|
||||||
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
|
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
|
||||||
|
loans: List["BookUserLink"] = Relationship(sa_relationship_kwargs={"cascade": "all, delete"})
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpda
|
|||||||
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
|
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
|
||||||
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
|
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
|
||||||
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
|
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
|
||||||
from .user import UserBase, UserCreate, UserLogin, UserRead, UserUpdate
|
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
|
||||||
|
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
|
||||||
from .token import Token, TokenData
|
from .token import Token, TokenData
|
||||||
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
|
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
|
||||||
BookWithAuthorsAndGenres, BookFilteredList)
|
BookWithAuthorsAndGenres, BookFilteredList, BookStatusUpdate, LoanWithBook)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AuthorBase",
|
"AuthorBase",
|
||||||
@@ -20,11 +21,24 @@ __all__ = [
|
|||||||
"BookRead",
|
"BookRead",
|
||||||
"BookList",
|
"BookList",
|
||||||
"BookFilteredList",
|
"BookFilteredList",
|
||||||
|
"BookStatusUpdate",
|
||||||
"GenreBase",
|
"GenreBase",
|
||||||
"GenreCreate",
|
"GenreCreate",
|
||||||
"GenreUpdate",
|
"GenreUpdate",
|
||||||
"GenreRead",
|
"GenreRead",
|
||||||
"GenreList",
|
"GenreList",
|
||||||
|
"LoanBase",
|
||||||
|
"LoanCreate",
|
||||||
|
"LoanUpdate",
|
||||||
|
"LoanRead",
|
||||||
|
"LoanList",
|
||||||
|
"LoanWithBook",
|
||||||
|
"UserBase",
|
||||||
|
"UserCreate",
|
||||||
|
"UserUpdate",
|
||||||
|
"UserRead",
|
||||||
|
"UserList",
|
||||||
|
"UserLogin",
|
||||||
"RoleBase",
|
"RoleBase",
|
||||||
"RoleCreate",
|
"RoleCreate",
|
||||||
"RoleUpdate",
|
"RoleUpdate",
|
||||||
@@ -32,9 +46,4 @@ __all__ = [
|
|||||||
"RoleList",
|
"RoleList",
|
||||||
"Token",
|
"Token",
|
||||||
"TokenData",
|
"TokenData",
|
||||||
"UserBase",
|
|
||||||
"UserCreate",
|
|
||||||
"UserRead",
|
|
||||||
"UserUpdate",
|
|
||||||
"UserLogin",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
"""Модуль DTO-моделей книг"""
|
"""Модуль DTO-моделей книг"""
|
||||||
from typing import List, TYPE_CHECKING
|
from typing import List
|
||||||
|
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
from library_service.models.enums import BookStatus
|
||||||
from .combined import BookWithAuthorsAndGenres
|
|
||||||
|
|
||||||
|
|
||||||
class BookBase(SQLModel):
|
class BookBase(SQLModel):
|
||||||
@@ -29,11 +28,13 @@ class BookUpdate(SQLModel):
|
|||||||
"""Модель книги для обновления"""
|
"""Модель книги для обновления"""
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
status: BookStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
class BookRead(BookBase):
|
class BookRead(BookBase):
|
||||||
"""Модель книги для чтения"""
|
"""Модель книги для чтения"""
|
||||||
id: int
|
id: int
|
||||||
|
status: BookStatus
|
||||||
|
|
||||||
|
|
||||||
class BookList(SQLModel):
|
class BookList(SQLModel):
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ from sqlmodel import SQLModel, Field
|
|||||||
from .author import AuthorRead
|
from .author import AuthorRead
|
||||||
from .genre import GenreRead
|
from .genre import GenreRead
|
||||||
from .book import BookRead
|
from .book import BookRead
|
||||||
|
from .loan import LoanRead
|
||||||
|
|
||||||
|
|
||||||
class AuthorWithBooks(SQLModel):
|
class AuthorWithBooks(SQLModel):
|
||||||
"""Модель автора с книгами"""
|
"""Модель автора с книгами"""
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
bio: str
|
|
||||||
books: List[BookRead] = Field(default_factory=list)
|
books: List[BookRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -51,3 +51,11 @@ class BookFilteredList(SQLModel):
|
|||||||
"""Список книг с фильтрацией"""
|
"""Список книг с фильтрацией"""
|
||||||
books: List[BookWithAuthorsAndGenres]
|
books: List[BookWithAuthorsAndGenres]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
class LoanWithBook(LoanRead):
|
||||||
|
"""Модель выдачи, включающая данные о книге"""
|
||||||
|
book: BookRead
|
||||||
|
|
||||||
|
class BookStatusUpdate(SQLModel):
|
||||||
|
"""Модель для ручного изменения статуса библиотекарем"""
|
||||||
|
status: str
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Модуль DTO-моделей для выдачи книг"""
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class LoanBase(SQLModel):
|
||||||
|
"""Базовая модель выдачи"""
|
||||||
|
book_id: int
|
||||||
|
user_id: int
|
||||||
|
due_date: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LoanCreate(LoanBase):
|
||||||
|
"""Модель для создания записи о выдаче"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LoanUpdate(SQLModel):
|
||||||
|
"""Модель для обновления записи о выдаче"""
|
||||||
|
returned_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoanRead(LoanBase):
|
||||||
|
"""Модель чтения записи о выдаче"""
|
||||||
|
id: int
|
||||||
|
borrowed_at: datetime
|
||||||
|
returned_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoanList(SQLModel):
|
||||||
|
"""Список выдач"""
|
||||||
|
loans: List[LoanRead]
|
||||||
|
total: int
|
||||||
@@ -59,3 +59,9 @@ class UserUpdate(SQLModel):
|
|||||||
email: EmailStr | None = None
|
email: EmailStr | None = None
|
||||||
full_name: str | None = None
|
full_name: str | None = None
|
||||||
password: str | None = None
|
password: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserList(SQLModel):
|
||||||
|
"""Список пользователей"""
|
||||||
|
users: List[UserRead]
|
||||||
|
total: int
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Модуль перечислений (Enums)"""
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
class BookStatus(str, Enum):
|
||||||
|
"""Статусы книги"""
|
||||||
|
ACTIVE = "active"
|
||||||
|
BORROWED = "borrowed"
|
||||||
|
RESERVED = "reserved"
|
||||||
|
RESTORATION = "restoration"
|
||||||
|
WRITTEN_OFF = "written_off"
|
||||||
@@ -11,9 +11,9 @@ from .misc import router as misc_router
|
|||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
|
|
||||||
# Подключение всех маршрутов
|
# Подключение всех маршрутов
|
||||||
api_router.include_router(auth_router)
|
|
||||||
api_router.include_router(authors_router)
|
|
||||||
api_router.include_router(books_router)
|
|
||||||
api_router.include_router(genres_router)
|
|
||||||
api_router.include_router(relationships_router)
|
|
||||||
api_router.include_router(misc_router)
|
api_router.include_router(misc_router)
|
||||||
|
api_router.include_router(auth_router, prefix="/api")
|
||||||
|
api_router.include_router(authors_router, prefix="/api")
|
||||||
|
api_router.include_router(books_router, prefix="/api")
|
||||||
|
api_router.include_router(genres_router, prefix="/api")
|
||||||
|
api_router.include_router(relationships_router, prefix="/api")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from fastapi.security import OAuth2PasswordRequestForm
|
|||||||
from sqlmodel import Session, select
|
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 Token, UserCreate, UserRead, UserUpdate
|
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList
|
||||||
from library_service.settings import get_session
|
from library_service.settings import get_session
|
||||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
|
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
|
||||||
RequireAuth, authenticate_user, get_password_hash,
|
RequireAuth, authenticate_user, get_password_hash,
|
||||||
@@ -44,13 +44,11 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создание пользователя
|
|
||||||
db_user = User(
|
db_user = User(
|
||||||
**user_data.model_dump(exclude={"password"}),
|
**user_data.model_dump(exclude={"password"}),
|
||||||
hashed_password=get_password_hash(user_data.password)
|
hashed_password=get_password_hash(user_data.password)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Назначение роли по умолчанию
|
|
||||||
default_role = session.exec(select(Role).where(Role.name == "user")).first()
|
default_role = session.exec(select(Role).where(Role.name == "user")).first()
|
||||||
if default_role:
|
if default_role:
|
||||||
db_user.roles.append(default_role)
|
db_user.roles.append(default_role)
|
||||||
@@ -138,7 +136,7 @@ def update_user_me(
|
|||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/users",
|
"/users",
|
||||||
response_model=list[UserRead],
|
response_model=UserList,
|
||||||
summary="Список пользователей",
|
summary="Список пользователей",
|
||||||
description="Получить список всех пользователей (только для админов)",
|
description="Получить список всех пользователей (только для админов)",
|
||||||
)
|
)
|
||||||
@@ -150,7 +148,106 @@ def read_users(
|
|||||||
):
|
):
|
||||||
"""Эндпоинт получения списка всех пользователей"""
|
"""Эндпоинт получения списка всех пользователей"""
|
||||||
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
||||||
return [
|
return UserList(
|
||||||
UserRead(**user.model_dump(), roles=[role.name for role in user.roles])
|
users=[UserRead(**user.model_dump()) for user in users],
|
||||||
for user in users
|
total=len(users),
|
||||||
]
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/users/{user_id}/roles/{role_name}",
|
||||||
|
response_model=UserRead,
|
||||||
|
summary="Назначить роль пользователю",
|
||||||
|
description="Добавить указанную роль пользователю",
|
||||||
|
)
|
||||||
|
def add_role_to_user(
|
||||||
|
user_id: int,
|
||||||
|
role_name: str,
|
||||||
|
admin: RequireAdmin,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Эндпоинт добавления роли пользователю"""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
role = session.exec(select(Role).where(Role.name == role_name)).first()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Role '{role_name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if role in user.roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="User already has this role",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.roles.append(role)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
|
||||||
|
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/users/{user_id}/roles/{role_name}",
|
||||||
|
response_model=UserRead,
|
||||||
|
summary="Удалить роль у пользователя",
|
||||||
|
description="Убрать указанную роль у пользователя",
|
||||||
|
)
|
||||||
|
def remove_role_from_user(
|
||||||
|
user_id: int,
|
||||||
|
role_name: str,
|
||||||
|
admin: RequireAdmin,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Эндпоинт удаления роли у пользователя"""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="User not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
role = session.exec(select(Role).where(Role.name == role_name)).first()
|
||||||
|
if not role:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Role '{role_name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
if role not in user.roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="User does not have this role",
|
||||||
|
)
|
||||||
|
|
||||||
|
user.roles.remove(role)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
|
||||||
|
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/roles",
|
||||||
|
response_model=RoleList,
|
||||||
|
summary="Получить список ролей",
|
||||||
|
description="Возвращает список ролей",
|
||||||
|
)
|
||||||
|
def get_roles(
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""Эндпоинт получения списа ролей"""
|
||||||
|
roles = session.exec(select(Role)).all()
|
||||||
|
return RoleList(
|
||||||
|
roles=[RoleRead(**role.model_dump()) for role in roles],
|
||||||
|
total=len(roles),
|
||||||
|
)
|
||||||
|
|||||||
@@ -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, Genre
|
||||||
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)
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from library_service.settings import get_session
|
|||||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||||
|
|
||||||
|
|
||||||
# Создание жанра
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/",
|
"/",
|
||||||
response_model=GenreRead,
|
response_model=GenreRead,
|
||||||
@@ -30,7 +29,6 @@ def create_genre(
|
|||||||
return GenreRead(**db_genre.model_dump())
|
return GenreRead(**db_genre.model_dump())
|
||||||
|
|
||||||
|
|
||||||
# Чтение жанров
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/",
|
"/",
|
||||||
response_model=GenreList,
|
response_model=GenreList,
|
||||||
@@ -45,7 +43,6 @@ def read_genres(session: Session = Depends(get_session)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# Чтение жанра с его книгами
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{genre_id}",
|
"/{genre_id}",
|
||||||
response_model=GenreWithBooks,
|
response_model=GenreWithBooks,
|
||||||
@@ -73,7 +70,6 @@ def get_genre(
|
|||||||
return GenreWithBooks(**genre_data)
|
return GenreWithBooks(**genre_data)
|
||||||
|
|
||||||
|
|
||||||
# Обновление жанра
|
|
||||||
@router.put(
|
@router.put(
|
||||||
"/{genre_id}",
|
"/{genre_id}",
|
||||||
response_model=GenreRead,
|
response_model=GenreRead,
|
||||||
@@ -100,7 +96,6 @@ def update_genre(
|
|||||||
return GenreRead(**db_genre.model_dump())
|
return GenreRead(**db_genre.model_dump())
|
||||||
|
|
||||||
|
|
||||||
# Удаление жанра
|
|
||||||
@router.delete(
|
@router.delete(
|
||||||
"/{genre_id}",
|
"/{genre_id}",
|
||||||
response_model=GenreRead,
|
response_model=GenreRead,
|
||||||
|
|||||||
@@ -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,20 +24,56 @@ 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):
|
||||||
"""Эндпоинт главной страницы"""
|
"""Эндпоинт главной страницы"""
|
||||||
return templates.TemplateResponse(request, "index.html", get_info(app))
|
return templates.TemplateResponse(request, "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/authors", include_in_schema=False)
|
||||||
|
async def authors(request: Request):
|
||||||
|
"""Эндпоинт страницы выбора автора"""
|
||||||
|
return templates.TemplateResponse(request, "authors.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/author/{author_id}", include_in_schema=False)
|
||||||
|
async def author(request: Request, author_id: int):
|
||||||
|
"""Эндпоинт страницы автора"""
|
||||||
|
return templates.TemplateResponse(request, "author.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/books", include_in_schema=False)
|
||||||
|
async def books(request: Request):
|
||||||
|
"""Эндпоинт страницы выбора книг"""
|
||||||
|
return templates.TemplateResponse(request, "books.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/book/{book_id}", include_in_schema=False)
|
||||||
|
async def book(request: Request, book_id: int):
|
||||||
|
"""Эндпоинт страницы книги"""
|
||||||
|
return templates.TemplateResponse(request, "book.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/auth", include_in_schema=False)
|
||||||
|
async def auth(request: Request):
|
||||||
|
"""Эндпоинт страницы авторизации"""
|
||||||
|
return templates.TemplateResponse(request, "auth.html")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/profile", include_in_schema=False)
|
||||||
|
async def profile(request: Request):
|
||||||
|
"""Эндпоинт страницы профиля"""
|
||||||
|
return templates.TemplateResponse(request, "profile.html")
|
||||||
|
|
||||||
|
|
||||||
@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))
|
||||||
|
|
||||||
@@ -54,23 +92,30 @@ async def favicon():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/static/{path:path}", include_in_schema=False)
|
|
||||||
async def serve_static(path: str):
|
|
||||||
"""Статические файлы"""
|
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
|
||||||
file_path = static_dir / path
|
|
||||||
|
|
||||||
if not file_path.is_file() or not file_path.is_relative_to(static_dir):
|
|
||||||
return JSONResponse(status_code=404, content={"error": "File not found"})
|
|
||||||
|
|
||||||
return FileResponse(file_path)
|
|
||||||
|
|
||||||
|
|
||||||
@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)
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
$(function () {
|
||||||
|
const $loginTab = $("#login-tab");
|
||||||
|
const $registerTab = $("#register-tab");
|
||||||
|
const $loginForm = $("#login-form");
|
||||||
|
const $registerForm = $("#register-form");
|
||||||
|
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
const $menuContainer = $("#user-menu-container");
|
||||||
|
|
||||||
|
function switchToLogin() {
|
||||||
|
$loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
||||||
|
$registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
||||||
|
$loginForm.removeClass("hidden"); $registerForm.addClass("hidden");
|
||||||
|
history.replaceState(null, "", "/auth#login");
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToRegister() {
|
||||||
|
$registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
||||||
|
$loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
||||||
|
$registerForm.removeClass("hidden"); $loginForm.addClass("hidden");
|
||||||
|
history.replaceState(null, "", "/auth#register");
|
||||||
|
}
|
||||||
|
|
||||||
|
$loginTab.on("click", switchToLogin);
|
||||||
|
$registerTab.on("click", switchToRegister);
|
||||||
|
|
||||||
|
$("body").on("click", ".toggle-password", function () {
|
||||||
|
const $btn = $(this);
|
||||||
|
const $input = $btn.siblings("input");
|
||||||
|
const $eyeOpen = $btn.find(".eye-open");
|
||||||
|
const $eyeClosed = $btn.find(".eye-closed");
|
||||||
|
|
||||||
|
if ($input.attr("type") === "password") {
|
||||||
|
$input.attr("type", "text");
|
||||||
|
$eyeOpen.addClass("hidden");
|
||||||
|
$eyeClosed.removeClass("hidden");
|
||||||
|
} else {
|
||||||
|
$input.attr("type", "password");
|
||||||
|
$eyeOpen.removeClass("hidden");
|
||||||
|
$eyeClosed.addClass("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#register-password").on("input", function () {
|
||||||
|
const password = $(this).val();
|
||||||
|
let strength = 0;
|
||||||
|
if (password.length >= 8) strength++;
|
||||||
|
if (password.length >= 12) strength++;
|
||||||
|
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||||
|
if (/\d/.test(password)) strength++;
|
||||||
|
if (/[^a-zA-Z0-9]/.test(password)) strength++;
|
||||||
|
|
||||||
|
const levels = [
|
||||||
|
{ width: "0%", color: "", text: "" },
|
||||||
|
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
|
||||||
|
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
|
||||||
|
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
|
||||||
|
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
|
||||||
|
{ width: "100%", color: "bg-green-500", text: "Отличный" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const level = levels[strength];
|
||||||
|
const $bar = $("#password-strength-bar");
|
||||||
|
|
||||||
|
$bar.css("width", level.width);
|
||||||
|
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
|
||||||
|
$("#password-strength-text").text(level.text);
|
||||||
|
|
||||||
|
checkPasswordMatch();
|
||||||
|
});
|
||||||
|
|
||||||
|
function checkPasswordMatch() {
|
||||||
|
const password = $("#register-password").val();
|
||||||
|
const confirm = $("#register-password-confirm").val();
|
||||||
|
const $error = $("#password-match-error");
|
||||||
|
|
||||||
|
if (confirm && password !== confirm) {
|
||||||
|
$error.removeClass("hidden");
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
$error.addClass("hidden");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#register-password-confirm").on("input", checkPasswordMatch);
|
||||||
|
|
||||||
|
$loginForm.on("submit", async function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const $errorDiv = $("#login-error");
|
||||||
|
const $submitBtn = $("#login-submit");
|
||||||
|
const username = $("#login-username").val();
|
||||||
|
const password = $("#login-password").val();
|
||||||
|
|
||||||
|
$errorDiv.addClass("hidden");
|
||||||
|
$submitBtn.prop("disabled", true).text("Вход...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append("username", username);
|
||||||
|
formData.append("password", password);
|
||||||
|
|
||||||
|
const response = await fetch("/api/auth/token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formData.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
localStorage.setItem("access_token", data.access_token);
|
||||||
|
if (data.refresh_token) {
|
||||||
|
localStorage.setItem("refresh_token", data.refresh_token);
|
||||||
|
}
|
||||||
|
window.location.href = "/";
|
||||||
|
} else {
|
||||||
|
$errorDiv.text(data.detail || "Неверное имя пользователя или пароль");
|
||||||
|
$errorDiv.removeClass("hidden");
|
||||||
|
$submitBtn.prop("disabled", false).text("Войти");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login error:", error);
|
||||||
|
$errorDiv.text("Ошибка соединения с сервером");
|
||||||
|
$errorDiv.removeClass("hidden");
|
||||||
|
$submitBtn.prop("disabled", false).text("Войти");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$registerForm.on("submit", async function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const $errorDiv = $("#register-error");
|
||||||
|
const $successDiv = $("#register-success");
|
||||||
|
const $submitBtn = $("#register-submit");
|
||||||
|
|
||||||
|
if (!checkPasswordMatch()) {
|
||||||
|
$errorDiv.text("Пароли не совпадают").removeClass("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userData = {
|
||||||
|
username: $("#register-username").val(),
|
||||||
|
email: $("#register-email").val(),
|
||||||
|
full_name: $("#register-fullname").val() || null,
|
||||||
|
password: $("#register-password").val(),
|
||||||
|
};
|
||||||
|
|
||||||
|
$errorDiv.addClass("hidden");
|
||||||
|
$successDiv.addClass("hidden");
|
||||||
|
$submitBtn.prop("disabled", true).text("Регистрация...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(userData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
$successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden");
|
||||||
|
setTimeout(() => {
|
||||||
|
$("#login-username").val(userData.username);
|
||||||
|
switchToLogin();
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
let errorMessage = data.detail;
|
||||||
|
if (Array.isArray(data.detail)) {
|
||||||
|
errorMessage = data.detail.map((err) => err.msg).join(". ");
|
||||||
|
}
|
||||||
|
$errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Register error:", error);
|
||||||
|
$errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
|
||||||
|
} finally {
|
||||||
|
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && isDropdownOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", function () {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function updateUserAvatar(email) {
|
||||||
|
if (!email) return;
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
const avatarImg = document.getElementById('user-avatar');
|
||||||
|
if (avatarImg) { avatarImg.src = avatarUrl; }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.hash === "#register") { switchToRegister(); }
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
showGuest();
|
||||||
|
} else {
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
showUser(user);
|
||||||
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
|
document.getElementById('user-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('guest-link').classList.add('hidden');
|
||||||
|
if (window.location.pathname === "/auth") { window.location.href = "/"; }
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
showGuest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
$(document).ready(() => {
|
||||||
|
const pathParts = window.location.pathname.split("/");
|
||||||
|
const authorId = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
|
if (!authorId || isNaN(authorId)) {
|
||||||
|
showErrorState("Некорректный ID автора");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAuthor(authorId);
|
||||||
|
|
||||||
|
function loadAuthor(id) {
|
||||||
|
showLoadingState();
|
||||||
|
|
||||||
|
fetch(`/api/authors/${id}`)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error("Автор не найден");
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((author) => {
|
||||||
|
renderAuthor(author);
|
||||||
|
renderBooks(author.books);
|
||||||
|
document.title = `LiB - ${author.name}`;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error loading author:", error);
|
||||||
|
showErrorState(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthor(author) {
|
||||||
|
const $card = $("#author-card");
|
||||||
|
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||||
|
const booksCount = author.books ? author.books.length : 0;
|
||||||
|
const booksWord = getWordForm(booksCount, ["книга", "книги", "книг"]);
|
||||||
|
|
||||||
|
$card.html(`
|
||||||
|
<div class="flex items-start">
|
||||||
|
<!-- Аватар -->
|
||||||
|
<div class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0">
|
||||||
|
${firstLetter}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Информация -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(author.name)}</h1>
|
||||||
|
<span class="text-sm text-gray-500">ID: ${author.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center text-gray-600 mb-4">
|
||||||
|
<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"/>
|
||||||
|
</svg>
|
||||||
|
<span>${booksCount} ${booksWord} в библиотеке</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопка назад -->
|
||||||
|
<a href="/authors" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1" 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>
|
||||||
|
Вернуться к списку авторов
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBooks(books) {
|
||||||
|
const $container = $("#books-container");
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
if (!books || books.length === 0) {
|
||||||
|
$container.html(`
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<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>
|
||||||
|
<p class="text-gray-500">У этого автора пока нет книг в библиотеке</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $grid = $('<div class="space-y-4"></div>');
|
||||||
|
|
||||||
|
books.forEach((book) => {
|
||||||
|
const $bookCard = $(`
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md 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-semibold text-gray-900 hover:text-blue-600 transition-colors mb-2">
|
||||||
|
${escapeHtml(book.title)}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 text-sm line-clamp-3">
|
||||||
|
${escapeHtml(book.description || "Описание отсутствует")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0" 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$grid.append($bookCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.append($grid);
|
||||||
|
|
||||||
|
$container.off("click", ".book-card").on("click", ".book-card", function () {
|
||||||
|
const bookId = $(this).data("id");
|
||||||
|
window.location.href = `/book/${bookId}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoadingState() {
|
||||||
|
const $authorCard = $("#author-card");
|
||||||
|
const $booksContainer = $("#books-container");
|
||||||
|
|
||||||
|
$authorCard.html(`
|
||||||
|
<div class="flex items-start animate-pulse">
|
||||||
|
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$booksContainer.html(`
|
||||||
|
<div class="space-y-4">
|
||||||
|
${Array(3)
|
||||||
|
.fill()
|
||||||
|
.map(
|
||||||
|
() => `
|
||||||
|
<div class="border border-gray-200 rounded-lg p-4 animate-pulse">
|
||||||
|
<div class="h-5 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-full mb-1"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorState(message) {
|
||||||
|
const $authorCard = $("#author-card");
|
||||||
|
const $booksSection = $("#books-section");
|
||||||
|
|
||||||
|
$booksSection.hide();
|
||||||
|
|
||||||
|
$authorCard.html(`
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="mx-auto h-16 w-16 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-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||||
|
<p class="text-gray-500 mb-6">Не удалось загрузить информацию об авторе</p>
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
<a href="/authors" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||||
|
К списку авторов
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$("#retry-btn").on("click", function () {
|
||||||
|
$booksSection.show();
|
||||||
|
loadAuthor(authorId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWordForm(number, forms) {
|
||||||
|
const cases = [2, 0, 1, 1, 1, 2];
|
||||||
|
const index =
|
||||||
|
number % 100 > 4 && number % 100 < 20
|
||||||
|
? 2
|
||||||
|
: cases[Math.min(number % 10, 5)];
|
||||||
|
return forms[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && isDropdownOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", function () {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserAvatar(email) {
|
||||||
|
if (!email) return;
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
const avatarImg = document.getElementById("user-avatar");
|
||||||
|
if (avatarImg) {
|
||||||
|
avatarImg.src = avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
showGuest();
|
||||||
|
} else {
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
showUser(user);
|
||||||
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
|
document.getElementById("user-btn").classList.remove("hidden");
|
||||||
|
document.getElementById("guest-link").classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
showGuest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
$(document).ready(() => {
|
||||||
|
let allAuthors = [];
|
||||||
|
let filteredAuthors = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
let pageSize = 12;
|
||||||
|
let currentSort = "name_asc";
|
||||||
|
|
||||||
|
loadAuthors();
|
||||||
|
|
||||||
|
function loadAuthors() {
|
||||||
|
showLoadingState();
|
||||||
|
|
||||||
|
fetch("/api/authors")
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
allAuthors = data.authors;
|
||||||
|
applyFiltersAndSort();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error loading authors:", error);
|
||||||
|
showErrorState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFiltersAndSort() {
|
||||||
|
const searchQuery = $("#author-search-input").val().trim().toLowerCase();
|
||||||
|
|
||||||
|
filteredAuthors = allAuthors.filter((author) =>
|
||||||
|
author.name.toLowerCase().includes(searchQuery)
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredAuthors.sort((a, b) => {
|
||||||
|
const nameA = a.name.toLowerCase();
|
||||||
|
const nameB = b.name.toLowerCase();
|
||||||
|
|
||||||
|
if (currentSort === "name_asc") {
|
||||||
|
return nameA.localeCompare(nameB, "ru");
|
||||||
|
} else {
|
||||||
|
return nameB.localeCompare(nameA, "ru");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateResultsCounter();
|
||||||
|
|
||||||
|
renderAuthors();
|
||||||
|
renderPagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateResultsCounter() {
|
||||||
|
const $counter = $("#results-counter");
|
||||||
|
const total = filteredAuthors.length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
$counter.text("Авторы не найдены");
|
||||||
|
} else {
|
||||||
|
const wordForm = getWordForm(total, ["автор", "автора", "авторов"]);
|
||||||
|
$counter.text(`Найдено: ${total} ${wordForm}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWordForm(number, forms) {
|
||||||
|
const cases = [2, 0, 1, 1, 1, 2];
|
||||||
|
const index =
|
||||||
|
number % 100 > 4 && number % 100 < 20
|
||||||
|
? 2
|
||||||
|
: cases[Math.min(number % 10, 5)];
|
||||||
|
return forms[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthors() {
|
||||||
|
const $container = $("#authors-container");
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
if (filteredAuthors.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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Авторы не найдены</h3>
|
||||||
|
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
|
const endIndex = startIndex + pageSize;
|
||||||
|
const pageAuthors = filteredAuthors.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const $grid = $('<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>');
|
||||||
|
|
||||||
|
pageAuthors.forEach((author) => {
|
||||||
|
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const $authorCard = $(`
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer author-card" data-id="${author.id}">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-12 h-12 bg-gray-500 text-white rounded-full flex items-center justify-center text-xl font-bold mr-4">
|
||||||
|
${firstLetter}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
|
||||||
|
${escapeHtml(author.name)}
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">ID: ${author.id}</p>
|
||||||
|
</div>
|
||||||
|
<svg class="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="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$grid.append($authorCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.append($grid);
|
||||||
|
|
||||||
|
$container.off("click", ".author-card").on("click", ".author-card", function () {
|
||||||
|
const authorId = $(this).data("id");
|
||||||
|
window.location.href = `/author/${authorId}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination() {
|
||||||
|
const $paginationContainer = $("#pagination-container");
|
||||||
|
$paginationContainer.empty();
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(filteredAuthors.length / pageSize);
|
||||||
|
|
||||||
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
|
const $pagination = $(`
|
||||||
|
<div 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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$paginationContainer.append($pagination);
|
||||||
|
|
||||||
|
$paginationContainer.find("#prev-page").on("click", function () {
|
||||||
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
|
renderAuthors();
|
||||||
|
renderPagination();
|
||||||
|
scrollToTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$paginationContainer.find("#next-page").on("click", function () {
|
||||||
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
|
renderAuthors();
|
||||||
|
renderPagination();
|
||||||
|
scrollToTop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$paginationContainer.find(".page-btn").on("click", function () {
|
||||||
|
const page = parseInt($(this).data("page"));
|
||||||
|
if (page !== currentPage) {
|
||||||
|
currentPage = page;
|
||||||
|
renderAuthors();
|
||||||
|
renderPagination();
|
||||||
|
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 = $("#authors-container");
|
||||||
|
$container.html(`
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
${Array(6)
|
||||||
|
.fill()
|
||||||
|
.map(
|
||||||
|
() => `
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorState() {
|
||||||
|
const $container = $("#authors-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", loadAuthors);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeFilters() {
|
||||||
|
const $authorSearch = $("#author-search-input");
|
||||||
|
const $resetBtn = $("#reset-filters-btn");
|
||||||
|
const $sortRadios = $('input[name="sort"]');
|
||||||
|
|
||||||
|
let searchTimeout;
|
||||||
|
$authorSearch.on("input", function () {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentPage = 1;
|
||||||
|
applyFiltersAndSort();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
$authorSearch.on("keypress", function (e) {
|
||||||
|
if (e.which === 13) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
currentPage = 1;
|
||||||
|
applyFiltersAndSort();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$sortRadios.on("change", function () {
|
||||||
|
currentSort = $(this).val();
|
||||||
|
currentPage = 1;
|
||||||
|
applyFiltersAndSort();
|
||||||
|
});
|
||||||
|
|
||||||
|
$resetBtn.on("click", function () {
|
||||||
|
$authorSearch.val("");
|
||||||
|
$('input[name="sort"][value="name_asc"]').prop("checked", true);
|
||||||
|
currentSort = "name_asc";
|
||||||
|
currentPage = 1;
|
||||||
|
applyFiltersAndSort();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeFilters();
|
||||||
|
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && isDropdownOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", function () {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserAvatar(email) {
|
||||||
|
if (!email) return;
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
const avatarImg = document.getElementById("user-avatar");
|
||||||
|
if (avatarImg) {
|
||||||
|
avatarImg.src = avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
showGuest();
|
||||||
|
} else {
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
showUser(user);
|
||||||
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
|
document.getElementById("user-btn").classList.remove("hidden");
|
||||||
|
document.getElementById("guest-link").classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
showGuest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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 |
@@ -0,0 +1,321 @@
|
|||||||
|
$(document).ready(() => {
|
||||||
|
const pathParts = window.location.pathname.split("/");
|
||||||
|
const bookId = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
|
if (!bookId || isNaN(bookId)) {
|
||||||
|
showErrorState("Некорректный ID книги");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBook(bookId);
|
||||||
|
|
||||||
|
function loadBook(id) {
|
||||||
|
showLoadingState();
|
||||||
|
|
||||||
|
fetch(`/api/books/${id}`)
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new Error("Книга не найдена");
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then((book) => {
|
||||||
|
renderBook(book);
|
||||||
|
renderAuthors(book.authors);
|
||||||
|
renderGenres(book.genres);
|
||||||
|
document.title = `LiB - ${book.title}`;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error loading book:", error);
|
||||||
|
showErrorState(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBook(book) {
|
||||||
|
const $card = $("#book-card");
|
||||||
|
const authorsText = book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||||
|
|
||||||
|
$card.html(`
|
||||||
|
<div class="flex flex-col md:flex-row items-start">
|
||||||
|
<!-- Иконка книги -->
|
||||||
|
<div class="w-32 h-40 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center mb-4 md:mb-0 md:mr-6 flex-shrink-0 shadow-md">
|
||||||
|
<svg class="w-16 h-16 text-white" 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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Информация о книге -->
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(book.title)}</h1>
|
||||||
|
<span class="text-sm text-gray-500 ml-4">ID: ${book.id}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-lg text-gray-600 mb-4">
|
||||||
|
${escapeHtml(authorsText)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="prose prose-gray max-w-none mb-6">
|
||||||
|
<p class="text-gray-700 leading-relaxed">
|
||||||
|
${escapeHtml(book.description || "Описание отсутствует")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Кнопка назад -->
|
||||||
|
<a href="/books" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-1" 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>
|
||||||
|
Вернуться к списку книг
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthors(authors) {
|
||||||
|
const $container = $("#authors-container");
|
||||||
|
const $section = $("#authors-section");
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
if (!authors || authors.length === 0) {
|
||||||
|
$section.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $grid = $('<div class="flex flex-wrap gap-3"></div>');
|
||||||
|
|
||||||
|
authors.forEach((author) => {
|
||||||
|
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const $authorCard = $(`
|
||||||
|
<a href="/author/${author.id}" class="flex items-center bg-gray-50 hover:bg-gray-100 rounded-lg p-3 transition-colors duration-200 border border-gray-200">
|
||||||
|
<div class="w-10 h-10 bg-gray-500 text-white rounded-full flex items-center justify-center text-lg font-bold mr-3">
|
||||||
|
${firstLetter}
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-900 font-medium">${escapeHtml(author.name)}</span>
|
||||||
|
<svg class="w-4 h-4 text-gray-400 ml-2" 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>
|
||||||
|
</a>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$grid.append($authorCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.append($grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGenres(genres) {
|
||||||
|
const $container = $("#genres-container");
|
||||||
|
const $section = $("#genres-section");
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
if (!genres || genres.length === 0) {
|
||||||
|
$section.hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $grid = $('<div class="flex flex-wrap gap-2"></div>');
|
||||||
|
|
||||||
|
genres.forEach((genre) => {
|
||||||
|
const $genreTag = $(`
|
||||||
|
<a href="/books?genre_id=${genre.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-full transition-colors duration-200">
|
||||||
|
<svg class="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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"/>
|
||||||
|
</svg>
|
||||||
|
${escapeHtml(genre.name)}
|
||||||
|
</a>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$grid.append($genreTag);
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.append($grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoadingState() {
|
||||||
|
const $bookCard = $("#book-card");
|
||||||
|
const $authorsContainer = $("#authors-container");
|
||||||
|
const $genresContainer = $("#genres-container");
|
||||||
|
|
||||||
|
$bookCard.html(`
|
||||||
|
<div class="flex flex-col md:flex-row items-start animate-pulse">
|
||||||
|
<div class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||||
|
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||||
|
<div class="space-y-2 mb-6">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$authorsContainer.html(`
|
||||||
|
<div class="flex gap-3 animate-pulse">
|
||||||
|
<div class="flex items-center bg-gray-100 rounded-lg p-3">
|
||||||
|
<div class="w-10 h-10 bg-gray-200 rounded-full mr-3"></div>
|
||||||
|
<div class="h-5 bg-gray-200 rounded w-24"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$genresContainer.html(`
|
||||||
|
<div class="flex gap-2 animate-pulse">
|
||||||
|
<div class="h-10 bg-gray-200 rounded-full w-24"></div>
|
||||||
|
<div class="h-10 bg-gray-200 rounded-full w-32"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorState(message) {
|
||||||
|
const $bookCard = $("#book-card");
|
||||||
|
const $authorsSection = $("#authors-section");
|
||||||
|
const $genresSection = $("#genres-section");
|
||||||
|
|
||||||
|
$authorsSection.hide();
|
||||||
|
$genresSection.hide();
|
||||||
|
|
||||||
|
$bookCard.html(`
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="mx-auto h-16 w-16 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-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||||
|
<p class="text-gray-500 mb-6">Не удалось загрузить информацию о книге</p>
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
<a href="/books" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||||
|
К списку книг
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$("#retry-btn").on("click", function () {
|
||||||
|
$authorsSection.show();
|
||||||
|
$genresSection.show();
|
||||||
|
loadBook(bookId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && isDropdownOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", function () {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserAvatar(email) {
|
||||||
|
if (!email) return;
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
const avatarImg = document.getElementById("user-avatar");
|
||||||
|
if (avatarImg) {
|
||||||
|
avatarImg.src = avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
showGuest();
|
||||||
|
} else {
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
showUser(user);
|
||||||
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
|
document.getElementById("user-btn").classList.remove("hidden");
|
||||||
|
document.getElementById("guest-link").classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
showGuest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,534 @@
|
|||||||
|
$(document).ready(() => {
|
||||||
|
let selectedAuthors = new Map();
|
||||||
|
let selectedGenres = new Map();
|
||||||
|
let currentPage = 1;
|
||||||
|
let pageSize = 20;
|
||||||
|
let totalBooks = 0;
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetch("/api/authors").then((response) => response.json()),
|
||||||
|
fetch("/api/genres").then((response) => response.json()),
|
||||||
|
])
|
||||||
|
.then(([authorsData, genresData]) => {
|
||||||
|
const $dropdown = $("#author-dropdown");
|
||||||
|
authorsData.authors.forEach((author) => {
|
||||||
|
$("<div>")
|
||||||
|
.addClass("p-2 hover:bg-gray-100 cursor-pointer author-item")
|
||||||
|
.attr("data-id", author.id)
|
||||||
|
.attr("data-name", author.name)
|
||||||
|
.text(author.name)
|
||||||
|
.appendTo($dropdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
const $list = $("#genres-list");
|
||||||
|
genresData.genres.forEach((genre) => {
|
||||||
|
$("<li>")
|
||||||
|
.addClass("mb-1")
|
||||||
|
.html(
|
||||||
|
`<label class="custom-checkbox flex items-center">
|
||||||
|
<input type="checkbox" data-id="${genre.id}" data-name="${genre.name}" />
|
||||||
|
<span class="checkmark"></span>
|
||||||
|
${genre.name}
|
||||||
|
</label>`,
|
||||||
|
)
|
||||||
|
.appendTo($list);
|
||||||
|
});
|
||||||
|
|
||||||
|
initializeAuthorDropdown();
|
||||||
|
initializeFilters();
|
||||||
|
|
||||||
|
loadBooks();
|
||||||
|
})
|
||||||
|
.catch((error) => console.error("Error loading data:", error));
|
||||||
|
|
||||||
|
function loadBooks() {
|
||||||
|
const searchQuery = $("#book-search-input").val().trim();
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `/book/${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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (query.length >= 3 || query.length === 0) {
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
currentPage = 1;
|
||||||
|
loadBooks();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$bookSearch.on("keypress", function (e) {
|
||||||
|
if (e.which === 13) {
|
||||||
|
clearTimeout(searchTimeout);
|
||||||
|
currentPage = 1;
|
||||||
|
loadBooks();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && isDropdownOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", function () {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserAvatar(email) {
|
||||||
|
if (!email) return;
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
const avatarImg = document.getElementById("user-avatar");
|
||||||
|
if (avatarImg) {
|
||||||
|
avatarImg.src = avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
showGuest();
|
||||||
|
} else {
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
showUser(user);
|
||||||
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
|
document.getElementById("user-btn").classList.remove("hidden");
|
||||||
|
document.getElementById("guest-link").classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
showGuest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
const $svg = $("#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)
|
||||||
|
.attr("x", bookX)
|
||||||
|
.attr("y", bookY)
|
||||||
|
.attr("width", bookWidth)
|
||||||
|
.attr("height", bookHeight)
|
||||||
|
.attr("fill", "#374151")
|
||||||
|
.attr("rx", "4");
|
||||||
|
$svg.append(book);
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < lineCount; i++) {
|
||||||
|
const line = document.createElementNS(NS, "rect");
|
||||||
|
$(line).attr("fill", "#ffffff").attr("rx", "1");
|
||||||
|
$svg.append(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
|
||||||
|
.attr("x", line.currentX)
|
||||||
|
.attr("y", centerY - line.height / 2)
|
||||||
|
.attr("width", line.width)
|
||||||
|
.attr("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.text(current.toLocaleString("ru-RU"));
|
||||||
|
|
||||||
|
if (progress < 1) {
|
||||||
|
requestAnimationFrame(update);
|
||||||
|
} else {
|
||||||
|
$element.text(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 = $("#stat-books");
|
||||||
|
const $authorsEl = $("#stat-authors");
|
||||||
|
const $genresEl = $("#stat-genres");
|
||||||
|
const $usersEl = $("#stat-users");
|
||||||
|
|
||||||
|
if ($booksEl.length) {
|
||||||
|
animateCounter($booksEl, stats.books, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if ($authorsEl.length) {
|
||||||
|
animateCounter($authorsEl, stats.authors, 1500);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if ($genresEl.length) {
|
||||||
|
animateCounter($genresEl, stats.genres, 1500);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if ($usersEl.length) {
|
||||||
|
animateCounter($usersEl, stats.users, 1500);
|
||||||
|
}
|
||||||
|
}, 450);
|
||||||
|
}, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Ошибка загрузки статистики:", error);
|
||||||
|
|
||||||
|
$("#stat-books").text("—");
|
||||||
|
$("#stat-authors").text("—");
|
||||||
|
$("#stat-genres").text("—");
|
||||||
|
$("#stat-users").text("—");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeStatCards() {
|
||||||
|
const $cards = $(".stat-card");
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
setTimeout(() => {
|
||||||
|
$(entry.target)
|
||||||
|
.addClass("animate-fade-in")
|
||||||
|
.css({
|
||||||
|
opacity: "1",
|
||||||
|
transform: "translateY(0)",
|
||||||
|
});
|
||||||
|
}, index * 100);
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ threshold: 0.1 }
|
||||||
|
);
|
||||||
|
|
||||||
|
$cards.each((index, card) => {
|
||||||
|
$(card).css({
|
||||||
|
opacity: "0",
|
||||||
|
transform: "translateY(20px)",
|
||||||
|
transition: "opacity 0.5s ease, transform 0.5s ease",
|
||||||
|
});
|
||||||
|
observer.observe(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(() => {
|
||||||
|
loadStats();
|
||||||
|
observeStatCards();
|
||||||
|
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && isDropdownOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", function () {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserAvatar(email) {
|
||||||
|
if (!email) return;
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
const avatarImg = document.getElementById("user-avatar");
|
||||||
|
if (avatarImg) {
|
||||||
|
avatarImg.src = avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
showGuest();
|
||||||
|
} else {
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
showUser(user);
|
||||||
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
|
document.getElementById("user-btn").classList.remove("hidden");
|
||||||
|
document.getElementById("guest-link").classList.add("hidden");
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
showGuest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
$(document).ready(() => {
|
||||||
|
let currentUser = null;
|
||||||
|
let allRoles = [];
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProfile();
|
||||||
|
|
||||||
|
function loadProfile() {
|
||||||
|
showLoadingState();
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
}).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}),
|
||||||
|
fetch("/api/auth/roles", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
}).then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
return { roles: [] };
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
.then(([user, rolesData]) => {
|
||||||
|
currentUser = user;
|
||||||
|
allRoles = rolesData.roles || [];
|
||||||
|
renderProfile(user);
|
||||||
|
renderAccountInfo(user);
|
||||||
|
renderRoles(user.roles, allRoles);
|
||||||
|
renderActions();
|
||||||
|
document.title = `LiB - ${user.full_name || user.username}`;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Error loading profile:", error);
|
||||||
|
if (error.message === "Unauthorized") {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
} else {
|
||||||
|
showErrorState(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfile(user) {
|
||||||
|
const $card = $("#profile-card");
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const emailHash = sha256(user.email.trim().toLowerCase());
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
|
||||||
|
$card.html(`
|
||||||
|
<div class="flex flex-col sm:flex-row items-center sm:items-start">
|
||||||
|
<!-- Аватар -->
|
||||||
|
<div class="relative mb-4 sm:mb-0 sm:mr-6">
|
||||||
|
<img src="${avatarUrl}" alt="Аватар"
|
||||||
|
class="w-24 h-24 rounded-full object-cover border-4 border-gray-200"
|
||||||
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||||
|
<div class="w-24 h-24 bg-gray-500 text-white rounded-full items-center justify-center text-4xl font-bold hidden">
|
||||||
|
${firstLetter}
|
||||||
|
</div>
|
||||||
|
<!-- Статус верификации -->
|
||||||
|
${user.is_verified ? `
|
||||||
|
<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1" title="Подтверждённый аккаунт">
|
||||||
|
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Информация -->
|
||||||
|
<div class="flex-1 text-center sm:text-left">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-1">${escapeHtml(displayName)}</h1>
|
||||||
|
<p class="text-gray-500 mb-3">@${escapeHtml(user.username)}</p>
|
||||||
|
|
||||||
|
<!-- Статусы -->
|
||||||
|
<div class="flex flex-wrap justify-center sm:justify-start gap-2">
|
||||||
|
${user.is_active ? `
|
||||||
|
<span class="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1 rounded-full">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||||
|
Активен
|
||||||
|
</span>
|
||||||
|
` : `
|
||||||
|
<span class="inline-flex items-center bg-red-100 text-red-800 text-sm px-3 py-1 rounded-full">
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||||
|
Заблокирован
|
||||||
|
</span>
|
||||||
|
`}
|
||||||
|
${user.is_verified ? `
|
||||||
|
<span class="inline-flex items-center bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded-full">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Подтверждён
|
||||||
|
</span>
|
||||||
|
` : `
|
||||||
|
<span class="inline-flex items-center bg-yellow-100 text-yellow-800 text-sm px-3 py-1 rounded-full">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Не подтверждён
|
||||||
|
</span>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccountInfo(user) {
|
||||||
|
const $container = $("#account-container");
|
||||||
|
|
||||||
|
$container.html(`
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- ID пользователя -->
|
||||||
|
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">ID пользователя</p>
|
||||||
|
<p class="text-gray-900">${user.id}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Имя пользователя -->
|
||||||
|
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Имя пользователя</p>
|
||||||
|
<p class="text-gray-900">@${escapeHtml(user.username)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Полное имя -->
|
||||||
|
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Полное имя</p>
|
||||||
|
<p class="text-gray-900">${escapeHtml(user.full_name || "Не указано")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Email -->
|
||||||
|
<div class="flex items-center justify-between py-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-500">Email</p>
|
||||||
|
<p class="text-gray-900">${escapeHtml(user.email)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoles(userRoles, allRoles) {
|
||||||
|
const $container = $("#roles-container");
|
||||||
|
|
||||||
|
if (!userRoles || userRoles.length === 0) {
|
||||||
|
$container.html(`
|
||||||
|
<p class="text-gray-500">У вас нет назначенных ролей</p>
|
||||||
|
`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleDescriptions = {};
|
||||||
|
allRoles.forEach((role) => {
|
||||||
|
roleDescriptions[role.name] = role.description;
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleIcons = {
|
||||||
|
admin: `<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 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||||
|
</svg>`,
|
||||||
|
librarian: `<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="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>`,
|
||||||
|
member: `<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||||
|
</svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleColors = {
|
||||||
|
admin: "bg-red-100 text-red-800 border-red-200",
|
||||||
|
librarian: "bg-blue-100 text-blue-800 border-blue-200",
|
||||||
|
member: "bg-green-100 text-green-800 border-green-200",
|
||||||
|
};
|
||||||
|
|
||||||
|
let rolesHtml = '<div class="space-y-3">';
|
||||||
|
|
||||||
|
userRoles.forEach((roleName) => {
|
||||||
|
const description = roleDescriptions[roleName] || "Описание недоступно";
|
||||||
|
const icon = roleIcons[roleName] || roleIcons.member;
|
||||||
|
const colorClass = roleColors[roleName] || roleColors.member;
|
||||||
|
|
||||||
|
rolesHtml += `
|
||||||
|
<div class="flex items-center p-4 rounded-lg border ${colorClass}">
|
||||||
|
<div class="flex-shrink-0 mr-4">
|
||||||
|
${icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium capitalize">${escapeHtml(roleName)}</h4>
|
||||||
|
<p class="text-sm opacity-75">${escapeHtml(description)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
rolesHtml += '</div>';
|
||||||
|
|
||||||
|
$container.html(rolesHtml);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderActions() {
|
||||||
|
const $container = $("#actions-container");
|
||||||
|
|
||||||
|
$container.html(`
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Смена пароля -->
|
||||||
|
<button id="change-password-btn" class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-700">Сменить пароль</span>
|
||||||
|
</div>
|
||||||
|
<svg class="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="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Выход -->
|
||||||
|
<button id="logout-profile-btn" class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-red-700">Выйти из аккаунта</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-5 h-5 text-red-400" 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>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$("#change-password-btn").on("click", openPasswordModal);
|
||||||
|
$("#logout-profile-btn").on("click", logout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPasswordModal() {
|
||||||
|
$("#password-modal").removeClass("hidden").addClass("flex");
|
||||||
|
$("#current-password").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePasswordModal() {
|
||||||
|
$("#password-modal").removeClass("flex").addClass("hidden");
|
||||||
|
$("#password-form")[0].reset();
|
||||||
|
$("#password-error").addClass("hidden").text("");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#close-password-modal, #cancel-password").on("click", closePasswordModal);
|
||||||
|
|
||||||
|
$("#password-modal").on("click", function (e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closePasswordModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && $("#password-modal").hasClass("flex")) {
|
||||||
|
closePasswordModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#password-form").on("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const currentPassword = $("#current-password").val();
|
||||||
|
const newPassword = $("#new-password").val();
|
||||||
|
const confirmPassword = $("#confirm-password").val();
|
||||||
|
const $error = $("#password-error");
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
$error.text("Пароли не совпадают").removeClass("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
$error.text("Пароль должен содержать минимум 6 символов").removeClass("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: смена пароля, 2FA
|
||||||
|
// fetch("/api/auth/change-password", {
|
||||||
|
// method: "POST",
|
||||||
|
// headers: {
|
||||||
|
// "Content-Type": "application/json",
|
||||||
|
// Authorization: "Bearer " + token,
|
||||||
|
// },
|
||||||
|
// body: JSON.stringify({
|
||||||
|
// current_password: currentPassword,
|
||||||
|
// new_password: newPassword,
|
||||||
|
// }),
|
||||||
|
// })
|
||||||
|
// .then((response) => {
|
||||||
|
// if (!response.ok) throw new Error("Ошибка смены пароля");
|
||||||
|
// return response.json();
|
||||||
|
// })
|
||||||
|
// .then(() => {
|
||||||
|
// closePasswordModal();
|
||||||
|
// showNotification("Пароль успешно изменён", "success");
|
||||||
|
// })
|
||||||
|
// .catch((error) => {
|
||||||
|
// $error.text(error.message).removeClass("hidden");
|
||||||
|
// });
|
||||||
|
|
||||||
|
$error.text("Функция смены пароля временно недоступна").removeClass("hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoadingState() {
|
||||||
|
const $profileCard = $("#profile-card");
|
||||||
|
const $accountContainer = $("#account-container");
|
||||||
|
const $rolesContainer = $("#roles-container");
|
||||||
|
const $actionsContainer = $("#actions-container");
|
||||||
|
|
||||||
|
$profileCard.html(`
|
||||||
|
<div class="flex flex-col sm:flex-row items-center sm:items-start animate-pulse">
|
||||||
|
<div class="w-24 h-24 bg-gray-200 rounded-full mb-4 sm:mb-0 sm:mr-6"></div>
|
||||||
|
<div class="flex-1 text-center sm:text-left">
|
||||||
|
<div class="h-7 bg-gray-200 rounded w-48 mx-auto sm:mx-0 mb-2"></div>
|
||||||
|
<div class="h-5 bg-gray-200 rounded w-32 mx-auto sm:mx-0 mb-3"></div>
|
||||||
|
<div class="flex justify-center sm:justify-start gap-2">
|
||||||
|
<div class="h-7 bg-gray-200 rounded-full w-20"></div>
|
||||||
|
<div class="h-7 bg-gray-200 rounded-full w-28"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$accountContainer.html(`
|
||||||
|
<div class="space-y-4 animate-pulse">
|
||||||
|
${Array(4)
|
||||||
|
.fill()
|
||||||
|
.map(
|
||||||
|
() => `
|
||||||
|
<div class="flex items-center py-3 border-b border-gray-100">
|
||||||
|
<div class="w-5 h-5 bg-gray-200 rounded mr-3"></div>
|
||||||
|
<div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-16 mb-2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-40"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$rolesContainer.html(`
|
||||||
|
<div class="space-y-3 animate-pulse">
|
||||||
|
<div class="h-16 bg-gray-200 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$actionsContainer.html(`
|
||||||
|
<div class="space-y-3 animate-pulse">
|
||||||
|
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
||||||
|
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showErrorState(message) {
|
||||||
|
const $profileCard = $("#profile-card");
|
||||||
|
const $accountSection = $("#account-section");
|
||||||
|
const $rolesSection = $("#roles-section");
|
||||||
|
const $actionsSection = $("#actions-section");
|
||||||
|
|
||||||
|
$accountSection.hide();
|
||||||
|
$rolesSection.hide();
|
||||||
|
$actionsSection.hide();
|
||||||
|
|
||||||
|
$profileCard.html(`
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<svg class="mx-auto h-16 w-16 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-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||||
|
<p class="text-gray-500 mb-6">Не удалось загрузить профиль</p>
|
||||||
|
<div class="flex justify-center gap-4">
|
||||||
|
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
<a href="/" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||||
|
На главную
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
$("#retry-btn").on("click", function () {
|
||||||
|
$accountSection.show();
|
||||||
|
$rolesSection.show();
|
||||||
|
$actionsSection.show();
|
||||||
|
loadProfile();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return "";
|
||||||
|
const div = document.createElement("div");
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", logout);
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
showUser(currentUser);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
// Load authors and genres asynchronously
|
|
||||||
Promise.all([
|
|
||||||
fetch("/authors").then((response) => response.json()),
|
|
||||||
fetch("/genres").then((response) => response.json()),
|
|
||||||
])
|
|
||||||
.then(([authorsData, genresData]) => {
|
|
||||||
// Populate authors dropdown
|
|
||||||
const dropdown = document.getElementById("author-dropdown");
|
|
||||||
authorsData.authors.forEach((author) => {
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.className = "p-2 hover:bg-gray-100 cursor-pointer";
|
|
||||||
div.setAttribute("data-value", author.name);
|
|
||||||
div.textContent = author.name;
|
|
||||||
dropdown.appendChild(div);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Populate genres list
|
|
||||||
const list = document.getElementById("genres-list");
|
|
||||||
genresData.genres.forEach((genre) => {
|
|
||||||
const li = document.createElement("li");
|
|
||||||
li.className = "mb-1";
|
|
||||||
li.innerHTML = `
|
|
||||||
<label class="custom-checkbox flex items-center">
|
|
||||||
<input type="checkbox" />
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
${genre.name}
|
|
||||||
</label>
|
|
||||||
`;
|
|
||||||
list.appendChild(li);
|
|
||||||
});
|
|
||||||
|
|
||||||
initializeAuthorDropdown();
|
|
||||||
})
|
|
||||||
.catch((error) => console.error("Error loading data:", error));
|
|
||||||
|
|
||||||
function initializeAuthorDropdown() {
|
|
||||||
const authorSearchInput = document.getElementById("author-search-input");
|
|
||||||
const authorDropdown = document.getElementById("author-dropdown");
|
|
||||||
const selectedAuthorsContainer = document.getElementById(
|
|
||||||
"selected-authors-container",
|
|
||||||
);
|
|
||||||
const dropdownItems = authorDropdown.querySelectorAll("[data-value]");
|
|
||||||
let selectedAuthors = new Set();
|
|
||||||
|
|
||||||
// Function to update highlights in dropdown
|
|
||||||
const updateDropdownHighlights = () => {
|
|
||||||
dropdownItems.forEach((item) => {
|
|
||||||
const value = item.dataset.value;
|
|
||||||
if (selectedAuthors.has(value)) {
|
|
||||||
item.classList.add("bg-gray-200");
|
|
||||||
} else {
|
|
||||||
item.classList.remove("bg-gray-200");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Function to render selected authors
|
|
||||||
const renderSelectedAuthors = () => {
|
|
||||||
Array.from(selectedAuthorsContainer.children).forEach((child) => {
|
|
||||||
if (child.id !== "author-search-input") {
|
|
||||||
child.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
selectedAuthors.forEach((author) => {
|
|
||||||
const authorChip = document.createElement("span");
|
|
||||||
authorChip.className =
|
|
||||||
"flex items-center bg-gray-200 text-gray-800 text-sm font-medium px-2.5 py-0.5 rounded-full";
|
|
||||||
authorChip.innerHTML = `
|
|
||||||
${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.insertBefore(authorChip, authorSearchInput);
|
|
||||||
});
|
|
||||||
updateDropdownHighlights();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle input focus to show dropdown
|
|
||||||
authorSearchInput.addEventListener("focus", () => {
|
|
||||||
authorDropdown.classList.remove("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle input for filtering
|
|
||||||
authorSearchInput.addEventListener("input", () => {
|
|
||||||
const query = authorSearchInput.value.toLowerCase();
|
|
||||||
dropdownItems.forEach((item) => {
|
|
||||||
const text = item.textContent.toLowerCase();
|
|
||||||
item.style.display = text.includes(query) ? "block" : "none";
|
|
||||||
});
|
|
||||||
authorDropdown.classList.remove("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle clicks outside to hide dropdown
|
|
||||||
document.addEventListener("click", (event) => {
|
|
||||||
if (
|
|
||||||
!selectedAuthorsContainer.contains(event.target) &&
|
|
||||||
!authorDropdown.contains(event.target)
|
|
||||||
) {
|
|
||||||
authorDropdown.classList.add("hidden");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle author selection from dropdown
|
|
||||||
authorDropdown.addEventListener("click", (event) => {
|
|
||||||
const selectedValue = event.target.dataset.value;
|
|
||||||
if (selectedValue) {
|
|
||||||
if (selectedAuthors.has(selectedValue)) {
|
|
||||||
selectedAuthors.delete(selectedValue);
|
|
||||||
} else {
|
|
||||||
selectedAuthors.add(selectedValue);
|
|
||||||
}
|
|
||||||
authorSearchInput.value = "";
|
|
||||||
renderSelectedAuthors();
|
|
||||||
authorSearchInput.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle removing selected author chip
|
|
||||||
selectedAuthorsContainer.addEventListener("click", (event) => {
|
|
||||||
if (event.target.closest("button")) {
|
|
||||||
const authorToRemove = event.target.closest("button").dataset.author;
|
|
||||||
selectedAuthors.delete(authorToRemove);
|
|
||||||
renderSelectedAuthors();
|
|
||||||
authorSearchInput.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial render and highlights (without auto-focus)
|
|
||||||
renderSelectedAuthors();
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,3 +74,179 @@ nav ul li a {
|
|||||||
border-width: 0 2px 2px 0;
|
border-width: 0 2px 2px 0;
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-tab {
|
||||||
|
font-family: "Dited", sans-serif;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form,
|
||||||
|
#register-form {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex.justify-center.gap-4 button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shake {
|
||||||
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
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 {
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.req-icon {
|
||||||
|
font-size: 10px;
|
||||||
|
width: 12px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-form:not(.hidden),
|
||||||
|
#register-form:not(.hidden) {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#login-tab,
|
||||||
|
#register-tab {
|
||||||
|
font-family: "Dited", sans-serif;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-dropdown {
|
||||||
|
animation: dropdownFade 0.1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropdownFade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-arrow.rotate-180 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line-clamp-3 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|||||||
@@ -50,11 +50,12 @@
|
|||||||
<p>Current Time: {{ server_time }}</p>
|
<p>Current Time: {{ server_time }}</p>
|
||||||
<p>Status: {{ status }}</p>
|
<p>Status: {{ status }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="/docs">Swagger UI</a></li>
|
<li><a href="/">Home page</a></li>
|
||||||
<li><a href="/redoc">ReDoc</a></li>
|
|
||||||
<li>
|
<li>
|
||||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li><a href="/docs">Swagger UI</a></li>
|
||||||
|
<li><a href="/redoc">ReDoc</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,327 @@
|
|||||||
|
{% 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-md">
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
|
||||||
|
<form id="login-form" class="p-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="login-username"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Имя пользователя</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="login-username"
|
||||||
|
name="username"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||||
|
placeholder="Введите имя пользователя"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="login-password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Пароль</label
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="login-password"
|
||||||
|
name="password"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
onclick="togglePassword(this)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="eye-open 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 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
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<label
|
||||||
|
class="custom-checkbox flex items-center text-sm text-gray-600"
|
||||||
|
>
|
||||||
|
<input type="checkbox" id="remember-me" />
|
||||||
|
<span class="checkmark"></span>Запомнить меня
|
||||||
|
</label>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-700 transition"
|
||||||
|
>Забыли пароль?</a
|
||||||
|
>
|
||||||
|
</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>
|
||||||
|
<button
|
||||||
|
type="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"
|
||||||
|
>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<form
|
||||||
|
id="register-form"
|
||||||
|
class="p-6 hidden"
|
||||||
|
onsubmit="return handleRegister(event);"
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="register-username"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Имя пользователя</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="register-username"
|
||||||
|
name="username"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||||
|
placeholder="Придумайте имя пользователя (мин. 3 символа)"
|
||||||
|
required
|
||||||
|
minlength="3"
|
||||||
|
maxlength="50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="register-email"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Email</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="register-email"
|
||||||
|
name="email"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||||
|
placeholder="example@mail.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="register-fullname"
|
||||||
|
name="full_name"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||||
|
placeholder="Иван Иванов"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="register-password"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Пароль</label
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="register-password"
|
||||||
|
name="password"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||||
|
placeholder="Минимум 8 символов, A-Z, a-z, 0-9"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
maxlength="100"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
onclick="togglePassword(this)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="eye-open 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 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
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<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>
|
||||||
|
<p
|
||||||
|
id="password-strength-text"
|
||||||
|
class="text-xs mt-1 text-gray-500"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
for="register-password-confirm"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>Подтвердите пароль</label
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="register-password-confirm"
|
||||||
|
name="password_confirm"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||||
|
placeholder="Повторите пароль"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
onclick="togglePassword(this)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="eye-open 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 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
|
||||||
|
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>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
id="password-match-error"
|
||||||
|
class="text-xs mt-1 text-red-500 hidden"
|
||||||
|
>
|
||||||
|
Пароли не совпадают
|
||||||
|
</p>
|
||||||
|
</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>
|
||||||
|
<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
|
||||||
|
type="submit"
|
||||||
|
id="register-submit"
|
||||||
|
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
Зарегистрироваться
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script type="text/javascript" src="/static/auth.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %} {% block
|
||||||
|
content %}
|
||||||
|
<div class="flex flex-1 mt-4 p-4">
|
||||||
|
<main class="flex-1 max-w-4xl mx-auto">
|
||||||
|
<div id="author-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||||
|
</div>
|
||||||
|
<div id="books-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Книги автора</h2>
|
||||||
|
<div id="books-container">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script type="text/javascript" src="/static/author.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %} {% block
|
||||||
|
content %}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Поиск</h2>
|
||||||
|
<div class="relative mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="author-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="Поиск авторов..."
|
||||||
|
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>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort"
|
||||||
|
value="name_asc"
|
||||||
|
checked
|
||||||
|
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-gray-700">По имени (А-Я)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort"
|
||||||
|
value="name_desc"
|
||||||
|
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
||||||
|
/>
|
||||||
|
<span class="ml-2 text-gray-700">По имени (Я-А)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
<main class="flex-1">
|
||||||
|
<div id="authors-container"></div>
|
||||||
|
<div id="pagination-container"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script type="text/javascript" src="/static/authors.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<title>{% block title %}LiB{% endblock %}</title>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<script async="" src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cash/8.1.5/cash.min.js"></script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="flex flex-col min-h-screen bg-gray-100">
|
||||||
|
<header class="bg-gray-500 text-white p-4 shadow-md">
|
||||||
|
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
||||||
|
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||||
|
<img class="invert" src="/static/logo.svg" />
|
||||||
|
<h1 class="text-2xl font-bold">LiB</h1>
|
||||||
|
</a>
|
||||||
|
<nav>
|
||||||
|
<ul class="flex space-x-4">
|
||||||
|
<li><a href="/" class="hover:text-gray-200">Главная</a></li>
|
||||||
|
<li><a href="/books" class="hover:text-gray-200">Книги</a></li>
|
||||||
|
<li><a href="/authors" class="hover:text-gray-200">Авторы</a></li>
|
||||||
|
<li><a href="/about" class="hover:text-gray-200">О нас</a></li>
|
||||||
|
<li><a href="/api" class="hover:text-gray-200">API</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="relative" id="user-menu-area">
|
||||||
|
<a href="/auth" id="guest-link" class="block hover:opacity-80 transition"><img class="w-6 h-6 invert" src="/static/avatar.svg" /></a>
|
||||||
|
<button type="button" id="user-btn" class="hidden items-center gap-2 hover:opacity-80 transition focus:outline-none">
|
||||||
|
<img
|
||||||
|
id="user-avatar"
|
||||||
|
src="https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y"
|
||||||
|
class="w-8 h-8 rounded-full border-2 border-white object-cover bg-gray-600"
|
||||||
|
alt="User Avatar"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<svg id="user-arrow" class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div id="user-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200">
|
||||||
|
<p id="dropdown-name" class="text-sm font-semibold text-gray-900 truncate">Пользователь</p>
|
||||||
|
<p id="dropdown-username" class="text-sm text-gray-500 truncate">@username</p>
|
||||||
|
<p id="dropdown-email" class="text-xs text-gray-400 truncate mt-1">email@example.com</p>
|
||||||
|
</div>
|
||||||
|
<a href="/profile" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100">
|
||||||
|
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
|
||||||
|
<p class="text-gray-700 text-sm">Мой профиль</p>
|
||||||
|
</a>
|
||||||
|
<a href="/my-books" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100">
|
||||||
|
<svg class="w-4 h-4 mr-3 text-gray-400" 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>
|
||||||
|
<p class="text-gray-700 text-sm">Мои книги</p>
|
||||||
|
</a>
|
||||||
|
<div class="border-t border-gray-200 mt-1 pt-1">
|
||||||
|
<button type="button" id="logout-btn" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||||
|
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
|
||||||
|
<p class="text-gray-700 text-sm">Выйти</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
|
||||||
|
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||||
|
<div class="container mx-auto text-center">
|
||||||
|
<p>© 2025 My Awesome Library. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{% block scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %} {% block
|
||||||
|
content %}
|
||||||
|
<div class="flex flex-1 mt-4 p-4">
|
||||||
|
<main class="flex-1 max-w-4xl mx-auto">
|
||||||
|
<div id="book-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||||
|
</div>
|
||||||
|
<div id="authors-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Авторы</h2>
|
||||||
|
<div id="authors-container">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="genres-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Жанры</h2>
|
||||||
|
<div id="genres-container">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script type="text/javascript" src="/static/book.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}LiB - Главная{% endblock %} {% block
|
||||||
|
content %}
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<div class="mb-4">
|
||||||
|
<h3 class="font-medium mb-2">Авторы</h3>
|
||||||
|
<div class="relative">
|
||||||
|
<div
|
||||||
|
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
|
||||||
|
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 class="mb-4">
|
||||||
|
<h3 class="font-medium mb-2">Жанры</h3>
|
||||||
|
<ul id="genres-list" class="max-h-60 overflow-y-auto"></ul>
|
||||||
|
</div>
|
||||||
|
<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
|
||||||
|
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>
|
||||||
|
<main class="flex-1">
|
||||||
|
<div id="books-container"></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script type="text/javascript" src="/static/books.js"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,138 +1,195 @@
|
|||||||
<!doctype html>
|
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {%
|
||||||
<html lang="en">
|
block content %}
|
||||||
<head>
|
<div class="flex flex-1 items-center justify-center p-4">
|
||||||
<meta charset="UTF-8" />
|
<div class="w-full max-w-4xl">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
<title>LiB</title>
|
<div class="text-center py-8 border-b border-gray-200">
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<h2 class="text-3xl font-bold text-gray-800 mb-2">
|
||||||
<link rel="stylesheet" href="static/styles.css" />
|
Добро пожаловать в LiB
|
||||||
</head>
|
</h2>
|
||||||
<body class="flex flex-col min-h-screen bg-gray-100">
|
<p class="text-gray-500">Ваша персональная библиотека книг</p>
|
||||||
<!-- Header -->
|
|
||||||
<header class="bg-gray-500 text-white p-4 shadow-md">
|
|
||||||
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
|
||||||
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
|
||||||
<img class="invert" src="static/logo.svg" />
|
|
||||||
<h1 class="text-2xl font-bold">LiB</h1>
|
|
||||||
</a>
|
|
||||||
<nav>
|
|
||||||
<ul class="flex space-x-4">
|
|
||||||
<li>
|
|
||||||
<a href="/" class="hover:text-gray-200">Home</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" class="hover:text-gray-200">Products</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#" class="hover:text-gray-200">About</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="/api" class="hover:text-gray-200">API</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<img class="max-w-6 h-auto invert" src="static/avatar.svg" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
<div class="p-8">
|
||||||
|
<div
|
||||||
<!-- Main -->
|
class="flex flex-col lg:flex-row items-center justify-center gap-12"
|
||||||
<div class="flex flex-1 mt-4 p-4">
|
>
|
||||||
<aside
|
<div class="flex-shrink-0">
|
||||||
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"
|
<svg
|
||||||
>
|
id="bookSvg"
|
||||||
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
|
width="400"
|
||||||
<!-- Authors -->
|
height="500"
|
||||||
<div class="mb-4">
|
viewBox="0 0 200 250"
|
||||||
<h3 class="font-medium mb-2">Авторы</h3>
|
class="drop-shadow-lg"
|
||||||
<div class="relative">
|
></svg>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-6">
|
||||||
<div
|
<div
|
||||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white"
|
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"
|
||||||
id="selected-authors-container"
|
|
||||||
>
|
>
|
||||||
<input
|
<div class="mb-3">
|
||||||
type="text"
|
<svg
|
||||||
id="author-search-input"
|
class="w-10 h-10 mx-auto text-gray-600"
|
||||||
class="flex-grow outline-none bg-transparent"
|
fill="none"
|
||||||
placeholder="Начните вводить..."
|
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>
|
||||||
<div
|
<div
|
||||||
id="author-dropdown"
|
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"
|
||||||
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 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>
|
||||||
<!-- Genres -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="font-medium mb-2">Жанры</h3>
|
|
||||||
<ul id="genres-list"></ul>
|
|
||||||
</div>
|
|
||||||
<!-- Apply -->
|
|
||||||
<button
|
|
||||||
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200"
|
|
||||||
>
|
|
||||||
Применить фильтры
|
|
||||||
</button>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main Area -->
|
|
||||||
<main class="flex-1">
|
|
||||||
<!-- Book Card 1 -->
|
|
||||||
<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 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>
|
|
||||||
|
|
||||||
<!-- Book Card 2 -->
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Book Card 3 -->
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
|
||||||
<div class="container mx-auto text-center">
|
|
||||||
<p>© 2025 My Awesome Library. All rights reserved.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
<div class="px-8 pb-8">
|
||||||
<script type="text/javascript" src="static/script.js"></script>
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
</body>
|
<a
|
||||||
</html>
|
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 %}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %} {% block
|
||||||
|
content %}
|
||||||
|
|
||||||
|
<div class="flex flex-1 mt-4 p-4">
|
||||||
|
<main class="flex-1 max-w-2xl mx-auto">
|
||||||
|
<div id="profile-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||||
|
</div>
|
||||||
|
<div id="account-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Информация об аккаунте</h2>
|
||||||
|
<div id="account-container">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="roles-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Роли и права</h2>
|
||||||
|
<div id="roles-container">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="actions-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Действия</h2>
|
||||||
|
<div id="actions-container">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<div id="password-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md mx-4">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="text-xl font-semibold">Смена пароля</h3>
|
||||||
|
<button id="close-password-modal" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="password-form">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 text-sm font-medium mb-2">Текущий пароль</label>
|
||||||
|
<input type="password" id="current-password"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||||
|
required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label>
|
||||||
|
<input type="password" id="new-password"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||||
|
required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите новый пароль</label>
|
||||||
|
<input type="password" id="confirm-password"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||||
|
required minlength="6">
|
||||||
|
</div>
|
||||||
|
<div id="password-error" class="mb-4 text-red-600 text-sm hidden"></div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" class="flex-1 bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition-colors">
|
||||||
|
Сменить пароль
|
||||||
|
</button>
|
||||||
|
<button type="button" id="cancel-password" class="flex-1 bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %} {% block scripts %}
|
||||||
|
<script type="text/javascript" src="/static/profile.js"></script>
|
||||||
|
{% endblock %}
|
||||||
+3
-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,10 +15,12 @@ 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
|
||||||
|
from library_service.models.enums import *
|
||||||
from library_service.models.db import *
|
from library_service.models.db import *
|
||||||
|
|
||||||
target_metadata = SQLModel.metadata
|
target_metadata = SQLModel.metadata
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Loans
|
||||||
|
|
||||||
|
Revision ID: 02ed6e775351
|
||||||
|
Revises: b838606ad8d1
|
||||||
|
Create Date: 2025-12-20 10:36:30.853896
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '02ed6e775351'
|
||||||
|
down_revision: Union[str, None] = 'b838606ad8d1'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
||||||
|
book_status_enum.create(op.get_bind())
|
||||||
|
op.create_table('book_loans',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('book_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('borrowed_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('due_date', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('returned_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['book_id'], ['book.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_book_loans_id'), 'book_loans', ['id'], unique=False)
|
||||||
|
op.add_column('book', sa.Column('status', book_status_enum, nullable=False, server_default='active'))
|
||||||
|
op.drop_index(op.f('ix_roles_name'), table_name='roles')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
|
||||||
|
op.drop_column('book', 'status')
|
||||||
|
op.drop_index(op.f('ix_book_loans_id'), table_name='book_loans')
|
||||||
|
op.drop_table('book_loans')
|
||||||
|
book_status_enum = sa.Enum('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus')
|
||||||
|
book_status_enum.drop(op.get_bind())
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""genres
|
"""Genres
|
||||||
|
|
||||||
Revision ID: 9d7a43ac5dfc
|
Revision ID: 9d7a43ac5dfc
|
||||||
Revises: d266fdc61e99
|
Revises: d266fdc61e99
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""auth
|
"""Auth
|
||||||
|
|
||||||
Revision ID: b838606ad8d1
|
Revision ID: b838606ad8d1
|
||||||
Revises: 9d7a43ac5dfc
|
Revises: 9d7a43ac5dfc
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""init
|
"""Init
|
||||||
|
|
||||||
Revision ID: d266fdc61e99
|
Revision ID: d266fdc61e99
|
||||||
Revises:
|
Revises:
|
||||||
|
|||||||
Generated
+8
-5
@@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiofiles"
|
name = "aiofiles"
|
||||||
@@ -59,6 +59,7 @@ files = [
|
|||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
idna = ">=2.8"
|
idna = ">=2.8"
|
||||||
sniffio = ">=1.1"
|
sniffio = ">=1.1"
|
||||||
|
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
|
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
|
||||||
@@ -525,7 +526,7 @@ description = "Lightweight in-process concurrent programming"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main"]
|
groups = ["main"]
|
||||||
markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
|
markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
|
||||||
files = [
|
files = [
|
||||||
{file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"},
|
{file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"},
|
||||||
{file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"},
|
{file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"},
|
||||||
@@ -1471,6 +1472,7 @@ files = [
|
|||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
pytest = ">=8.2,<10"
|
pytest = ">=8.2,<10"
|
||||||
|
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
|
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
|
||||||
@@ -1855,11 +1857,12 @@ version = "4.15.0"
|
|||||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
groups = ["main"]
|
groups = ["main", "dev"]
|
||||||
files = [
|
files = [
|
||||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||||
]
|
]
|
||||||
|
markers = {dev = "python_version == \"3.12\""}
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-inspection"
|
name = "typing-inspection"
|
||||||
@@ -2243,5 +2246,5 @@ files = [
|
|||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = "^3.13"
|
python-versions = "^3.12"
|
||||||
content-hash = "2fdd550e819b1456733250a62196acb442127a296ff60e010c424ecbebec294e"
|
content-hash = "a8d44f0decfa3ba437e998207c16ca7429ee42e930e8aa1d40f87231e71f219f"
|
||||||
|
|||||||
+1
-4
@@ -7,7 +7,7 @@ readme = "README.md"
|
|||||||
packages = [{ include = "library_service" }]
|
packages = [{ include = "library_service" }]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.13"
|
python = "^3.12"
|
||||||
fastapi = { extras = ["all"], version = "^0.115.12" }
|
fastapi = { extras = ["all"], version = "^0.115.12" }
|
||||||
psycopg2-binary = "^2.9.10"
|
psycopg2-binary = "^2.9.10"
|
||||||
alembic = "^1.16.1"
|
alembic = "^1.16.1"
|
||||||
@@ -28,9 +28,6 @@ isort = "^7.0.0"
|
|||||||
pytest-asyncio = "^1.3.0"
|
pytest-asyncio = "^1.3.0"
|
||||||
pylint = "^4.0.4"
|
pylint = "^4.0.4"
|
||||||
|
|
||||||
[tool.poetry.requires-plugins]
|
|
||||||
poetry-plugin-export = ">=1.8"
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core"]
|
requires = ["poetry-core"]
|
||||||
build-backend = "poetry.core.masonry.api"
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|||||||
Reference in New Issue
Block a user