Compare commits

...

7 Commits

16 changed files with 1844 additions and 374 deletions
+4
View File
@@ -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_PORT = "5432"
POSTGRES_USER = "postgres"
+3 -1
View File
@@ -103,7 +103,7 @@ erDiagram
int id PK "ID автора"
string name "Имя автора"
}
BOOK {
int id PK "ID книги"
string title "Название книги"
@@ -142,3 +142,5 @@ erDiagram
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных.
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах.
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker.
- **Tailwind**: CSS-фреймворк, позволяющий стилизовать веб-интерфейсы, применяя готовые низкоуровневые классы.
- **Cash**: Микро JavaScript-библиотека, созданная как очень быстрая и компактная альтернатива jQuery.
+363
View File
@@ -0,0 +1,363 @@
import requests
from typing import Optional
# Конфигурация
USERNAME = "sys-admin"
PASSWORD = "wTKPVqTIMqzXL2EZxYz80w"
BASE_URL = "http://localhost:8000"
class LibraryAPI:
def __init__(self, base_url: str):
self.base_url = base_url
self.token: Optional[str] = None
self.session = requests.Session()
def login(self, username: str, password: str) -> bool:
"""Авторизация и получение токена"""
response = self.session.post(
f"{self.base_url}/api/auth/token",
data={"username": username, "password": password},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
if response.status_code == 200:
self.token = response.json()["access_token"]
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
print(f"✓ Авторизация успешна для пользователя: {username}")
return True
else:
print(f"✗ Ошибка авторизации: {response.text}")
return False
def register(self, username: str, email: str, password: str, full_name: str = None) -> bool:
"""Регистрация нового пользователя"""
data = {
"username": username,
"email": email,
"password": password
}
if full_name:
data["full_name"] = full_name
response = self.session.post(
f"{self.base_url}/api/auth/register",
json=data
)
if response.status_code == 201:
print(f"✓ Пользователь {username} зарегистрирован")
return True
else:
print(f"✗ Ошибка регистрации: {response.text}")
return False
def create_author(self, name: str) -> Optional[int]:
"""Создание автора"""
response = self.session.post(
f"{self.base_url}/api/authors/",
json={"name": name}
)
if response.status_code == 200:
author_id = response.json()["id"]
print(f" ✓ Автор создан: {name} (ID: {author_id})")
return author_id
else:
print(f" ✗ Ошибка создания автора {name}: {response.text}")
return None
def create_book(self, title: str, description: str) -> Optional[int]:
"""Создание книги"""
response = self.session.post(
f"{self.base_url}/api/books/",
json={"title": title, "description": description}
)
if response.status_code == 200:
book_id = response.json()["id"]
print(f" ✓ Книга создана: {title} (ID: {book_id})")
return book_id
else:
print(f" ✗ Ошибка создания книги {title}: {response.text}")
return None
def create_genre(self, name: str) -> Optional[int]:
"""Создание жанра"""
response = self.session.post(
f"{self.base_url}/api/genres/",
json={"name": name}
)
if response.status_code == 200:
genre_id = response.json()["id"]
print(f" ✓ Жанр создан: {name} (ID: {genre_id})")
return genre_id
else:
print(f" ✗ Ошибка создания жанра {name}: {response.text}")
return None
def link_author_book(self, author_id: int, book_id: int) -> bool:
"""Связь автора и книги"""
response = self.session.post(
f"{self.base_url}/api/relationships/author-book",
params={"author_id": author_id, "book_id": book_id}
)
if response.status_code == 200:
print(f" ↔ Связь автор-книга: {author_id}{book_id}")
return True
else:
print(f" ✗ Ошибка связи автор-книга: {response.text}")
return False
def link_genre_book(self, genre_id: int, book_id: int) -> bool:
"""Связь жанра и книги"""
response = self.session.post(
f"{self.base_url}/api/relationships/genre-book",
params={"genre_id": genre_id, "book_id": book_id}
)
if response.status_code == 200:
print(f" ↔ Связь жанр-книга: {genre_id}{book_id}")
return True
else:
print(f" ✗ Ошибка связи жанр-книга: {response.text}")
return False
def main():
api = LibraryAPI(BASE_URL)
# Авторизация
if not api.login(USERNAME, PASSWORD):
print("Не удалось авторизоваться. Проверьте логин и пароль.")
return
# === АВТОРЫ (12 авторов) ===
print("\n📚 Создание авторов...")
authors_data = [
"Лев Толстой",
"Фёдор Достоевский",
"Антон Чехов",
"Александр Пушкин",
"Михаил Булгаков",
"Николай Гоголь",
"Иван Тургенев",
"Борис Пастернак",
"Михаил Лермонтов",
"Александр Солженицын",
"Максим Горький",
"Иван Бунин"
]
authors = {}
for name in authors_data:
author_id = api.create_author(name)
if author_id:
authors[name] = author_id
# === ЖАНРЫ (8 жанров) ===
print("\n🏷️ Создание жанров...")
genres_data = [
"Роман",
"Повесть",
"Рассказ",
"Поэзия",
"Драма",
"Философская проза",
"Историческая проза",
"Сатира"
]
genres = {}
for name in genres_data:
genre_id = api.create_genre(name)
if genre_id:
genres[name] = genre_id
# === КНИГИ (25 книг) ===
print("\n📖 Создание книг...")
books_data = [
{
"title": "Война и мир",
"description": "Роман-эпопея Льва Толстого, описывающий русское общество в эпоху войн против Наполеона в 1805—1812 годах. Одно из величайших произведений мировой литературы.",
"authors": ["Лев Толстой"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Анна Каренина",
"description": "Роман Льва Толстого о трагической любви замужней дамы Анны Карениной к блестящему офицеру Вронскому. История страсти, ревности и роковых решений.",
"authors": ["Лев Толстой"],
"genres": ["Роман", "Драма"]
},
{
"title": "Преступление и наказание",
"description": "Социально-психологический роман Фёдора Достоевского о бедном студенте Раскольникове, совершившем убийство и мучающемся угрызениями совести.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Братья Карамазовы",
"description": "Последний роман Достоевского, история семьи Карамазовых, затрагивающая глубокие вопросы веры, свободы воли и морали.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза", "Драма"]
},
{
"title": "Идиот",
"description": "Роман о князе Мышкине — человеке с чистой душой, который сталкивается с жестокостью и корыстью петербургского общества.",
"authors": ["Фёдор Достоевский"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Вишнёвый сад",
"description": "Пьеса Антона Чехова о разорении дворянского гнезда и продаже родового имения с вишнёвым садом.",
"authors": ["Антон Чехов"],
"genres": ["Драма"]
},
{
"title": "Чайка",
"description": "Пьеса Чехова о любви, искусстве и несбывшихся мечтах, разворачивающаяся в усадьбе на берегу озера.",
"authors": ["Антон Чехов"],
"genres": ["Драма"]
},
{
"title": "Палата № 6",
"description": "Повесть о враче психиатрической больницы, который начинает сомневаться в границах между нормой и безумием.",
"authors": ["Антон Чехов"],
"genres": ["Повесть", "Философская проза"]
},
{
"title": "Евгений Онегин",
"description": "Роман в стихах Александра Пушкина — энциклопедия русской жизни начала XIX века и история несчастной любви.",
"authors": ["Александр Пушкин"],
"genres": ["Роман", "Поэзия"]
},
{
"title": "Капитанская дочка",
"description": "Исторический роман Пушкина о событиях Пугачёвского восстания, любви и чести.",
"authors": ["Александр Пушкин"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Пиковая дама",
"description": "Повесть о молодом офицере Германне, одержимом желанием узнать тайну трёх карт.",
"authors": ["Александр Пушкин"],
"genres": ["Повесть"]
},
{
"title": "Мастер и Маргарита",
"description": "Роман Михаила Булгакова о визите дьявола в Москву 1930-х годов, переплетённый с историей Понтия Пилата.",
"authors": ["Михаил Булгаков"],
"genres": ["Роман", "Сатира", "Философская проза"]
},
{
"title": "Собачье сердце",
"description": "Повесть-сатира о профессоре Преображенском, превратившем бродячего пса в человека.",
"authors": ["Михаил Булгаков"],
"genres": ["Повесть", "Сатира"]
},
{
"title": "Белая гвардия",
"description": "Роман о семье Турбиных в Киеве во время Гражданской войны 1918-1919 годов.",
"authors": ["Михаил Булгаков"],
"genres": ["Роман", "Историческая проза"]
},
{
"title": "Мёртвые души",
"description": "Поэма Николая Гоголя о похождениях Чичикова, скупающего «мёртвые души» крепостных крестьян.",
"authors": ["Николай Гоголь"],
"genres": ["Роман", "Сатира"]
},
{
"title": "Ревизор",
"description": "Комедия о чиновниках уездного города, принявших проезжего за ревизора из Петербурга.",
"authors": ["Николай Гоголь"],
"genres": ["Драма", "Сатира"]
},
{
"title": "Шинель",
"description": "Повесть о маленьком человеке — титулярном советнике Акакии Башмачкине и его мечте о новой шинели.",
"authors": ["Николай Гоголь"],
"genres": ["Повесть"]
},
{
"title": "Отцы и дети",
"description": "Роман Ивана Тургенева о конфликте поколений и нигилизме на примере Евгения Базарова.",
"authors": ["Иван Тургенев"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Записки охотника",
"description": "Цикл рассказов Тургенева о русской деревне и крестьянах, написанный с глубоким сочувствием к народу.",
"authors": ["Иван Тургенев"],
"genres": ["Рассказ"]
},
{
"title": "Доктор Живаго",
"description": "Роман Бориса Пастернака о судьбе русского интеллигента в эпоху революции и Гражданской войны.",
"authors": ["Борис Пастернак"],
"genres": ["Роман", "Историческая проза", "Поэзия"]
},
{
"title": "Герой нашего времени",
"description": "Роман Михаила Лермонтова о Печорине — «лишнем человеке», скучающем и разочарованном в жизни.",
"authors": ["Михаил Лермонтов"],
"genres": ["Роман", "Философская проза"]
},
{
"title": "Архипелаг ГУЛАГ",
"description": "Документально-художественное исследование Александра Солженицына о системе советских лагерей.",
"authors": ["Александр Солженицын"],
"genres": ["Историческая проза"]
},
{
"title": "Один день Ивана Денисовича",
"description": "Повесть о одном дне заключённого советского лагеря, положившая начало лагерной прозе.",
"authors": ["Александр Солженицын"],
"genres": ["Повесть", "Историческая проза"]
},
{
"title": "На дне",
"description": "Пьеса Максима Горького о жителях ночлежки для бездомных — людях, оказавшихся на дне жизни.",
"authors": ["Максим Горький"],
"genres": ["Драма", "Философская проза"]
},
{
"title": "Тёмные аллеи",
"description": "Сборник рассказов Ивана Бунина о любви — трагической, мимолётной и прекрасной.",
"authors": ["Иван Бунин"],
"genres": ["Рассказ"]
}
]
books = {}
for book in books_data:
book_id = api.create_book(book["title"], book["description"])
if book_id:
books[book["title"]] = {
"id": book_id,
"authors": book["authors"],
"genres": book["genres"]
}
# === СОЗДАНИЕ СВЯЗЕЙ ===
print("\n🔗 Создание связей...")
for book_title, book_info in books.items():
book_id = book_info["id"]
# Связи с авторами
for author_name in book_info["authors"]:
if author_name in authors:
api.link_author_book(authors[author_name], book_id)
# Связи с жанрами
for genre_name in book_info["genres"]:
if genre_name in genres:
api.link_genre_book(genres[genre_name], book_id)
# === ИТОГИ ===
print("\n" + "=" * 50)
print("📊 ИТОГИ:")
print(f" • Авторов создано: {len(authors)}")
print(f" • Жанров создано: {len(genres)}")
print(f" • Книг создано: {len(books)}")
print("=" * 50)
if __name__ == "__main__":
main()
+20 -9
View File
@@ -11,7 +11,7 @@ from sqlmodel import Session, select
from library_service.models.db import Role, User
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"))
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_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:
@@ -161,7 +165,7 @@ def seed_roles(session: Session) -> dict[str, Role]:
session.commit()
session.refresh(role)
roles[role_data["name"]] = role
print(f"[+] Created role: {role_data['name']}")
logger.info(f"[+] Created role: {role_data['name']}")
return roles
@@ -173,18 +177,18 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
).all()
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
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
generated = False
if not admin_password:
import secrets
admin_password = secrets.token_urlsafe(16)
print(f"[!] Generated admin password: {admin_password}")
print("[!] Please save this password and set DEFAULT_ADMIN_PASSWORD env var")
generated = True
admin_user = User(
username=admin_username,
@@ -200,7 +204,14 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
session.commit()
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
+28 -12
View File
@@ -6,27 +6,43 @@ from alembic import command
from alembic.config import Config
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from sqlmodel import Session
from .auth import run_seeds
from .routers import api_router
from .settings import engine, get_app
app = get_app()
alembic_cfg = Config("alembic.ini")
from .settings import engine, get_app, get_logger
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Жизененый цикл сервиса"""
print("[+] Initializing...")
"""Жизненный цикл сервиса"""
logger = get_logger("uvicorn")
logger.info("[+] Initializing database...")
# Настройка базы данных
with engine.begin() as connection:
alembic_cfg.attributes["connection"] = connection
command.upgrade(alembic_cfg, "head")
try:
with engine.begin() as connection:
alembic_cfg = Config("alembic.ini")
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 # Обработка запросов
print("[+] Application shutdown")
logger.info("[+] Application shutdown")
app = get_app(lifespan)
# Подключение маршрутов
+50 -50
View File
@@ -6,8 +6,8 @@ from sqlmodel import Session, select, col, func
from library_service.auth import RequireAuth
from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
from library_service.models.dto.combined import (
BookWithAuthorsAndGenres,
BookFilteredList
@@ -17,6 +17,54 @@ from library_service.models.dto.combined import (
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(
"/",
response_model=Book,
@@ -127,51 +175,3 @@ def delete_book(
session.delete(book)
session.commit()
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)
+30 -9
View File
@@ -7,8 +7,10 @@ from fastapi import APIRouter, Request
from fastapi.params import Depends
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
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"])
@@ -22,33 +24,33 @@ def get_info(app) -> Dict:
"app_info": {
"title": app.title,
"version": app.version,
"description": app.description,
"description": app.description.rsplit('|', 1)[0],
},
"server_time": datetime.now().isoformat(),
}
@router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)):
async def root(request: Request, app=Depends(lambda: get_app())):
"""Эндпоинт главной страницы"""
return RedirectResponse("/books")
return templates.TemplateResponse(request, "index.html", get_info(app))
@router.get("/books", include_in_schema=False)
async def books(request: Request, app=Depends(get_app)):
async def books(request: Request, app=Depends(lambda: get_app())):
"""Эндпоинт страницы выбора книг"""
return templates.TemplateResponse(request, "books.html", get_info(app))
@router.get("/auth", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)):
async def auth(request: Request, app=Depends(lambda: get_app())):
"""Эндпоинт страницы авторизации"""
return templates.TemplateResponse(request, "auth.html", get_info(app))
@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"""
return templates.TemplateResponse(request, "api.html", get_info(app))
@@ -70,8 +72,27 @@ async def favicon():
@router.get(
"/api/info",
summary="Информация о сервисе",
description="Возвращает информацию о системе",
description="Возвращает общую информацию о системе",
)
async def api_info(app=Depends(get_app)):
async def api_info(app=Depends(lambda: get_app())):
"""Эндпоинт информации об API"""
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
View File
@@ -1,5 +1,5 @@
"""Модуль настроек проекта"""
import os
import os, logging
from dotenv import load_dotenv
from fastapi import FastAPI
@@ -12,39 +12,42 @@ with open("pyproject.toml", 'r', encoding='utf-8') as f:
config = load(f)
def get_app() -> FastAPI:
"""Dependency, для получение экземплярра FastAPI application"""
return FastAPI(
title=config["tool"]["poetry"]["name"],
description=config["tool"]["poetry"]["description"],
version=config["tool"]["poetry"]["version"],
openapi_tags=[
{
"name": "authentication",
"description": "Авторизация пользователя."
},
{
"name": "authors",
"description": "Действия с авторами.",
},
{
"name": "books",
"description": "Действия с книгами.",
},
{
"name": "genres",
"description": "Действия с жанрами.",
},
{
"name": "relations",
"description": "Действия с связями.",
},
{
"name": "misc",
"description": "Прочие.",
},
],
)
def get_app(lifespan=None, /) -> FastAPI:
"""Dependency для получения экземпляра FastAPI application"""
if not hasattr(get_app, 'instance'):
get_app.instance = FastAPI(
title=config["tool"]["poetry"]["name"],
description=config["tool"]["poetry"]["description"] + " | [Вернутьсяна главную](/)",
version=config["tool"]["poetry"]["version"],
lifespan=lifespan,
openapi_tags=[
{
"name": "authentication",
"description": "Авторизация пользователя."
},
{
"name": "authors",
"description": "Действия с авторами.",
},
{
"name": "books",
"description": "Действия с книгами.",
},
{
"name": "genres",
"description": "Действия с жанрами.",
},
{
"name": "relations",
"description": "Действия с связями.",
},
{
"name": "misc",
"description": "Прочие.",
},
],
)
return get_app.instance
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")
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():
"""Dependency, для получение сессии БД"""
with Session(engine) as session:
yield session
def get_logger(name: str = "uvicorn"):
"""Dependency, для получение логгера"""
return logging.getLogger(name)
+14 -8
View File
@@ -1,9 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<?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"
>
<path
fill-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"
fill="#000000"
/>
</svg>
fill-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"
fill="#000000"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

+443 -100
View File
@@ -1,4 +1,10 @@
$(document).ready(function () {
let selectedAuthors = new Map(); // Map<id, name>
let selectedGenres = new Map(); // Map<id, name>
let currentPage = 1;
let pageSize = 20;
let totalBooks = 0;
Promise.all([
fetch("/api/authors").then((response) => response.json()),
fetch("/api/genres").then((response) => response.json()),
@@ -6,113 +12,448 @@ $(document).ready(function () {
.then(([authorsData, genresData]) => {
const $dropdown = $("#author-dropdown");
authorsData.authors.forEach((author) => {
const $div = $("<div>", {
class: "p-2 hover:bg-gray-100 cursor-pointer",
"data-value": author.name,
text: author.name,
});
$dropdown.append($div);
$("<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) => {
const $li = $("<li>", { class: "mb-1" });
$li.html(`
<label class="custom-checkbox flex items-center">
<input type="checkbox" />
<span class="checkmark"></span>
${genre.name}
</label>
`);
$list.append($li);
$("<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 initializeAuthorDropdown() {
const $authorSearchInput = $("#author-search-input");
const $authorDropdown = $("#author-dropdown");
const $selectedAuthorsContainer = $("#selected-authors-container");
const $dropdownItems = $authorDropdown.find("[data-value]");
let selectedAuthors = new Set();
// === Функция загрузки книг ===
function loadBooks() {
const searchQuery = $("#book-search-input").val().trim();
const updateDropdownHighlights = () => {
$dropdownItems.each(function () {
const value = $(this).data("value");
$(this).toggleClass("bg-gray-200", selectedAuthors.has(value));
// Формируем URL с параметрами
const params = new URLSearchParams();
// Добавляем поиск (минимум 3 символа)
if (searchQuery.length >= 3) {
params.append("q", searchQuery);
}
// Добавляем авторов
selectedAuthors.forEach((name, id) => {
params.append("author_ids", id);
});
// Добавляем жанры
selectedGenres.forEach((name, id) => {
params.append("genre_ids", id);
});
// Пагинация
params.append("page", currentPage);
params.append("size", pageSize);
const url = `/api/books/filter?${params.toString()}`;
// Показываем индикатор загрузки
showLoadingState();
fetch(url)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((data) => {
totalBooks = data.total;
renderBooks(data.books);
renderPagination();
})
.catch((error) => {
console.error("Error loading books:", error);
showErrorState();
});
};
const renderSelectedAuthors = () => {
$selectedAuthorsContainer.children().not("#author-search-input").remove();
selectedAuthors.forEach((author) => {
const $authorChip = $("<span>", {
class:
"flex items-center bg-gray-200 text-gray-800 text-sm font-medium px-2.5 py-0.5 rounded-full",
});
$authorChip.html(`
${author}
<button type="button" class="ml-1 inline-flex items-center p-0.5 text-sm text-gray-400 bg-transparent rounded-sm hover:bg-gray-200 hover:text-gray-900" data-author="${author}">
<svg class="w-2 h-2" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
<span class="sr-only">Remove author</span>
</button>
`);
$selectedAuthorsContainer.append($authorChip);
});
updateDropdownHighlights();
};
$authorSearchInput.on("focus", () => {
$authorDropdown.removeClass("hidden");
});
$authorSearchInput.on("input", function () {
const query = $(this).val().toLowerCase();
$dropdownItems.each(function () {
const text = $(this).text().toLowerCase();
$(this).css("display", text.includes(query) ? "block" : "none");
});
$authorDropdown.removeClass("hidden");
});
$(document).on("click", function (event) {
if (
!$selectedAuthorsContainer.is(event.target) &&
!$selectedAuthorsContainer.has(event.target).length &&
!$authorDropdown.is(event.target) &&
!$authorDropdown.has(event.target).length
) {
$authorDropdown.addClass("hidden");
}
});
$authorDropdown.on("click", "[data-value]", function () {
const selectedValue = $(this).data("value");
if (selectedAuthors.has(selectedValue)) {
selectedAuthors.delete(selectedValue);
} else {
selectedAuthors.add(selectedValue);
}
$authorSearchInput.val("");
renderSelectedAuthors();
$authorSearchInput.focus();
});
$selectedAuthorsContainer.on("click", "button", function () {
const authorToRemove = $(this).data("author");
selectedAuthors.delete(authorToRemove);
renderSelectedAuthors();
$authorSearchInput.focus();
});
renderSelectedAuthors();
}
// === Отображение книг ===
function renderBooks(books) {
const $container = $("#books-container");
$container.empty();
if (books.length === 0) {
$container.html(`
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Книги не найдены</h3>
<p class="text-gray-500">Попробуйте изменить параметры поиска или фильтры</p>
</div>
`);
return;
}
books.forEach((book) => {
const authorsText =
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
const genresText =
book.genres.map((g) => g.name).join(", ") || "Без жанра";
const $bookCard = $(`
<div class="bg-white p-4 rounded-lg shadow-md mb-4 hover:shadow-lg transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-bold mb-1 text-gray-900 hover:text-blue-600 transition-colors">
${escapeHtml(book.title)}
</h3>
<p class="text-sm text-gray-600 mb-2">
<span class="font-medium">Авторы:</span> ${escapeHtml(authorsText)}
</p>
<p class="text-gray-700 text-sm mb-2">
${escapeHtml(book.description || "Описание отсутствует")}
</p>
<div class="flex flex-wrap gap-1">
${book.genres
.map(
(g) => `
<span class="inline-block bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full">
${escapeHtml(g.name)}
</span>
`,
)
.join("")}
</div>
</div>
</div>
</div>
`);
$container.append($bookCard);
});
// Обработчик клика на карточку книги
$container.on("click", ".book-card", function () {
const bookId = $(this).data("id");
window.location.href = `/books/${bookId}`;
});
}
// === Пагинация ===
function renderPagination() {
// Удаляем старую пагинацию
$("#pagination-container").remove();
const totalPages = Math.ceil(totalBooks / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div id="pagination-container" class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
// Генерируем номера страниц
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
${page}
</button>
`);
}
});
$("#books-container").after($pagination);
// Обработчики пагинации
$("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
loadBooks();
scrollToTop();
}
});
$("#next-page").on("click", function () {
if (currentPage < totalPages) {
currentPage++;
loadBooks();
scrollToTop();
}
});
$(".page-btn").on("click", function () {
const page = parseInt($(this).data("page"));
if (page !== currentPage) {
currentPage = page;
loadBooks();
scrollToTop();
}
});
}
function generatePageNumbers(current, total) {
const pages = [];
const delta = 2;
for (let i = 1; i <= total; i++) {
if (
i === 1 ||
i === total ||
(i >= current - delta && i <= current + delta)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return pages;
}
function scrollToTop() {
$("html, body").animate({ scrollTop: 0 }, 300);
}
// === Состояния загрузки ===
function showLoadingState() {
const $container = $("#books-container");
$container.html(`
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div class="flex gap-2">
<div class="h-6 bg-gray-200 rounded-full w-16"></div>
<div class="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</div>
`,
)
.join("")}
</div>
`);
}
function showErrorState() {
const $container = $("#books-container");
$container.html(`
<div class="bg-red-50 p-8 rounded-lg shadow-md text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
<p class="text-red-700 mb-4">Не удалось загрузить список книг</p>
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
Попробовать снова
</button>
</div>
`);
$("#retry-btn").on("click", loadBooks);
}
// === Экранирование HTML ===
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// === Dropdown авторов ===
function initializeAuthorDropdown() {
const $input = $("#author-search-input");
const $dropdown = $("#author-dropdown");
const $container = $("#selected-authors-container");
function updateHighlights() {
$dropdown.find(".author-item").each(function () {
const id = $(this).attr("data-id");
const isSelected = selectedAuthors.has(parseInt(id));
$(this)
.toggleClass("bg-gray-300 text-gray-600", isSelected)
.toggleClass("hover:bg-gray-100", !isSelected);
});
}
function filterDropdown(query) {
const lowerQuery = query.toLowerCase();
$dropdown.find(".author-item").each(function () {
$(this).toggle($(this).text().toLowerCase().includes(lowerQuery));
});
}
function renderChips() {
$container.find(".author-chip").remove();
selectedAuthors.forEach((name, id) => {
$(`<span class="author-chip flex items-center bg-gray-500 text-white text-sm font-medium px-2.5 py-0.5 rounded-full">
${escapeHtml(name)}
<button type="button" class="remove-author ml-1.5 inline-flex items-center p-0.5 text-gray-200 hover:text-white hover:bg-gray-600 rounded-full" data-id="${id}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 14 14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</span>`).insertBefore($input);
});
updateHighlights();
}
function toggleAuthor(id, name) {
id = parseInt(id);
if (selectedAuthors.has(id)) {
selectedAuthors.delete(id);
} else {
selectedAuthors.add(id, name);
selectedAuthors.set(id, name);
}
$input.val("");
filterDropdown("");
renderChips();
}
$input.on("focus", () => $dropdown.removeClass("hidden"));
$input.on("input", function () {
filterDropdown($(this).val().toLowerCase());
$dropdown.removeClass("hidden");
});
$(document).on("click", (e) => {
if (
!$(e.target).closest("#selected-authors-container, #author-dropdown")
.length
) {
$dropdown.addClass("hidden");
}
});
$dropdown.on("click", ".author-item", function (e) {
e.stopPropagation();
toggleAuthor($(this).attr("data-id"), $(this).attr("data-name"));
$input.focus();
});
$container.on("click", ".remove-author", function (e) {
e.stopPropagation();
selectedAuthors.delete(parseInt($(this).attr("data-id")));
renderChips();
$input.focus();
});
$container.on("click", (e) => {
if (!$(e.target).closest(".author-chip").length) {
$input.focus();
}
});
window.renderAuthorChips = renderChips;
window.updateAuthorHighlights = updateHighlights;
}
// === Инициализация фильтров ===
function initializeFilters() {
const $bookSearch = $("#book-search-input");
const $applyBtn = $("#apply-filters-btn");
const $resetBtn = $("#reset-filters-btn");
// Обработка жанров
$("#genres-list").on("change", "input[type='checkbox']", function () {
const id = parseInt($(this).attr("data-id"));
const name = $(this).attr("data-name");
if ($(this).is(":checked")) {
selectedGenres.set(id, name);
} else {
selectedGenres.delete(id);
}
});
// Применить фильтры
$applyBtn.on("click", function () {
currentPage = 1; // Сбрасываем на первую страницу
loadBooks();
});
// Сбросить фильтры
$resetBtn.on("click", function () {
$bookSearch.val("");
selectedAuthors.clear();
$("#selected-authors-container .author-chip").remove();
if (window.updateAuthorHighlights) window.updateAuthorHighlights();
selectedGenres.clear();
$("#genres-list input[type='checkbox']").prop("checked", false);
currentPage = 1;
loadBooks();
});
// Поиск с дебаунсом
let searchTimeout;
$bookSearch.on("input", function () {
clearTimeout(searchTimeout);
const query = $(this).val().trim();
// Автопоиск только если >= 3 символов или пусто
if (query.length >= 3 || query.length === 0) {
searchTimeout = setTimeout(() => {
currentPage = 1;
loadBooks();
}, 500);
}
});
// Поиск по Enter
$bookSearch.on("keypress", function (e) {
if (e.which === 13) {
clearTimeout(searchTimeout);
currentPage = 1;
loadBooks();
}
});
}
// === Остальной код (пользователь/авторизация) ===
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
@@ -182,11 +523,13 @@ $(document).ready(function () {
function updateUserAvatar(email) {
if (!email) return;
const cleanEmail = email.trim().toLowerCase();
const emailHash = sha256(cleanEmail);
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 avatarImg = document.getElementById("user-avatar");
if (avatarImg) {
avatarImg.src = avatarUrl;
}
}
const token = localStorage.getItem("access_token");
@@ -204,9 +547,9 @@ $(document).ready(function () {
.then((user) => {
showUser(user);
updateUserAvatar(user.email);
document.getElementById('user-btn').classList.remove('hidden');
document.getElementById('guest-link').classList.add('hidden');
document.getElementById("user-btn").classList.remove("hidden");
document.getElementById("guest-link").classList.add("hidden");
})
.catch(() => {
localStorage.removeItem("access_token");
@@ -214,4 +557,4 @@ $(document).ready(function () {
showGuest();
});
}
});
});
+274
View File
@@ -0,0 +1,274 @@
const svg = document.getElementById("bookSvg");
const NS = "http://www.w3.org/2000/svg";
const svgWidth = 200;
const svgHeight = 250;
const lineCount = 5;
const lineDelay = 16;
const bookWidth = 120;
const bookHeight = 180;
const bookX = (svgWidth - bookWidth) / 2;
const bookY = (svgHeight - bookHeight) / 2;
const desiredLineSpacing = 8;
const baseLineWidth = 2;
const maxLineWidth = 10;
const maxLineHeight = bookHeight - 24;
const innerPaddingX = 10;
const appearStagger = 8;
let lineSpacing;
if (lineCount > 1) {
const maxSpan = Math.max(0, bookWidth - maxLineWidth - 2 * innerPaddingX);
const wishSpan = desiredLineSpacing * (lineCount - 1);
const realSpan = Math.min(wishSpan, maxSpan);
lineSpacing = realSpan / (lineCount - 1);
} else {
lineSpacing = 0;
}
const linesSpan = lineSpacing * (lineCount - 1);
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
const lineStartX = rightBase - linesSpan;
const leftLimit = bookX + innerPaddingX;
let phase = 0;
let time = 0;
const baseAppearDuration = 40;
const appearDuration = baseAppearDuration + (lineCount - 1) * appearStagger;
const baseFlipDuration = 120;
const flipDuration = baseFlipDuration + (lineCount - 1) * lineDelay;
const baseDisappearDuration = 40;
const disappearDuration =
baseDisappearDuration + (lineCount - 1) * appearStagger;
const pauseDuration = 30;
const book = document.createElementNS(NS, "rect");
book.setAttribute("x", bookX);
book.setAttribute("y", bookY);
book.setAttribute("width", bookWidth);
book.setAttribute("height", bookHeight);
book.setAttribute("fill", "#374151");
book.setAttribute("rx", "4");
svg.appendChild(book);
const lines = [];
for (let i = 0; i < lineCount; i++) {
const line = document.createElementNS(NS, "rect");
line.setAttribute("fill", "#ffffff");
line.setAttribute("rx", "1");
svg.appendChild(line);
const baseX = lineStartX + i * lineSpacing;
const targetX = leftLimit + i * lineSpacing;
const moveDistance = baseX - targetX;
lines.push({
el: line,
baseX,
targetX,
moveDistance,
currentX: baseX,
width: baseLineWidth,
height: 0,
});
}
function easeInOutQuad(t) {
return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
}
function easeOutQuad(t) {
return 1 - (1 - t) * (1 - t);
}
function easeInQuad(t) {
return t * t;
}
function updateLine(line) {
const el = line.el;
const centerY = bookY + bookHeight / 2;
el.setAttribute("x", line.currentX);
el.setAttribute("y", centerY - line.height / 2);
el.setAttribute("width", line.width);
el.setAttribute("height", Math.max(0, line.height));
}
function animateBook() {
time++;
if (phase === 0) {
for (let i = 0; i < lineCount; i++) {
const delay = (lineCount - 1 - i) * appearStagger;
const localTime = Math.max(0, time - delay);
const progress = Math.min(1, localTime / baseAppearDuration);
const easedProgress = easeOutQuad(progress);
lines[i].height = maxLineHeight * easedProgress;
lines[i].currentX = lines[i].baseX;
lines[i].width = baseLineWidth;
updateLine(lines[i]);
}
if (time >= appearDuration) {
phase = 1;
time = 0;
}
} else if (phase === 1) {
for (let i = 0; i < lineCount; i++) {
const delay = i * lineDelay;
const localTime = Math.max(0, time - delay);
const progress = Math.min(1, localTime / baseFlipDuration);
const moveProgress = easeInOutQuad(progress);
lines[i].currentX = lines[i].baseX - lines[i].moveDistance * moveProgress;
const widthProgress =
progress < 0.5
? easeOutQuad(progress * 2)
: 1 - easeInQuad((progress - 0.5) * 2);
lines[i].width =
baseLineWidth + (maxLineWidth - baseLineWidth) * widthProgress;
updateLine(lines[i]);
}
if (time >= flipDuration) {
phase = 2;
time = 0;
}
} else if (phase === 2) {
for (let i = 0; i < lineCount; i++) {
const delay = (lineCount - 1 - i) * appearStagger;
const localTime = Math.max(0, time - delay);
const progress = Math.min(1, localTime / baseDisappearDuration);
const easedProgress = easeInQuad(progress);
lines[i].height = maxLineHeight * (1 - easedProgress);
updateLine(lines[i]);
}
if (time >= disappearDuration + pauseDuration) {
phase = 0;
time = 0;
for (let i = 0; i < lineCount; i++) {
lines[i].currentX = lines[i].baseX;
lines[i].width = baseLineWidth;
lines[i].height = 0;
}
}
}
requestAnimationFrame(animateBook);
}
animateBook();
function animateCounter(element, target, duration = 2000) {
const start = 0;
const startTime = performance.now();
function update(currentTime) {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const easedProgress = 1 - Math.pow(1 - progress, 3);
const current = Math.floor(start + (target - start) * easedProgress);
element.textContent = current.toLocaleString("ru-RU");
if (progress < 1) {
requestAnimationFrame(update);
} else {
element.textContent = target.toLocaleString("ru-RU");
}
}
requestAnimationFrame(update);
}
async function loadStats() {
try {
const response = await fetch("/api/stats");
if (!response.ok) {
throw new Error("Ошибка загрузки статистики");
}
const stats = await response.json();
setTimeout(() => {
const booksEl = document.getElementById("stat-books");
const authorsEl = document.getElementById("stat-authors");
const genresEl = document.getElementById("stat-genres");
const usersEl = document.getElementById("stat-users");
if (booksEl) {
animateCounter(booksEl, stats.books, 1500);
}
setTimeout(() => {
if (authorsEl) {
animateCounter(authorsEl, stats.authors, 1500);
}
}, 150);
setTimeout(() => {
if (genresEl) {
animateCounter(genresEl, stats.genres, 1500);
}
}, 300);
setTimeout(() => {
if (usersEl) {
animateCounter(usersEl, stats.users, 1500);
}
}, 450);
}, 500);
} catch (error) {
console.error("Ошибка загрузки статистики:", error);
document.getElementById("stat-books").textContent = "—";
document.getElementById("stat-authors").textContent = "—";
document.getElementById("stat-genres").textContent = "—";
document.getElementById("stat-users").textContent = "—";
}
}
function observeStatCards() {
const cards = document.querySelectorAll(".stat-card");
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry, index) => {
if (entry.isIntersecting) {
setTimeout(() => {
entry.target.classList.add("animate-fade-in");
entry.target.style.opacity = "1";
entry.target.style.transform = "translateY(0)";
}, index * 100);
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1 },
);
cards.forEach((card) => {
card.style.opacity = "0";
card.style.transform = "translateY(20px)";
card.style.transition = "opacity 0.5s ease, transform 0.5s ease";
observer.observe(card);
});
}
document.addEventListener("DOMContentLoaded", () => {
loadStats();
observeStatCards();
});
+92 -12
View File
@@ -19,7 +19,6 @@ nav ul li a {
font-size: large;
}
/* Custom checkbox styles */
.custom-checkbox {
display: inline-block;
position: relative;
@@ -40,7 +39,7 @@ nav ul li a {
height: 18px;
width: 18px;
background-color: #fff;
border: 2px solid #d1d5db; /* gray-300 */
border: 2px solid #d1d5db;
border-radius: 4px;
transition: all 0.2s ease;
display: inline-block;
@@ -48,11 +47,11 @@ nav ul li a {
}
.custom-checkbox:hover input ~ .checkmark {
border-color: #6b7280; /* gray-500 */
border-color: #6b7280;
}
.custom-checkbox input:checked ~ .checkmark {
background-color: #6b7280; /* gray-500 */
background-color: #6b7280;
border-color: #6b7280;
}
@@ -114,12 +113,29 @@ button:disabled {
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
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 {
#req-length,
#req-upper,
#req-lower,
#req-digit {
transition: color 0.2s ease;
}
@@ -145,7 +161,8 @@ button:disabled {
}
}
#login-tab, #register-tab {
#login-tab,
#register-tab {
font-family: "Dited", sans-serif;
letter-spacing: 1.5px;
cursor: pointer;
@@ -156,10 +173,73 @@ button:disabled {
}
@keyframes dropdownFade {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
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;
}
+222 -88
View File
@@ -1,80 +1,135 @@
<!-- templates/auth.html -->
{% extends "base.html" %}
{% block title %}LiB - Авторизация{% endblock %}
{% block content %}
{% 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>
<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"
<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>
<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"
<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"
<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
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
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">
<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>
<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"
<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>
>
Войти
</button>
</form>
<form id="register-form" class="p-6 hidden" onsubmit="return handleRegister(event)">
<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"
<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 символа)"
@@ -83,37 +138,49 @@
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"
<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"
<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>
<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"
<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"
@@ -121,62 +188,131 @@
minlength="8"
maxlength="100"
/>
<button
type="button"
<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
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
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
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>
<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>
<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"
<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"
<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
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
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>
<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"
<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"
>
@@ -186,8 +322,6 @@
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
{% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/auth.js"></script>
{% endblock %}
{% endblock %}
+61 -48
View File
@@ -1,66 +1,79 @@
{% extends "base.html" %}
{% block title %}LiB - Главная{% endblock %}
{% block content %}
{% 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">
<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" id="selected-authors-container">
<input type="text" id="author-search-input" class="flex-grow outline-none bg-transparent" placeholder="Начните вводить..." />
<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"></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"></ul>
<ul id="genres-list" class="max-h-60 overflow-y-auto"></ul>
</div>
<button class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200">
<button
id="apply-filters-btn"
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 mb-2"
>
Применить фильтры
</button>
<button
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 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>
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
<div>
<h3 class="text-lg font-bold mb-1">Product Title 2</h3>
<p class="text-gray-700 text-sm">
Another great product with amazing features. You'll
love it!
</p>
</div>
<span class="text-lg font-semibold text-blue-600">$49.99</span>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
<div>
<h3 class="text-lg font-bold mb-1">Product Title 3</h3>
<p class="text-gray-700 text-sm">
This product is a must-have for every modern home.
High quality and durable.
</p>
</div>
<span class="text-lg font-semibold text-gray-600">$19.99</span>
</div>
<div id="books-container"></div>
</main>
</div>
{% endblock %}
{% block scripts %}
{% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/books.js"></script>
{% endblock %}
{% endblock %}
+195
View File
@@ -0,0 +1,195 @@
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {%
block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-4xl">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="text-center py-8 border-b border-gray-200">
<h2 class="text-3xl font-bold text-gray-800 mb-2">
Добро пожаловать в LiB
</h2>
<p class="text-gray-500">Ваша персональная библиотека книг</p>
</div>
<div class="p-8">
<div
class="flex flex-col lg:flex-row items-center justify-center gap-12"
>
<div class="flex-shrink-0">
<svg
id="bookSvg"
width="400"
height="500"
viewBox="0 0 200 250"
class="drop-shadow-lg"
></svg>
</div>
<div class="grid grid-cols-2 gap-6">
<div
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
>
<div class="mb-3">
<svg
class="w-10 h-10 mx-auto text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
</div>
<div
id="stat-books"
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
data-target="0"
>
0
</div>
<div class="text-sm text-gray-500 font-medium">
Книг
</div>
</div>
<div
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
>
<div class="mb-3">
<svg
class="w-10 h-10 mx-auto text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</div>
<div
id="stat-authors"
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
data-target="0"
>
0
</div>
<div class="text-sm text-gray-500 font-medium">
Авторов
</div>
</div>
<div
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
>
<div class="mb-3">
<svg
class="w-10 h-10 mx-auto text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
></path>
</svg>
</div>
<div
id="stat-genres"
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
data-target="0"
>
0
</div>
<div class="text-sm text-gray-500 font-medium">
Жанров
</div>
</div>
<div
class="stat-card bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 text-center transform transition-all duration-300 hover:scale-105 hover:shadow-lg"
>
<div class="mb-3">
<svg
class="w-10 h-10 mx-auto text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
</div>
<div
id="stat-users"
class="text-4xl font-bold text-gray-800 mb-1 stat-number"
data-target="0"
>
0
</div>
<div class="text-sm text-gray-500 font-medium">
Пользователей
</div>
</div>
</div>
</div>
</div>
<div class="px-8 pb-8">
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a
href="/books"
class="inline-flex items-center justify-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition duration-200 font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
Смотреть книги
</a>
<a
href="/authors"
class="inline-flex items-center justify-center px-6 py-3 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition duration-200 font-medium shadow-sm hover:shadow-md transform hover:-translate-y-0.5"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
></path>
</svg>
Все авторы
</a>
</div>
</div>
</div>
<div class="mt-6 text-center text-gray-400 text-sm">
<p>LiB — Библиотека. Создано с ❤️</p>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/index.js"></script>
{% endblock %}
+2 -2
View File
@@ -6,7 +6,6 @@ from sqlmodel import SQLModel
from library_service.settings import POSTGRES_DATABASE_URL
print(POSTGRES_DATABASE_URL)
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
@@ -16,7 +15,8 @@ config.set_main_option("sqlalchemy.url", POSTGRES_DATABASE_URL)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
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
# for 'autogenerate' support