diff --git a/data.py b/data.py
index c8675a5..98ff870 100644
--- a/data.py
+++ b/data.py
@@ -2,8 +2,8 @@ import requests
from typing import Optional
# Конфигурация
-USERNAME = "sys-admin"
-PASSWORD = "wTKPVqTIMqzXL2EZxYz80w"
+USERNAME = "admin"
+PASSWORD = "n_ElBL9LTfTTgZSqHShqOg"
BASE_URL = "http://localhost:8000"
@@ -127,7 +127,6 @@ def main():
print("Не удалось авторизоваться. Проверьте логин и пароль.")
return
- # === АВТОРЫ (12 авторов) ===
print("\n📚 Создание авторов...")
authors_data = [
"Лев Толстой",
@@ -150,7 +149,6 @@ def main():
if author_id:
authors[name] = author_id
- # === ЖАНРЫ (8 жанров) ===
print("\n🏷️ Создание жанров...")
genres_data = [
"Роман",
@@ -169,7 +167,6 @@ def main():
if genre_id:
genres[name] = genre_id
- # === КНИГИ (25 книг) ===
print("\n📖 Создание книг...")
books_data = [
{
@@ -334,23 +331,19 @@ def main():
"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)}")
diff --git a/library_service/auth.py b/library_service/auth.py
index de4d590..7a22980 100644
--- a/library_service/auth.py
+++ b/library_service/auth.py
@@ -147,8 +147,8 @@ def seed_roles(session: Session) -> dict[str, Role]:
"""Создаёт роли по умолчанию, если их нет."""
default_roles = [
{"name": "admin", "description": "Администратор системы"},
- {"name": "moderator", "description": "Модератор"},
- {"name": "user", "description": "Обычный пользователь"},
+ {"name": "librarian", "description": "Библиотекарь"},
+ {"name": "member", "description": "Посетитель библиотеки"},
]
roles = {}
diff --git a/library_service/models/db/role.py b/library_service/models/db/role.py
index 4aab599..bc5e6c2 100644
--- a/library_service/models/db/role.py
+++ b/library_service/models/db/role.py
@@ -16,5 +16,4 @@ class Role(RoleBase, table=True):
id: int | None = Field(default=None, primary_key=True, index=True)
- # Связи
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
diff --git a/library_service/models/dto/combined.py b/library_service/models/dto/combined.py
index ae0ba5c..8e0f46c 100644
--- a/library_service/models/dto/combined.py
+++ b/library_service/models/dto/combined.py
@@ -11,7 +11,6 @@ class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
id: int
name: str
- bio: str
books: List[BookRead] = Field(default_factory=list)
diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py
index 5199be1..ee923c8 100644
--- a/library_service/routers/auth.py
+++ b/library_service/routers/auth.py
@@ -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"
)
- # Создание пользователя
db_user = User(
**user_data.model_dump(exclude={"password"}),
hashed_password=get_password_hash(user_data.password)
)
- # Назначение роли по умолчанию
default_role = session.exec(select(Role).where(Role.name == "user")).first()
if default_role:
db_user.roles.append(default_role)
@@ -154,3 +152,85 @@ def read_users(
UserRead(**user.model_dump(), roles=[role.name for role in user.roles])
for user in 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])
\ No newline at end of file
diff --git a/library_service/routers/books.py b/library_service/routers/books.py
index 90388ae..fe2b1c9 100644
--- a/library_service/routers/books.py
+++ b/library_service/routers/books.py
@@ -6,7 +6,7 @@ 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, GenreBookLink
+from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
from library_service.models.dto.combined import (
BookWithAuthorsAndGenres,
diff --git a/library_service/routers/genres.py b/library_service/routers/genres.py
index 8ff9fe4..2281e42 100644
--- a/library_service/routers/genres.py
+++ b/library_service/routers/genres.py
@@ -10,7 +10,6 @@ from library_service.settings import get_session
router = APIRouter(prefix="/genres", tags=["genres"])
-# Создание жанра
@router.post(
"/",
response_model=GenreRead,
@@ -30,7 +29,6 @@ def create_genre(
return GenreRead(**db_genre.model_dump())
-# Чтение жанров
@router.get(
"/",
response_model=GenreList,
@@ -45,7 +43,6 @@ def read_genres(session: Session = Depends(get_session)):
)
-# Чтение жанра с его книгами
@router.get(
"/{genre_id}",
response_model=GenreWithBooks,
@@ -73,7 +70,6 @@ def get_genre(
return GenreWithBooks(**genre_data)
-# Обновление жанра
@router.put(
"/{genre_id}",
response_model=GenreRead,
@@ -100,7 +96,6 @@ def update_genre(
return GenreRead(**db_genre.model_dump())
-# Удаление жанра
@router.delete(
"/{genre_id}",
response_model=GenreRead,
diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py
index 1133ca7..3de7965 100644
--- a/library_service/routers/misc.py
+++ b/library_service/routers/misc.py
@@ -31,21 +31,39 @@ def get_info(app) -> Dict:
@router.get("/", include_in_schema=False)
-async def root(request: Request, app=Depends(lambda: 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, app=Depends(lambda: get_app())):
+async def books(request: Request):
"""Эндпоинт страницы выбора книг"""
- return templates.TemplateResponse(request, "books.html", get_info(app))
+ 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, app=Depends(lambda: get_app())):
+async def auth(request: Request):
"""Эндпоинт страницы авторизации"""
- return templates.TemplateResponse(request, "auth.html", get_info(app))
+ return templates.TemplateResponse(request, "auth.html")
diff --git a/library_service/static/author.js b/library_service/static/author.js
new file mode 100644
index 0000000..06c5dcf
--- /dev/null
+++ b/library_service/static/author.js
@@ -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(`
+
+
+
+ ${firstLetter}
+
+
+
+
+
+ `);
+ }
+
+ function renderBooks(books) {
+ const $container = $("#books-container");
+ $container.empty();
+
+ if (!books || books.length === 0) {
+ $container.html(`
+
+
+
У этого автора пока нет книг в библиотеке
+
+ `);
+ return;
+ }
+
+ const $grid = $('');
+
+ books.forEach((book) => {
+ const $bookCard = $(`
+
+
+
+
+ ${escapeHtml(book.title)}
+
+
+ ${escapeHtml(book.description || "Описание отсутствует")}
+
+
+
+
+
+ `);
+
+ $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(`
+
+ `);
+
+ $booksContainer.html(`
+
+ ${Array(3)
+ .fill()
+ .map(
+ () => `
+
+ `
+ )
+ .join("")}
+
+ `);
+ }
+
+ function showErrorState(message) {
+ const $authorCard = $("#author-card");
+ const $booksSection = $("#books-section");
+
+ $booksSection.hide();
+
+ $authorCard.html(`
+
+
+
${escapeHtml(message)}
+
Не удалось загрузить информацию об авторе
+
+
+ `);
+
+ $("#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();
+ });
+ }
+ });
\ No newline at end of file
diff --git a/library_service/static/authors.js b/library_service/static/authors.js
new file mode 100644
index 0000000..fb7f909
--- /dev/null
+++ b/library_service/static/authors.js
@@ -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(`
+
+
+
Авторы не найдены
+
Попробуйте изменить параметры поиска
+
+ `);
+ return;
+ }
+
+ const startIndex = (currentPage - 1) * pageSize;
+ const endIndex = startIndex + pageSize;
+ const pageAuthors = filteredAuthors.slice(startIndex, endIndex);
+
+ const $grid = $('');
+
+ pageAuthors.forEach((author) => {
+ const firstLetter = author.name.charAt(0).toUpperCase();
+
+ const $authorCard = $(`
+
+
+
+ ${firstLetter}
+
+
+
+ ${escapeHtml(author.name)}
+
+
ID: ${author.id}
+
+
+
+
+ `);
+
+ $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 = $(`
+
+ `);
+
+ const $pageNumbers = $pagination.find("#page-numbers");
+
+ const pages = generatePageNumbers(currentPage, totalPages);
+
+ pages.forEach((page) => {
+ if (page === "...") {
+ $pageNumbers.append(`...`);
+ } else {
+ const isActive = page === currentPage;
+ $pageNumbers.append(`
+
+ `);
+ }
+ });
+
+ $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(`
+
+ ${Array(6)
+ .fill()
+ .map(
+ () => `
+
+ `
+ )
+ .join("")}
+
+ `);
+ }
+
+ function showErrorState() {
+ const $container = $("#authors-container");
+ $container.html(`
+
+
+
Ошибка загрузки
+
Не удалось загрузить список авторов
+
+
+ `);
+
+ $("#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();
+ });
+ }
+ });
\ No newline at end of file
diff --git a/library_service/static/book.js b/library_service/static/book.js
new file mode 100644
index 0000000..ca50a45
--- /dev/null
+++ b/library_service/static/book.js
@@ -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(`
+
+
+
+
+
+
+
+
${escapeHtml(book.title)}
+ ID: ${book.id}
+
+
+
+ ${escapeHtml(authorsText)}
+
+
+
+
+ ${escapeHtml(book.description || "Описание отсутствует")}
+
+
+
+
+
+
+ Вернуться к списку книг
+
+
+
+ `);
+ }
+
+ function renderAuthors(authors) {
+ const $container = $("#authors-container");
+ const $section = $("#authors-section");
+ $container.empty();
+
+ if (!authors || authors.length === 0) {
+ $section.hide();
+ return;
+ }
+
+ const $grid = $('');
+
+ authors.forEach((author) => {
+ const firstLetter = author.name.charAt(0).toUpperCase();
+
+ const $authorCard = $(`
+
+
+ ${firstLetter}
+
+ ${escapeHtml(author.name)}
+
+
+ `);
+
+ $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 = $('');
+
+ genres.forEach((genre) => {
+ const $genreTag = $(`
+
+
+ ${escapeHtml(genre.name)}
+
+ `);
+
+ $grid.append($genreTag);
+ });
+
+ $container.append($grid);
+ }
+
+ function showLoadingState() {
+ const $bookCard = $("#book-card");
+ const $authorsContainer = $("#authors-container");
+ const $genresContainer = $("#genres-container");
+
+ $bookCard.html(`
+
+ `);
+
+ $authorsContainer.html(`
+
+ `);
+
+ $genresContainer.html(`
+
+ `);
+ }
+
+ function showErrorState(message) {
+ const $bookCard = $("#book-card");
+ const $authorsSection = $("#authors-section");
+ const $genresSection = $("#genres-section");
+
+ $authorsSection.hide();
+ $genresSection.hide();
+
+ $bookCard.html(`
+
+
+
${escapeHtml(message)}
+
Не удалось загрузить информацию о книге
+
+
+ `);
+
+ $("#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();
+ });
+ }
+ });
\ No newline at end of file
diff --git a/library_service/static/books.js b/library_service/static/books.js
index 938709e..b9bd30f 100644
--- a/library_service/static/books.js
+++ b/library_service/static/books.js
@@ -1,6 +1,6 @@
-$(document).ready(function () {
- let selectedAuthors = new Map(); // Map
- let selectedGenres = new Map(); // Map
+$(document).ready(() => {
+ let selectedAuthors = new Map();
+ let selectedGenres = new Map();
let currentPage = 1;
let pageSize = 20;
let totalBooks = 0;
@@ -36,41 +36,32 @@ $(document).ready(function () {
initializeAuthorDropdown();
initializeFilters();
-
- // Загружаем книги при старте
+
loadBooks();
})
.catch((error) => console.error("Error loading data:", error));
- // === Функция загрузки книг ===
function loadBooks() {
const searchQuery = $("#book-search-input").val().trim();
- // Формируем 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)
@@ -91,7 +82,6 @@ $(document).ready(function () {
});
}
- // === Отображение книг ===
function renderBooks(books) {
const $container = $("#books-container");
$container.empty();
@@ -146,17 +136,14 @@ $(document).ready(function () {
$container.append($bookCard);
});
-
- // Обработчик клика на карточку книги
+
$container.on("click", ".book-card", function () {
const bookId = $(this).data("id");
- window.location.href = `/books/${bookId}`;
+ window.location.href = `/book/${bookId}`;
});
}
- // === Пагинация ===
function renderPagination() {
- // Удаляем старую пагинацию
$("#pagination-container").remove();
const totalPages = Math.ceil(totalBooks / pageSize);
@@ -181,7 +168,6 @@ $(document).ready(function () {
const $pageNumbers = $pagination.find("#page-numbers");
- // Генерируем номера страниц
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
@@ -199,7 +185,6 @@ $(document).ready(function () {
$("#books-container").after($pagination);
- // Обработчики пагинации
$("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
@@ -249,7 +234,6 @@ $(document).ready(function () {
$("html, body").animate({ scrollTop: 0 }, 300);
}
- // === Состояния загрузки ===
function showLoadingState() {
const $container = $("#books-container");
$container.html(`
@@ -292,7 +276,6 @@ $(document).ready(function () {
$("#retry-btn").on("click", loadBooks);
}
- // === Экранирование HTML ===
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
@@ -300,7 +283,6 @@ $(document).ready(function () {
return div.innerHTML;
}
- // === Dropdown авторов ===
function initializeAuthorDropdown() {
const $input = $("#author-search-input");
const $dropdown = $("#author-dropdown");
@@ -390,13 +372,11 @@ $(document).ready(function () {
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");
@@ -407,13 +387,11 @@ $(document).ready(function () {
}
});
- // Применить фильтры
$applyBtn.on("click", function () {
- currentPage = 1; // Сбрасываем на первую страницу
+ currentPage = 1;
loadBooks();
});
- // Сбросить фильтры
$resetBtn.on("click", function () {
$bookSearch.val("");
@@ -428,13 +406,11 @@ $(document).ready(function () {
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;
@@ -443,7 +419,6 @@ $(document).ready(function () {
}
});
- // Поиск по Enter
$bookSearch.on("keypress", function (e) {
if (e.which === 13) {
clearTimeout(searchTimeout);
@@ -453,7 +428,6 @@ $(document).ready(function () {
});
}
- // === Остальной код (пользователь/авторизация) ===
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
diff --git a/library_service/static/index.js b/library_service/static/index.js
index 21523b9..aa5c155 100644
--- a/library_service/static/index.js
+++ b/library_service/static/index.js
@@ -1,4 +1,4 @@
-const svg = document.getElementById("bookSvg");
+const $svg = $("#bookSvg");
const NS = "http://www.w3.org/2000/svg";
const svgWidth = 200;
@@ -48,27 +48,27 @@ const disappearDuration =
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);
+$(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.setAttribute("fill", "#ffffff");
- line.setAttribute("rx", "1");
- svg.appendChild(line);
+ $(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,
+ el: $(line),
baseX,
targetX,
moveDistance,
@@ -91,13 +91,14 @@ function easeInQuad(t) {
}
function updateLine(line) {
- const el = line.el;
+ 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));
+ $el
+ .attr("x", line.currentX)
+ .attr("y", centerY - line.height / 2)
+ .attr("width", line.width)
+ .attr("height", Math.max(0, line.height));
}
function animateBook() {
@@ -171,7 +172,7 @@ function animateBook() {
animateBook();
-function animateCounter(element, target, duration = 2000) {
+function animateCounter($element, target, duration = 2000) {
const start = 0;
const startTime = performance.now();
@@ -182,12 +183,12 @@ function animateCounter(element, target, duration = 2000) {
const easedProgress = 1 - Math.pow(1 - progress, 3);
const current = Math.floor(start + (target - start) * easedProgress);
- element.textContent = current.toLocaleString("ru-RU");
+ $element.text(current.toLocaleString("ru-RU"));
if (progress < 1) {
requestAnimationFrame(update);
} else {
- element.textContent = target.toLocaleString("ru-RU");
+ $element.text(target.toLocaleString("ru-RU"));
}
}
@@ -204,71 +205,180 @@ async function loadStats() {
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");
+ const $booksEl = $("#stat-books");
+ const $authorsEl = $("#stat-authors");
+ const $genresEl = $("#stat-genres");
+ const $usersEl = $("#stat-users");
- if (booksEl) {
- animateCounter(booksEl, stats.books, 1500);
+ if ($booksEl.length) {
+ animateCounter($booksEl, stats.books, 1500);
}
setTimeout(() => {
- if (authorsEl) {
- animateCounter(authorsEl, stats.authors, 1500);
+ if ($authorsEl.length) {
+ animateCounter($authorsEl, stats.authors, 1500);
}
}, 150);
setTimeout(() => {
- if (genresEl) {
- animateCounter(genresEl, stats.genres, 1500);
+ if ($genresEl.length) {
+ animateCounter($genresEl, stats.genres, 1500);
}
}, 300);
setTimeout(() => {
- if (usersEl) {
- animateCounter(usersEl, stats.users, 1500);
+ if ($usersEl.length) {
+ 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 = "—";
+ $("#stat-books").text("—");
+ $("#stat-authors").text("—");
+ $("#stat-genres").text("—");
+ $("#stat-users").text("—");
}
}
function observeStatCards() {
- const cards = document.querySelectorAll(".stat-card");
+ const $cards = $(".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)";
+ $(entry.target)
+ .addClass("animate-fade-in")
+ .css({
+ opacity: "1",
+ transform: "translateY(0)",
+ });
}, index * 100);
observer.unobserve(entry.target);
}
});
},
- { threshold: 0.1 },
+ { 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";
+ $cards.each((index, card) => {
+ $(card).css({
+ opacity: "0",
+ transform: "translateY(20px)",
+ transition: "opacity 0.5s ease, transform 0.5s ease",
+ });
observer.observe(card);
});
}
-document.addEventListener("DOMContentLoaded", () => {
+$(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();
+ });
+ }
});
diff --git a/library_service/static/styles.css b/library_service/static/styles.css
index c641c27..db5bc29 100644
--- a/library_service/static/styles.css
+++ b/library_service/static/styles.css
@@ -243,3 +243,10 @@ button:disabled {
-webkit-text-fill-color: transparent;
background-clip: text;
}
+
+.line-clamp-3 {
+display: -webkit-box;
+-webkit-line-clamp: 3;
+-webkit-box-orient: vertical;
+overflow: hidden;
+}
diff --git a/library_service/templates/author.html b/library_service/templates/author.html
new file mode 100644
index 0000000..94c850b
--- /dev/null
+++ b/library_service/templates/author.html
@@ -0,0 +1,16 @@
+{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %} {% block
+content %}
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/library_service/templates/authors.html b/library_service/templates/authors.html
new file mode 100644
index 0000000..656cf12
--- /dev/null
+++ b/library_service/templates/authors.html
@@ -0,0 +1,72 @@
+{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %} {% block
+content %}
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
\ No newline at end of file
diff --git a/library_service/templates/base.html b/library_service/templates/base.html
index cb9d1c3..2528d15 100644
--- a/library_service/templates/base.html
+++ b/library_service/templates/base.html
@@ -21,6 +21,7 @@
diff --git a/library_service/templates/book.html b/library_service/templates/book.html
new file mode 100644
index 0000000..26fb095
--- /dev/null
+++ b/library_service/templates/book.html
@@ -0,0 +1,21 @@
+{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %} {% block
+content %}
+
+{% endblock %} {% block scripts %}
+
+{% endblock %}
\ No newline at end of file