diff --git a/data.py b/data.py
index 46166c7..c9463b5 100644
--- a/data.py
+++ b/data.py
@@ -3,7 +3,7 @@ from typing import Optional
# Конфигурация
USERNAME = "admin"
-PASSWORD = "TzUlDpUCHutFa-oGCd1cBw"
+PASSWORD = "4ai2_pQnrJ1-tDx-XSLTKw"
BASE_URL = "http://localhost:8000"
@@ -339,7 +339,7 @@ def main():
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)
diff --git a/library_service/auth.py b/library_service/auth.py
index 7a22980..178e0d3 100644
--- a/library_service/auth.py
+++ b/library_service/auth.py
@@ -146,9 +146,9 @@ RequireModerator = Annotated[User, Depends(require_role("moderator"))]
def seed_roles(session: Session) -> dict[str, Role]:
"""Создаёт роли по умолчанию, если их нет."""
default_roles = [
- {"name": "admin", "description": "Администратор системы"},
- {"name": "librarian", "description": "Библиотекарь"},
- {"name": "member", "description": "Посетитель библиотеки"},
+ {"name": "admin", "description": "Администратор системы", "payroll": 80000},
+ {"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
+ {"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
]
roles = {}
diff --git a/library_service/models/dto/combined.py b/library_service/models/dto/combined.py
index 2e328ae..ed3c3af 100644
--- a/library_service/models/dto/combined.py
+++ b/library_service/models/dto/combined.py
@@ -6,7 +6,7 @@ from .author import AuthorRead
from .genre import GenreRead
from .book import BookRead
from .loan import LoanRead
-
+from ..enums import BookStatus
class AuthorWithBooks(SQLModel):
"""Модель автора с книгами"""
@@ -35,6 +35,7 @@ class BookWithGenres(SQLModel):
id: int
title: str
description: str
+ status: BookStatus | None = None
genres: List[GenreRead] = Field(default_factory=list)
@@ -43,6 +44,7 @@ class BookWithAuthorsAndGenres(SQLModel):
id: int
title: str
description: str
+ status: BookStatus | None = None
authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list)
@@ -55,7 +57,7 @@ class BookFilteredList(SQLModel):
class LoanWithBook(LoanRead):
"""Модель выдачи, включающая данные о книге"""
book: BookRead
-
+
class BookStatusUpdate(SQLModel):
"""Модель для ручного изменения статуса библиотекарем"""
- status: str
\ No newline at end of file
+ status: str
diff --git a/library_service/models/dto/role.py b/library_service/models/dto/role.py
index 6a0326f..a3628a3 100644
--- a/library_service/models/dto/role.py
+++ b/library_service/models/dto/role.py
@@ -8,6 +8,7 @@ class RoleBase(SQLModel):
"""Базовая модель роли"""
name: str
description: str | None = None
+ payroll: int
class RoleCreate(RoleBase):
diff --git a/library_service/routers/books.py b/library_service/routers/books.py
index fe2b1c9..4644d83 100644
--- a/library_service/routers/books.py
+++ b/library_service/routers/books.py
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/books", tags=["books"])
)
def filter_books(
session: Session = Depends(get_session),
- q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
+ q: str | None = Query(None, 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="Номер страницы"),
diff --git a/library_service/static/auth.js b/library_service/static/auth.js
index 14c64fe..c6bd10b 100644
--- a/library_service/static/auth.js
+++ b/library_service/static/auth.js
@@ -1,287 +1,69 @@
$(function () {
- const $loginTab = $("#login-tab");
- const $registerTab = $("#register-tab");
- const $loginForm = $("#login-form");
- const $registerForm = $("#register-form");
-
- const $guestLink = $("#guest-link");
- const $userBtn = $("#user-btn");
- const $userDropdown = $("#user-dropdown");
- const $userArrow = $("#user-arrow");
- const $userAvatar = $("#user-avatar");
- const $dropdownName = $("#dropdown-name");
- const $dropdownUsername = $("#dropdown-username");
- const $dropdownEmail = $("#dropdown-email");
- const $logoutBtn = $("#logout-btn");
- const $menuContainer = $("#user-menu-container");
-
- function switchToLogin() {
- $loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
- $registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
- $loginForm.removeClass("hidden"); $registerForm.addClass("hidden");
- history.replaceState(null, "", "/auth#login");
- }
-
- function switchToRegister() {
- $registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
- $loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
- $registerForm.removeClass("hidden"); $loginForm.addClass("hidden");
- history.replaceState(null, "", "/auth#register");
- }
-
- $loginTab.on("click", switchToLogin);
- $registerTab.on("click", switchToRegister);
-
- $("body").on("click", ".toggle-password", function () {
- const $btn = $(this);
- const $input = $btn.siblings("input");
- const $eyeOpen = $btn.find(".eye-open");
- const $eyeClosed = $btn.find(".eye-closed");
-
- if ($input.attr("type") === "password") {
- $input.attr("type", "text");
- $eyeOpen.addClass("hidden");
- $eyeClosed.removeClass("hidden");
- } else {
- $input.attr("type", "password");
- $eyeOpen.removeClass("hidden");
- $eyeClosed.addClass("hidden");
- }
- });
-
- $("#register-password").on("input", function () {
- const password = $(this).val();
- let strength = 0;
- if (password.length >= 8) strength++;
- if (password.length >= 12) strength++;
- if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
- if (/\d/.test(password)) strength++;
- if (/[^a-zA-Z0-9]/.test(password)) strength++;
-
- const levels = [
- { width: "0%", color: "", text: "" },
- { width: "20%", color: "bg-red-500", text: "Очень слабый" },
- { width: "40%", color: "bg-orange-500", text: "Слабый" },
- { width: "60%", color: "bg-yellow-500", text: "Средний" },
- { width: "80%", color: "bg-lime-500", text: "Хороший" },
- { width: "100%", color: "bg-green-500", text: "Отличный" },
- ];
-
- const level = levels[strength];
- const $bar = $("#password-strength-bar");
-
- $bar.css("width", level.width);
- $bar.attr("class", "h-full transition-all duration-300 " + level.color);
- $("#password-strength-text").text(level.text);
-
- checkPasswordMatch();
- });
-
- function checkPasswordMatch() {
- const password = $("#register-password").val();
- const confirm = $("#register-password-confirm").val();
- const $error = $("#password-match-error");
-
- if (confirm && password !== confirm) {
- $error.removeClass("hidden");
- return false;
- } else {
- $error.addClass("hidden");
- return true;
- }
- }
-
- $("#register-password-confirm").on("input", checkPasswordMatch);
-
- $loginForm.on("submit", async function (event) {
- event.preventDefault();
-
- const $errorDiv = $("#login-error");
- const $submitBtn = $("#login-submit");
- const username = $("#login-username").val();
- const password = $("#login-password").val();
-
- $errorDiv.addClass("hidden");
- $submitBtn.prop("disabled", true).text("Вход...");
-
- try {
- const formData = new URLSearchParams();
- formData.append("username", username);
- formData.append("password", password);
-
- const response = await fetch("/api/auth/token", {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: formData.toString(),
- });
-
- const data = await response.json();
-
- if (response.ok) {
- localStorage.setItem("access_token", data.access_token);
- if (data.refresh_token) {
- localStorage.setItem("refresh_token", data.refresh_token);
- }
- window.location.href = "/";
- } else {
- $errorDiv.text(data.detail || "Неверное имя пользователя или пароль");
- $errorDiv.removeClass("hidden");
- $submitBtn.prop("disabled", false).text("Войти");
- }
- } catch (error) {
- console.error("Login error:", error);
- $errorDiv.text("Ошибка соединения с сервером");
- $errorDiv.removeClass("hidden");
- $submitBtn.prop("disabled", false).text("Войти");
- }
- });
-
- $registerForm.on("submit", async function (event) {
- event.preventDefault();
-
- const $errorDiv = $("#register-error");
- const $successDiv = $("#register-success");
- const $submitBtn = $("#register-submit");
-
- if (!checkPasswordMatch()) {
- $errorDiv.text("Пароли не совпадают").removeClass("hidden");
- return;
- }
-
- const userData = {
- username: $("#register-username").val(),
- email: $("#register-email").val(),
- full_name: $("#register-fullname").val() || null,
- password: $("#register-password").val(),
- };
-
- $errorDiv.addClass("hidden");
- $successDiv.addClass("hidden");
- $submitBtn.prop("disabled", true).text("Регистрация...");
-
- try {
- const response = await fetch("/api/auth/register", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(userData),
- });
-
- const data = await response.json();
-
- if (response.ok) {
- $successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden");
- setTimeout(() => {
- $("#login-username").val(userData.username);
- switchToLogin();
- }, 2000);
- } else {
- let errorMessage = data.detail;
- if (Array.isArray(data.detail)) {
- errorMessage = data.detail.map((err) => err.msg).join(". ");
- }
- $errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden");
- }
- } catch (error) {
- console.error("Register error:", error);
- $errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
- } finally {
- $submitBtn.prop("disabled", false).text("Зарегистрироваться");
- }
- });
-
- let isDropdownOpen = false;
-
- function openDropdown() {
- isDropdownOpen = true;
- $userDropdown.removeClass("hidden");
- $userArrow.addClass("rotate-180");
- }
-
- function closeDropdown() {
- isDropdownOpen = false;
- $userDropdown.addClass("hidden");
- $userArrow.removeClass("rotate-180");
- }
-
- $userBtn.on("click", function (e) {
- e.stopPropagation();
- isDropdownOpen ? closeDropdown() : openDropdown();
- });
-
- $(document).on("click", function (e) {
- if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
- closeDropdown();
- }
- });
-
- $(document).on("keydown", function (e) {
- if (e.key === "Escape" && isDropdownOpen) {
- closeDropdown();
- }
- });
-
- $logoutBtn.on("click", function () {
- localStorage.removeItem("access_token");
- localStorage.removeItem("refresh_token");
+ $("#login-form").on("submit", async function (event) {
+ event.preventDefault();
+ const $submitBtn = $("#login-submit");
+ const username = $("#login-username").val();
+ const password = $("#login-password").val();
+
+ $submitBtn.prop("disabled", true).text("Вход...");
+
+ try {
+ const formData = new URLSearchParams();
+ formData.append("username", username);
+ formData.append("password", password);
+
+ const data = await Api.postForm("/api/auth/token", formData);
+
+ localStorage.setItem("access_token", data.access_token);
+ if (data.refresh_token)
+ localStorage.setItem("refresh_token", data.refresh_token);
window.location.href = "/";
- });
-
- function showGuest() {
- $guestLink.removeClass("hidden");
- $userBtn.addClass("hidden").removeClass("flex");
- closeDropdown();
+ } catch (error) {
+ Utils.showToast(error.message || "Ошибка входа", "error");
+ } finally {
+ $submitBtn.prop("disabled", false).text("Войти");
}
-
- 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);
+ $("#register-form").on("submit", async function (event) {
+ event.preventDefault();
+ const $submitBtn = $("#register-submit");
+ const pass = $("#register-password").val();
+ const confirm = $("#register-password-confirm").val();
- const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
- const avatarImg = document.getElementById('user-avatar');
- if (avatarImg) { avatarImg.src = avatarUrl; }
- }
-
- if (window.location.hash === "#register") { switchToRegister(); }
-
- const token = localStorage.getItem("access_token");
-
- if (!token) {
- showGuest();
- } else {
- fetch("/api/auth/me", {
- headers: { Authorization: "Bearer " + token },
- })
- .then((response) => {
- if (response.ok) return response.json();
- throw new Error("Unauthorized");
- })
- .then((user) => {
- showUser(user);
- updateUserAvatar(user.email);
-
- document.getElementById('user-btn').classList.remove('hidden');
- document.getElementById('guest-link').classList.add('hidden');
- if (window.location.pathname === "/auth") { window.location.href = "/"; }
- })
- .catch(() => {
- localStorage.removeItem("access_token");
- localStorage.removeItem("refresh_token");
- showGuest();
- });
+ if (pass !== confirm) {
+ Utils.showToast("Пароли не совпадают", "error");
+ return;
}
- });
\ No newline at end of file
+
+ const userData = {
+ username: $("#register-username").val(),
+ email: $("#register-email").val(),
+ full_name: $("#register-fullname").val() || null,
+ password: pass,
+ };
+
+ $submitBtn.prop("disabled", true).text("Регистрация...");
+
+ try {
+ await Api.post("/api/auth/register", userData);
+ Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
+ setTimeout(() => window.location.reload(), 1500);
+ } catch (error) {
+ let msg = error.message;
+ if (Array.isArray(error.detail)) {
+ msg = error.detail.map((e) => e.msg).join(". ");
+ }
+ Utils.showToast(msg || "Ошибка регистрации", "error");
+ } finally {
+ $submitBtn.prop("disabled", false).text("Зарегистрироваться");
+ }
+ });
+
+ $("body").on("click", ".toggle-password", function () {
+ const $input = $(this).siblings("input");
+ const type = $input.attr("type") === "password" ? "text" : "password";
+ $input.attr("type", type);
+ $(this).find("svg").toggleClass("hidden");
+ });
+});
diff --git a/library_service/static/author.js b/library_service/static/author.js
index 06c5dcf..5ee133d 100644
--- a/library_service/static/author.js
+++ b/library_service/static/author.js
@@ -1,305 +1,61 @@
$(document).ready(() => {
- const pathParts = window.location.pathname.split("/");
- const authorId = pathParts[pathParts.length - 1];
-
- if (!authorId || isNaN(authorId)) {
- showErrorState("Некорректный ID автора");
+ const pathParts = window.location.pathname.split("/");
+ const authorId = pathParts[pathParts.length - 1];
+
+ if (!authorId || isNaN(authorId)) {
+ Utils.showToast("Некорректный ID автора", "error");
+ return;
+ }
+
+ Api.get(`/api/authors/${authorId}`)
+ .then((author) => {
+ document.title = `LiB - ${author.name}`;
+ renderAuthor(author);
+ renderBooks(author.books);
+ })
+ .catch((error) => {
+ console.error(error);
+ Utils.showToast("Автор не найден", "error");
+ $("#author-loader").html('
Ошибка загрузки
');
+ });
+
+ function renderAuthor(author) {
+ $("#author-name").text(author.name);
+ $("#author-id").text(`ID: ${author.id}`);
+ $("#author-avatar").text(author.name.charAt(0).toUpperCase());
+
+ const count = author.books ? author.books.length : 0;
+ $("#author-books-count").text(`${count} книг в библиотеке`);
+
+ $("#author-loader").addClass("hidden");
+ $("#author-content").removeClass("hidden");
+ }
+
+ function renderBooks(books) {
+ const $container = $("#books-container");
+ const tpl = document.getElementById("book-item-template");
+
+ $container.empty();
+
+ if (!books || books.length === 0) {
+ $container.html('Книг пока нет
');
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();
+
+ books.forEach((book) => {
+ const clone = tpl.content.cloneNode(true);
+ const card = clone.querySelector(".book-card");
+
+ card.dataset.id = book.id;
+ clone.querySelector(".book-title").textContent = book.title;
+ clone.querySelector(".book-desc").textContent =
+ book.description || "Описание отсутствует";
+
+ $container.append(clone);
});
-
- $(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
+ }
+
+ $("#books-container").on("click", ".book-card", function () {
+ window.location.href = `/book/${$(this).data("id")}`;
+ });
+});
diff --git a/library_service/static/authors.js b/library_service/static/authors.js
index fb7f909..8f06df4 100644
--- a/library_service/static/authors.js
+++ b/library_service/static/authors.js
@@ -1,417 +1,183 @@
$(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");
- }
+ let allAuthors = [];
+ let filteredAuthors = [];
+ let currentPage = 1;
+ let pageSize = 12;
+ let currentSort = "name_asc";
+
+ loadAuthors();
+
+ function loadAuthors() {
+ showLoadingState();
+
+ Api.get("/api/authors")
+ .then((data) => {
+ allAuthors = data.authors;
+ applyFiltersAndSort();
+ })
+ .catch((error) => {
+ console.error(error);
+ Utils.showToast("Не удалось загрузить авторов", "error");
+ $("#authors-container").empty();
});
-
- updateResultsCounter();
-
- renderAuthors();
- renderPagination();
+ }
+
+ 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();
+ return currentSort === "name_asc"
+ ? nameA.localeCompare(nameB, "ru")
+ : nameB.localeCompare(nameA, "ru");
+ });
+
+ const total = filteredAuthors.length;
+ $("#results-counter").text(
+ total === 0 ? "Авторы не найдены" : `Найдено: ${total}`,
+ );
+
+ renderAuthors();
+ renderPagination();
+ }
+
+ function renderAuthors() {
+ const $container = $("#authors-container");
+ const tpl = document.getElementById("author-card-template");
+ const emptyTpl = document.getElementById("empty-state-template");
+
+ $container.empty();
+
+ if (filteredAuthors.length === 0) {
+ $container.append(emptyTpl.content.cloneNode(true));
+ return;
}
-
- function updateResultsCounter() {
- const $counter = $("#results-counter");
- const total = filteredAuthors.length;
-
- if (total === 0) {
- $counter.text("Авторы не найдены");
- } else {
- const wordForm = getWordForm(total, ["автор", "автора", "авторов"]);
- $counter.text(`Найдено: ${total} ${wordForm}`);
+
+ const startIndex = (currentPage - 1) * pageSize;
+ const pageAuthors = filteredAuthors.slice(
+ startIndex,
+ startIndex + pageSize,
+ );
+
+ pageAuthors.forEach((author) => {
+ const clone = tpl.content.cloneNode(true);
+ const card = clone.querySelector(".author-card");
+
+ card.dataset.id = author.id;
+ clone.querySelector(".author-name").textContent = author.name;
+ clone.querySelector(".author-id").textContent = `ID: ${author.id}`;
+ clone.querySelector(".author-avatar").textContent = author.name
+ .charAt(0)
+ .toUpperCase();
+
+ $container.append(clone);
+ });
+ }
+
+ function renderPagination() {
+ $("#pagination-container").empty();
+ const totalPages = Math.ceil(filteredAuthors.length / pageSize);
+ if (totalPages <= 1) return;
+
+ const $pagination = $(`
+
+ `);
+
+ const $pageNumbers = $pagination.find("#page-numbers");
+ const pages = [];
+ for (let i = 1; i <= totalPages; i++) {
+ if (
+ i === 1 ||
+ i === totalPages ||
+ (i >= currentPage - 2 && i <= currentPage + 2)
+ ) {
+ pages.push(i);
+ } else if (pages[pages.length - 1] !== "...") {
+ pages.push("...");
}
}
- 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;
+ pages.forEach((page) => {
+ if (page === "...") {
+ $pageNumbers.append(`...`);
+ } else {
+ const isActive = page === currentPage;
+ $pageNumbers.append(`
+
+ `);
}
-
- 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("...");
- }
+ });
+
+ $("#pagination-container").append($pagination);
+
+ $("#prev-page").on("click", () => {
+ if (currentPage > 1) {
+ currentPage--;
+ renderAuthors();
+ renderPagination();
+ scrollToTop();
}
-
- return pages;
- }
-
- function scrollToTop() {
- $("html, body").animate({ scrollTop: 0 }, 300);
- }
-
- function showLoadingState() {
- const $container = $("#authors-container");
- $container.html(`
-
- ${Array(6)
- .fill()
- .map(
- () => `
-
-
-
-
-
-
+ });
+ $("#next-page").on("click", () => {
+ if (currentPage < totalPages) {
+ currentPage++;
+ renderAuthors();
+ renderPagination();
+ scrollToTop();
+ }
+ });
+ $(".page-btn").on("click", function () {
+ currentPage = parseInt($(this).data("page"));
+ renderAuthors();
+ renderPagination();
+ scrollToTop();
+ });
+ }
+
+ function showLoadingState() {
+ $("#authors-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
+ `,
+ )
+ .join("")}
+ `);
+ }
+
+ function scrollToTop() {
+ $("html, body").animate({ scrollTop: 0 }, 300);
+ }
+
+ $("#author-search-input").on("input", function () {
+ currentPage = 1;
+ applyFiltersAndSort();
+ });
+
+ $('input[name="sort"]').on("change", function () {
+ currentSort = $(this).val();
+ currentPage = 1;
+ applyFiltersAndSort();
+ });
+
+ $("#authors-container").on("click", ".author-card", function () {
+ window.location.href = `/author/${$(this).data("id")}`;
+ });
+});
diff --git a/library_service/static/avatar.svg b/library_service/static/avatar.svg
deleted file mode 100644
index ef1bcfa..0000000
--- a/library_service/static/avatar.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
diff --git a/library_service/static/base.js b/library_service/static/base.js
new file mode 100644
index 0000000..c2c28d7
--- /dev/null
+++ b/library_service/static/base.js
@@ -0,0 +1,116 @@
+$(function () {
+ const $guestLink = $("#guest-link");
+ const $userBtn = $("#user-btn");
+ const $userDropdown = $("#user-dropdown");
+ const $userArrow = $("#user-arrow");
+ const $userAvatar = $("#user-avatar");
+ const $dropdownName = $("#dropdown-name");
+ const $dropdownUsername = $("#dropdown-username");
+ const $dropdownEmail = $("#dropdown-email");
+ const $logoutBtn = $("#logout-btn");
+ const $menuContainer = $("#user-menu-area");
+
+ 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-area").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;
+ if (typeof sha256 === "undefined") {
+ console.warn("sha256 library not loaded yet");
+ 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");
+
+ if (window.location.pathname === "/auth") {
+ window.location.href = "/";
+ }
+ })
+ .catch(() => {
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("refresh_token");
+ showGuest();
+ });
+ }
+});
diff --git a/library_service/static/book.js b/library_service/static/book.js
index ca50a45..fe36a01 100644
--- a/library_service/static/book.js
+++ b/library_service/static/book.js
@@ -1,321 +1,123 @@
$(document).ready(() => {
- const pathParts = window.location.pathname.split("/");
- const bookId = pathParts[pathParts.length - 1];
+ const STATUS_CONFIG = {
+ active: {
+ label: "Доступна",
+ bgClass: "bg-green-100",
+ textClass: "text-green-800",
+ icon: `
`,
+ },
+ borrowed: {
+ label: "Выдана",
+ bgClass: "bg-yellow-100",
+ textClass: "text-yellow-800",
+ icon: `
`,
+ },
+ reserved: {
+ label: "Забронирована",
+ bgClass: "bg-blue-100",
+ textClass: "text-blue-800",
+ icon: `
`,
+ },
+ restoration: {
+ label: "На реставрации",
+ bgClass: "bg-orange-100",
+ textClass: "text-orange-800",
+ icon: `
`,
+ },
+ written_off: {
+ label: "Списана",
+ bgClass: "bg-red-100",
+ textClass: "text-red-800",
+ icon: `
`,
+ },
+ };
- if (!bookId || isNaN(bookId)) {
- showErrorState("Некорректный ID книги");
- return;
- }
+ function getStatusConfig(status) {
+ return (
+ STATUS_CONFIG[status] || {
+ label: status || "Неизвестно",
+ bgClass: "bg-gray-100",
+ textClass: "text-gray-800",
+ icon: "",
+ }
+ );
+ }
- loadBook(bookId);
+ const pathParts = window.location.pathname.split("/");
+ const bookId = pathParts[pathParts.length - 1];
- function loadBook(id) {
- showLoadingState();
+ if (!bookId || isNaN(bookId)) {
+ Utils.showToast("Некорректный ID книги", "error");
+ return;
+ }
- 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();
+ Api.get(`/api/books/${bookId}`)
+ .then((book) => {
+ document.title = `LiB - ${book.title}`;
+ renderBook(book);
+ })
+ .catch((error) => {
+ console.error(error);
+ Utils.showToast("Книга не найдена", "error");
+ $("#book-loader").html(
+ '
Ошибка загрузки
',
+ );
});
- $(document).on("click", function (e) {
- if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
- closeDropdown();
- }
- });
+ function renderBook(book) {
+ $("#book-title").text(book.title);
+ $("#book-id").text(`ID: ${book.id}`);
+ $("#book-authors-text").text(
+ book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
+ );
+ $("#book-description").text(book.description || "Описание отсутствует");
- $(document).on("keydown", function (e) {
- if (e.key === "Escape" && isDropdownOpen) {
- closeDropdown();
- }
- });
+ const statusConfig = getStatusConfig(book.status);
+ $("#book-status")
+ .html(statusConfig.icon + statusConfig.label)
+ .removeClass()
+ .addClass(
+ `inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${statusConfig.bgClass} ${statusConfig.textClass}`,
+ );
- $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();
+ if (book.genres && book.genres.length > 0) {
+ $("#genres-section").removeClass("hidden");
+ const $genres = $("#genres-container");
+ book.genres.forEach((g) => {
+ $genres.append(`
+
+ ${Utils.escapeHtml(g.name)}
+
+ `);
+ });
}
- function showUser(user) {
- $guestLink.addClass("hidden");
- $userBtn.removeClass("hidden").addClass("flex");
-
- const displayName = user.full_name || user.username;
- const firstLetter = displayName.charAt(0).toUpperCase();
-
- $userAvatar.text(firstLetter);
- $dropdownName.text(displayName);
- $dropdownUsername.text("@" + user.username);
- $dropdownEmail.text(user.email);
+ if (book.authors && book.authors.length > 0) {
+ $("#authors-section").removeClass("hidden");
+ const $authors = $("#authors-container");
+ book.authors.forEach((a) => {
+ $authors.append(`
+
+
+ ${a.name.charAt(0).toUpperCase()}
+
+ ${Utils.escapeHtml(a.name)}
+
+ `);
+ });
}
- 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
+ $("#book-loader").addClass("hidden");
+ $("#book-content").removeClass("hidden");
+ }
+});
diff --git a/library_service/static/books.js b/library_service/static/books.js
index bf33c32..d36fc1a 100644
--- a/library_service/static/books.js
+++ b/library_service/static/books.js
@@ -1,4 +1,42 @@
$(document).ready(() => {
+ const STATUS_CONFIG = {
+ active: {
+ label: "Доступна",
+ bgClass: "bg-green-100",
+ textClass: "text-green-800",
+ },
+ borrowed: {
+ label: "Выдана",
+ bgClass: "bg-yellow-100",
+ textClass: "text-yellow-800",
+ },
+ reserved: {
+ label: "Забронирована",
+ bgClass: "bg-blue-100",
+ textClass: "text-blue-800",
+ },
+ restoration: {
+ label: "На реставрации",
+ bgClass: "bg-orange-100",
+ textClass: "text-orange-800",
+ },
+ written_off: {
+ label: "Списана",
+ bgClass: "bg-red-100",
+ textClass: "text-red-800",
+ },
+ };
+
+ function getStatusConfig(status) {
+ return (
+ STATUS_CONFIG[status] || {
+ label: status || "Неизвестно",
+ bgClass: "bg-gray-100",
+ textClass: "text-gray-800",
+ }
+ );
+ }
+
let selectedAuthors = new Map();
let selectedGenres = new Map();
let currentPage = 1;
@@ -10,237 +48,183 @@ $(document).ready(() => {
const authorIdsFromUrl = urlParams.getAll("author_id");
const searchFromUrl = urlParams.get("q");
- Promise.all([
- fetch("/api/authors").then((response) => response.json()),
- fetch("/api/genres").then((response) => response.json()),
- ])
+ if (searchFromUrl) $("#book-search-input").val(searchFromUrl);
+
+ Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
.then(([authorsData, genresData]) => {
- const $dropdown = $("#author-dropdown");
- authorsData.authors.forEach((author) => {
- $("
")
- .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);
-
- if (authorIdsFromUrl.includes(String(author.id))) {
- selectedAuthors.set(author.id, author.name);
- }
- });
-
- const $list = $("#genres-list");
- genresData.genres.forEach((genre) => {
- const isChecked = genreIdsFromUrl.includes(String(genre.id));
-
- if (isChecked) {
- selectedGenres.set(genre.id, genre.name);
- }
-
- $("
")
- .addClass("mb-1")
- .html(
- ``,
- )
- .appendTo($list);
- });
-
- initializeAuthorDropdown();
- initializeFilters();
-
+ initAuthors(authorsData.authors);
+ initGenres(genresData.genres);
+ initializeAuthorDropdownListeners();
+ renderChips();
loadBooks();
})
- .catch((error) => console.error("Error loading data:", error));
+ .catch((error) => {
+ console.error(error);
+ Utils.showToast("Ошибка загрузки данных", "error");
+ });
+
+ function initAuthors(authors) {
+ const $dropdown = $("#author-dropdown");
+ authors.forEach((author) => {
+ $("")
+ .addClass(
+ "p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors",
+ )
+ .attr("data-id", author.id)
+ .attr("data-name", author.name)
+ .text(author.name)
+ .appendTo($dropdown);
+
+ if (authorIdsFromUrl.includes(String(author.id))) {
+ selectedAuthors.set(author.id, author.name);
+ }
+ });
+ }
+
+ function initGenres(genres) {
+ const $list = $("#genres-list");
+ genres.forEach((genre) => {
+ const isChecked = genreIdsFromUrl.includes(String(genre.id));
+ if (isChecked) selectedGenres.set(genre.id, genre.name);
+
+ $list.append(`
+
+
+
+ `);
+ });
+
+ $list.on("change", "input", function () {
+ const id = parseInt($(this).data("id"));
+ const name = $(this).data("name");
+ this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
+ });
+ }
function loadBooks() {
const searchQuery = $("#book-search-input").val().trim();
-
const params = new URLSearchParams();
- if (searchQuery.length >= 3) {
- params.append("q", searchQuery);
- }
- selectedAuthors.forEach((name, id) => {
- params.append("author_ids", id);
- });
+ params.append("q", searchQuery);
+ selectedAuthors.forEach((_, id) => params.append("author_ids", id));
+ selectedGenres.forEach((_, id) => params.append("genre_ids", id));
- selectedGenres.forEach((name, id) => {
- params.append("genre_ids", id);
- });
+ const browserParams = new URLSearchParams();
+ browserParams.append("q", searchQuery);
+ selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
+ selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
- function updateBrowserUrl() {
- const params = new URLSearchParams();
-
- const searchQuery = $("#book-search-input").val().trim();
- if (searchQuery.length >= 3) {
- params.append("q", searchQuery);
- }
-
- selectedAuthors.forEach((name, id) => {
- params.append("author_id", id);
- });
-
- selectedGenres.forEach((name, id) => {
- params.append("genre_id", id);
- });
-
- const newUrl = params.toString()
- ? `${window.location.pathname}?${params.toString()}`
- : window.location.pathname;
-
- window.history.replaceState({}, "", newUrl);
- }
+ const newUrl =
+ window.location.pathname +
+ (browserParams.toString() ? `?${browserParams.toString()}` : "");
+ window.history.replaceState({}, "", newUrl);
params.append("page", currentPage);
params.append("size", pageSize);
- const url = `/api/books/filter?${params.toString()}`;
-
showLoadingState();
- updateBrowserUrl();
-
- fetch(url)
- .then((response) => {
- if (!response.ok) {
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- return response.json();
- })
+ Api.get(`/api/books/filter?${params.toString()}`)
.then((data) => {
totalBooks = data.total;
renderBooks(data.books);
renderPagination();
})
.catch((error) => {
- console.error("Error loading books:", error);
- showErrorState();
+ console.error(error);
+ Utils.showToast("Не удалось загрузить книги", "error");
+ $("#books-container").html(
+ document.getElementById("empty-state-template").innerHTML,
+ );
});
}
function renderBooks(books) {
const $container = $("#books-container");
+ const tpl = document.getElementById("book-card-template");
+ const emptyTpl = document.getElementById("empty-state-template");
+ const badgeTpl = document.getElementById("genre-badge-template");
+
$container.empty();
if (books.length === 0) {
- $container.html(`
-
-
-
Книги не найдены
-
Попробуйте изменить параметры поиска или фильтры
-
- `);
+ $container.append(emptyTpl.content.cloneNode(true));
return;
}
books.forEach((book) => {
- const authorsText =
+ const clone = tpl.content.cloneNode(true);
+ const card = clone.querySelector(".book-card");
+
+ card.dataset.id = book.id;
+ clone.querySelector(".book-title").textContent = book.title;
+ clone.querySelector(".book-authors").textContent =
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
- const genresText =
- book.genres.map((g) => g.name).join(", ") || "Без жанра";
+ clone.querySelector(".book-desc").textContent = book.description || "";
- const $bookCard = $(`
-
-
-
-
- ${escapeHtml(book.title)}
-
-
- Авторы: ${escapeHtml(authorsText)}
-
-
- ${escapeHtml(book.description || "Описание отсутствует")}
-
-
- ${book.genres
- .map(
- (g) => `
-
- ${escapeHtml(g.name)}
-
- `,
- )
- .join("")}
-
-
-
-
- `);
+ const statusConfig = getStatusConfig(book.status);
+ const statusEl = clone.querySelector(".book-status");
+ statusEl.textContent = statusConfig.label;
+ statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
- $container.append($bookCard);
- });
-
- $container.on("click", ".book-card", function () {
- const bookId = $(this).data("id");
- window.location.href = `/book/${bookId}`;
+ const genresContainer = clone.querySelector(".book-genres");
+ book.genres.forEach((g) => {
+ const badge = badgeTpl.content.cloneNode(true);
+ const span = badge.querySelector("span");
+ span.textContent = g.name;
+ genresContainer.appendChild(badge);
+ });
+
+ $container.append(clone);
});
}
function renderPagination() {
- $("#pagination-container").remove();
-
+ $("#pagination-container").empty();
const totalPages = Math.ceil(totalBooks / 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(`
...`);
+ $pageNumbers.append(`
...`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
-
- `);
+
+ `);
}
});
- $("#books-container").after($pagination);
+ $("#pagination-container").append($pagination);
- $("#prev-page").on("click", function () {
+ $("#prev-page").on("click", () => {
if (currentPage > 1) {
currentPage--;
loadBooks();
scrollToTop();
}
});
-
- $("#next-page").on("click", function () {
+ $("#next-page").on("click", () => {
if (currentPage < totalPages) {
currentPage++;
loadBooks();
scrollToTop();
}
});
-
$(".page-btn").on("click", function () {
const page = parseInt($(this).data("page"));
if (page !== currentPage) {
@@ -254,7 +238,6 @@ $(document).ready(() => {
function generatePageNumbers(current, total) {
const pages = [];
const delta = 2;
-
for (let i = 1; i <= total; i++) {
if (
i === 1 ||
@@ -266,7 +249,6 @@ $(document).ready(() => {
pages.push("...");
}
}
-
return pages;
}
@@ -275,115 +257,78 @@ $(document).ready(() => {
}
function showLoadingState() {
- const $container = $("#books-container");
- $container.html(`
-
- ${Array(3)
- .fill()
- .map(
- () => `
-
-
-
-
-
-
-
+ $("#books-container").html(`
+
+ ${Array(3)
+ .fill()
+ .map(
+ () => `
+
+ `,
+ )
+ .join("")}
-
- `,
- )
- .join("")}
-
- `);
+ `);
}
- function showErrorState() {
- const $container = $("#books-container");
- $container.html(`
-
-
-
Ошибка загрузки
-
Не удалось загрузить список книг
-
-
- `);
+ function renderChips() {
+ const $container = $("#selected-authors-container");
+ const $dropdown = $("#author-dropdown");
- $("#retry-btn").on("click", loadBooks);
+ $container.empty();
+
+ selectedAuthors.forEach((name, id) => {
+ $(`
+ ${Utils.escapeHtml(name)}
+
+ `).appendTo($container);
+ });
+
+ $dropdown.find(".author-item").each(function () {
+ const id = parseInt($(this).data("id"));
+ if (selectedAuthors.has(id)) {
+ $(this)
+ .addClass("bg-gray-200 text-gray-900 font-semibold")
+ .removeClass("hover:bg-gray-100");
+ } else {
+ $(this)
+ .removeClass("bg-gray-200 text-gray-900 font-semibold")
+ .addClass("hover:bg-gray-100");
+ }
+ });
}
- function escapeHtml(text) {
- if (!text) return "";
- const div = document.createElement("div");
- div.textContent = text;
- return div.innerHTML;
- }
-
- function initializeAuthorDropdown() {
+ function initializeAuthorDropdownListeners() {
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) => {
- $(`
- ${escapeHtml(name)}
-
- `).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());
+ $input.on("focus", function () {
$dropdown.removeClass("hidden");
});
- $(document).on("click", (e) => {
+ $input.on("input", function () {
+ const val = $(this).val().toLowerCase();
+ $dropdown.removeClass("hidden");
+ $dropdown.find(".author-item").each(function () {
+ const text = $(this).text().toLowerCase();
+ $(this).toggle(text.includes(val));
+ });
+ });
+
+ $(document).on("click", function (e) {
if (
- !$(e.target).closest("#selected-authors-container, #author-dropdown")
- .length
+ !$(e.target).closest(
+ "#author-search-input, #author-dropdown, #selected-authors-container",
+ ).length
) {
$dropdown.addClass("hidden");
}
@@ -391,184 +336,52 @@ $(document).ready(() => {
$dropdown.on("click", ".author-item", function (e) {
e.stopPropagation();
- toggleAuthor($(this).attr("data-id"), $(this).attr("data-name"));
- $input.focus();
+ const id = parseInt($(this).data("id"));
+ const name = $(this).data("name");
+
+ if (selectedAuthors.has(id)) {
+ selectedAuthors.delete(id);
+ } else {
+ selectedAuthors.set(id, name);
+ }
+
+ $input.val("");
+ $dropdown.find(".author-item").show();
+ renderChips();
+ $input[0].focus();
});
$container.on("click", ".remove-author", function (e) {
e.stopPropagation();
- selectedAuthors.delete(parseInt($(this).attr("data-id")));
+ const id = parseInt($(this).data("id"));
+ selectedAuthors.delete(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");
+ $("#books-container").on("click", ".book-card", function () {
+ window.location.href = `/book/${$(this).data("id")}`;
+ });
- $("#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);
- }
- });
+ $("#apply-filters-btn").on("click", function () {
+ currentPage = 1;
+ loadBooks();
+ });
- $applyBtn.on("click", function () {
+ $("#reset-filters-btn").on("click", function () {
+ $("#book-search-input").val("");
+ selectedAuthors.clear();
+ selectedGenres.clear();
+ $("#genres-list input").prop("checked", false);
+ renderChips();
+ currentPage = 1;
+ loadBooks();
+ });
+
+ $("#book-search-input").on("keypress", function (e) {
+ if (e.which === 13) {
currentPage = 1;
loadBooks();
- });
-
- $resetBtn.on("click", function () {
- $bookSearch.val("");
-
- selectedAuthors.clear();
- $("#selected-authors-container .author-chip").remove();
- if (window.updateAuthorHighlights) window.updateAuthorHighlights();
-
- selectedGenres.clear();
- $("#genres-list input[type='checkbox']").prop("checked", false);
-
- currentPage = 1;
- loadBooks();
- });
-
- let searchTimeout;
- $bookSearch.on("input", function () {
- clearTimeout(searchTimeout);
- const query = $(this).val().trim();
-
- if (query.length >= 3 || query.length === 0) {
- searchTimeout = setTimeout(() => {
- currentPage = 1;
- loadBooks();
- }, 500);
- }
- });
-
- $bookSearch.on("keypress", function (e) {
- if (e.which === 13) {
- clearTimeout(searchTimeout);
- currentPage = 1;
- loadBooks();
- }
- });
- }
-
- const $guestLink = $("#guest-link");
- const $userBtn = $("#user-btn");
- const $userDropdown = $("#user-dropdown");
- const $userArrow = $("#user-arrow");
- const $userAvatar = $("#user-avatar");
- const $dropdownName = $("#dropdown-name");
- const $dropdownUsername = $("#dropdown-username");
- const $dropdownEmail = $("#dropdown-email");
- const $logoutBtn = $("#logout-btn");
-
- let isDropdownOpen = false;
-
- function openDropdown() {
- isDropdownOpen = true;
- $userDropdown.removeClass("hidden");
- $userArrow.addClass("rotate-180");
- }
-
- function closeDropdown() {
- isDropdownOpen = false;
- $userDropdown.addClass("hidden");
- $userArrow.removeClass("rotate-180");
- }
-
- $userBtn.on("click", function (e) {
- e.stopPropagation();
- isDropdownOpen ? closeDropdown() : openDropdown();
- });
-
- $(document).on("click", function (e) {
- if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
- closeDropdown();
}
});
-
- $(document).on("keydown", function (e) {
- if (e.key === "Escape" && isDropdownOpen) {
- closeDropdown();
- }
- });
-
- $logoutBtn.on("click", function () {
- localStorage.removeItem("access_token");
- localStorage.removeItem("refresh_token");
- window.location.reload();
- });
-
- function showGuest() {
- $guestLink.removeClass("hidden");
- $userBtn.addClass("hidden").removeClass("flex");
- closeDropdown();
- }
-
- function showUser(user) {
- $guestLink.addClass("hidden");
- $userBtn.removeClass("hidden").addClass("flex");
-
- const displayName = user.full_name || user.username;
- const firstLetter = displayName.charAt(0).toUpperCase();
-
- $userAvatar.text(firstLetter);
- $dropdownName.text(displayName);
- $dropdownUsername.text("@" + user.username);
- $dropdownEmail.text(user.email);
- }
-
- function updateUserAvatar(email) {
- if (!email) return;
- const cleanEmail = email.trim().toLowerCase();
- const emailHash = sha256(cleanEmail);
-
- const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
- const avatarImg = document.getElementById("user-avatar");
- if (avatarImg) {
- avatarImg.src = avatarUrl;
- }
- }
-
- const token = localStorage.getItem("access_token");
-
- if (!token) {
- showGuest();
- } else {
- fetch("/api/auth/me", {
- headers: { Authorization: "Bearer " + token },
- })
- .then((response) => {
- if (response.ok) return response.json();
- throw new Error("Unauthorized");
- })
- .then((user) => {
- showUser(user);
- updateUserAvatar(user.email);
-
- document.getElementById("user-btn").classList.remove("hidden");
- document.getElementById("guest-link").classList.add("hidden");
- })
- .catch(() => {
- localStorage.removeItem("access_token");
- localStorage.removeItem("refresh_token");
- showGuest();
- });
- }
});
diff --git a/library_service/static/index.js b/library_service/static/index.js
index aa5c155..9c6b6ea 100644
--- a/library_service/static/index.js
+++ b/library_service/static/index.js
@@ -11,9 +11,9 @@ const bookX = (svgWidth - bookWidth) / 2;
const bookY = (svgHeight - bookHeight) / 2;
const desiredLineSpacing = 8;
const baseLineWidth = 2;
-const maxLineWidth = 10;
+const maxLineWidth = 8;
const maxLineHeight = bookHeight - 24;
-const innerPaddingX = 10;
+const innerPaddingX = 15;
const appearStagger = 8;
let lineSpacing;
@@ -28,7 +28,7 @@ if (lineCount > 1) {
const linesSpan = lineSpacing * (lineCount - 1);
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
-const lineStartX = rightBase - linesSpan;
+const lineStartX = rightBase - linesSpan + maxLineWidth;
const leftLimit = bookX + innerPaddingX;
@@ -250,18 +250,16 @@ function observeStatCards() {
entries.forEach((entry, index) => {
if (entry.isIntersecting) {
setTimeout(() => {
- $(entry.target)
- .addClass("animate-fade-in")
- .css({
- opacity: "1",
- 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.each((index, card) => {
@@ -277,108 +275,4 @@ function observeStatCards() {
$(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/profile.js b/library_service/static/profile.js
index 3b212b2..c8a2021 100644
--- a/library_service/static/profile.js
+++ b/library_service/static/profile.js
@@ -1,509 +1,131 @@
$(document).ready(() => {
- let currentUser = null;
- let allRoles = [];
-
- const token = localStorage.getItem("access_token");
-
- if (!token) {
- window.location.href = "/login";
- return;
- }
-
- loadProfile();
-
- function loadProfile() {
- showLoadingState();
-
- Promise.all([
- fetch("/api/auth/me", {
- headers: { Authorization: "Bearer " + token },
- }).then((response) => {
- if (!response.ok) {
- if (response.status === 401) {
- throw new Error("Unauthorized");
- }
- throw new Error(`HTTP error! status: ${response.status}`);
- }
- return response.json();
- }),
- fetch("/api/auth/roles", {
- headers: { Authorization: "Bearer " + token },
- }).then((response) => {
- if (response.ok) return response.json();
- return { roles: [] };
- }),
- ])
- .then(([user, rolesData]) => {
- currentUser = user;
- allRoles = rolesData.roles || [];
- renderProfile(user);
- renderAccountInfo(user);
- renderRoles(user.roles, allRoles);
- renderActions();
- document.title = `LiB - ${user.full_name || user.username}`;
- })
- .catch((error) => {
- console.error("Error loading profile:", error);
- if (error.message === "Unauthorized") {
- localStorage.removeItem("access_token");
- localStorage.removeItem("refresh_token");
- window.location.href = "/login";
- } else {
- showErrorState(error.message);
- }
- });
- }
-
- function renderProfile(user) {
- const $card = $("#profile-card");
- const displayName = user.full_name || user.username;
- const firstLetter = displayName.charAt(0).toUpperCase();
-
- const emailHash = sha256(user.email.trim().toLowerCase());
- const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
-
- $card.html(`
-
-
-
-

-
- ${firstLetter}
-
-
- ${user.is_verified ? `
-
- ` : ''}
-
-
-
-
-
${escapeHtml(displayName)}
-
@${escapeHtml(user.username)}
-
-
-
- ${user.is_active ? `
-
-
- Активен
-
- ` : `
-
-
- Заблокирован
-
- `}
- ${user.is_verified ? `
-
-
- Подтверждён
-
- ` : `
-
-
- Не подтверждён
-
- `}
-
-
-
- `);
- }
-
- function renderAccountInfo(user) {
- const $container = $("#account-container");
-
- $container.html(`
-
-
-
-
-
-
-
ID пользователя
-
${user.id}
+ const token = localStorage.getItem("access_token");
+ if (!token) {
+ window.location.href = "/auth";
+ return;
+ }
+
+ loadProfile();
+
+ function loadProfile() {
+ Promise.all([
+ Api.get("/api/auth/me"),
+ Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
+ ])
+ .then(async ([user, rolesData]) => {
+ document.title = `LiB - ${user.full_name || user.username}`;
+ await renderProfileHeader(user);
+ renderInfo(user);
+ renderRoles(user.roles || [], rolesData.roles || []);
+
+ $("#account-section, #roles-section").removeClass("hidden");
+ })
+ .catch((error) => {
+ console.error(error);
+ Utils.showToast("Ошибка загрузки профиля", "error");
+ });
+ }
+
+ async function renderProfileHeader(user) {
+ const avatarUrl = await Utils.getGravatarUrl(user.email);
+ const displayName = Utils.escapeHtml(user.full_name || user.username);
+
+ $("#profile-card").html(`
+
+
+

+ ${user.is_verified ? '
' : ""}
+
+
+
${displayName}
+
@${Utils.escapeHtml(user.username)}
+
+ ${user.is_active ? "Активен" : "Заблокирован"}
+
-
-
-
-
-
-
-
Имя пользователя
-
@${escapeHtml(user.username)}
-
-
-
-
-
-
-
-
-
-
Полное имя
-
${escapeHtml(user.full_name || "Не указано")}
-
-
-
-
-
-
-
-
-
-
Email
-
${escapeHtml(user.email)}
-
-
-
-
- `);
- }
-
- function renderRoles(userRoles, allRoles) {
- const $container = $("#roles-container");
-
- if (!userRoles || userRoles.length === 0) {
- $container.html(`
-
У вас нет назначенных ролей
`);
- return;
- }
-
- const roleDescriptions = {};
- allRoles.forEach((role) => {
- roleDescriptions[role.name] = role.description;
- });
-
- const roleIcons = {
- admin: `
`,
- librarian: `
`,
- member: `
`,
- };
-
- const roleColors = {
- admin: "bg-red-100 text-red-800 border-red-200",
- librarian: "bg-blue-100 text-blue-800 border-blue-200",
- member: "bg-green-100 text-green-800 border-green-200",
- };
-
- let rolesHtml = '
';
-
- userRoles.forEach((roleName) => {
- const description = roleDescriptions[roleName] || "Описание недоступно";
- const icon = roleIcons[roleName] || roleIcons.member;
- const colorClass = roleColors[roleName] || roleColors.member;
-
- rolesHtml += `
-
-
- ${icon}
+ }
+
+ function renderInfo(user) {
+ const fields = [
+ { label: "ID пользователя", value: user.id },
+ { label: "Email", value: user.email },
+ { label: "Полное имя", value: user.full_name || "Не указано" },
+ ];
+
+ const html = fields
+ .map(
+ (f) => `
+
+ ${f.label}
+ ${Utils.escapeHtml(String(f.value))}
-
-
${escapeHtml(roleName)}
-
${escapeHtml(description)}
-
-
- `;
- });
-
- rolesHtml += '
';
-
- $container.html(rolesHtml);
- }
-
- function renderActions() {
- const $container = $("#actions-container");
-
- $container.html(`
-
-
-
-
-
-
-
- `);
-
- $("#change-password-btn").on("click", openPasswordModal);
- $("#logout-profile-btn").on("click", logout);
- }
-
- function openPasswordModal() {
- $("#password-modal").removeClass("hidden").addClass("flex");
- $("#current-password").focus();
- }
-
- function closePasswordModal() {
- $("#password-modal").removeClass("flex").addClass("hidden");
- $("#password-form")[0].reset();
- $("#password-error").addClass("hidden").text("");
- }
-
- $("#close-password-modal, #cancel-password").on("click", closePasswordModal);
-
- $("#password-modal").on("click", function (e) {
- if (e.target === this) {
- closePasswordModal();
- }
- });
-
- $(document).on("keydown", function (e) {
- if (e.key === "Escape" && $("#password-modal").hasClass("flex")) {
- closePasswordModal();
- }
- });
-
- $("#password-form").on("submit", function (e) {
- e.preventDefault();
-
- const currentPassword = $("#current-password").val();
- const newPassword = $("#new-password").val();
- const confirmPassword = $("#confirm-password").val();
- const $error = $("#password-error");
-
- if (newPassword !== confirmPassword) {
- $error.text("Пароли не совпадают").removeClass("hidden");
- return;
- }
-
- if (newPassword.length < 6) {
- $error.text("Пароль должен содержать минимум 6 символов").removeClass("hidden");
- return;
- }
-
- // TODO: смена пароля, 2FA
- // fetch("/api/auth/change-password", {
- // method: "POST",
- // headers: {
- // "Content-Type": "application/json",
- // Authorization: "Bearer " + token,
- // },
- // body: JSON.stringify({
- // current_password: currentPassword,
- // new_password: newPassword,
- // }),
- // })
- // .then((response) => {
- // if (!response.ok) throw new Error("Ошибка смены пароля");
- // return response.json();
- // })
- // .then(() => {
- // closePasswordModal();
- // showNotification("Пароль успешно изменён", "success");
- // })
- // .catch((error) => {
- // $error.text(error.message).removeClass("hidden");
- // });
-
- $error.text("Функция смены пароля временно недоступна").removeClass("hidden");
- });
-
- function logout() {
- localStorage.removeItem("access_token");
- localStorage.removeItem("refresh_token");
- window.location.href = "/login";
- }
-
- function showLoadingState() {
- const $profileCard = $("#profile-card");
- const $accountContainer = $("#account-container");
- const $rolesContainer = $("#roles-container");
- const $actionsContainer = $("#actions-container");
-
- $profileCard.html(`
-
- `);
-
- $accountContainer.html(`
-
- ${Array(4)
- .fill()
- .map(
- () => `
-
- `
- )
- .join("")}
-
- `);
-
- $rolesContainer.html(`
-
- `);
-
- $actionsContainer.html(`
-
- `);
- }
-
- function showErrorState(message) {
- const $profileCard = $("#profile-card");
- const $accountSection = $("#account-section");
- const $rolesSection = $("#roles-section");
- const $actionsSection = $("#actions-section");
-
- $accountSection.hide();
- $rolesSection.hide();
- $actionsSection.hide();
-
- $profileCard.html(`
-
-
-
${escapeHtml(message)}
-
Не удалось загрузить профиль
-
-
- `);
-
- $("#retry-btn").on("click", function () {
- $accountSection.show();
- $rolesSection.show();
- $actionsSection.show();
- loadProfile();
- });
- }
-
- function escapeHtml(text) {
- if (!text) return "";
- const div = document.createElement("div");
- div.textContent = text;
- return div.innerHTML;
- }
-
- const $guestLink = $("#guest-link");
- const $userBtn = $("#user-btn");
- const $userDropdown = $("#user-dropdown");
- const $userArrow = $("#user-arrow");
- const $userAvatar = $("#user-avatar");
- const $dropdownName = $("#dropdown-name");
- const $dropdownUsername = $("#dropdown-username");
- const $dropdownEmail = $("#dropdown-email");
- const $logoutBtn = $("#logout-btn");
-
- let isDropdownOpen = false;
-
- function openDropdown() {
- isDropdownOpen = true;
- $userDropdown.removeClass("hidden");
- $userArrow.addClass("rotate-180");
- }
-
- function closeDropdown() {
- isDropdownOpen = false;
- $userDropdown.addClass("hidden");
- $userArrow.removeClass("rotate-180");
- }
-
- $userBtn.on("click", function (e) {
- e.stopPropagation();
- isDropdownOpen ? closeDropdown() : openDropdown();
- });
-
- $(document).on("click", function (e) {
- if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
- closeDropdown();
- }
- });
-
- $logoutBtn.on("click", logout);
-
- function showGuest() {
- $guestLink.removeClass("hidden");
- $userBtn.addClass("hidden").removeClass("flex");
- closeDropdown();
- }
-
- function showUser(user) {
- $guestLink.addClass("hidden");
- $userBtn.removeClass("hidden").addClass("flex");
-
- const displayName = user.full_name || user.username;
- const firstLetter = displayName.charAt(0).toUpperCase();
-
- $userAvatar.text(firstLetter);
- $dropdownName.text(displayName);
- $dropdownUsername.text("@" + user.username);
- $dropdownEmail.text(user.email);
+ `,
+ )
+ .join("");
+
+ $("#account-info").html(html);
+ }
+
+ function renderRoles(userRoles, allRoles) {
+ const $container = $("#roles-container");
+ if (userRoles.length === 0) {
+ $container.html('
Нет ролей
');
+ return;
}
- if (currentUser) {
- showUser(currentUser);
+ const roleMap = {};
+ allRoles.forEach((r) => (roleMap[r.name] = r.description));
+
+ const html = userRoles
+ .map(
+ (role) => `
+
+
${Utils.escapeHtml(role)}
+
${Utils.escapeHtml(roleMap[role] || "")}
+
+ `,
+ )
+ .join("");
+
+ $container.html(html);
+ }
+
+ $("#submit-password-btn").on("click", async function () {
+ const $btn = $(this);
+ const newPass = $("#new-password").val();
+ const confirm = $("#confirm-password").val();
+
+ if (newPass !== confirm) {
+ Utils.showToast("Пароли не совпадают", "error");
+ return;
}
- });
\ No newline at end of file
+
+ if (newPass.length < 4) {
+ Utils.showToast("Пароль слишком короткий", "error");
+ return;
+ }
+
+ $btn.prop("disabled", true).text("Меняем...");
+
+ try {
+ await Api.request("/api/auth/me", {
+ method: "PUT",
+ body: JSON.stringify({
+ password: newPass,
+ }),
+ });
+
+ Utils.showToast("Пароль успешно изменен", "success");
+ window.dispatchEvent(new CustomEvent("close-modal"));
+
+ $("#change-password-form")[0].reset();
+ } catch (error) {
+ console.error(error);
+ Utils.showToast(error.message || "Ошибка смены пароля", "error");
+ } finally {
+ $btn.prop("disabled", false).text("Сменить");
+ }
+ });
+});
diff --git a/library_service/static/styles.css b/library_service/static/styles.css
index db5bc29..2b4e29c 100644
--- a/library_service/static/styles.css
+++ b/library_service/static/styles.css
@@ -13,6 +13,7 @@ h1 {
letter-spacing: 10px;
}
+h2,
nav ul li a {
font-family: "Dited", sans-serif;
letter-spacing: 2.5px;
@@ -245,8 +246,8 @@ button:disabled {
}
.line-clamp-3 {
-display: -webkit-box;
--webkit-line-clamp: 3;
--webkit-box-orient: vertical;
-overflow: hidden;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
}
diff --git a/library_service/static/utils.js b/library_service/static/utils.js
new file mode 100644
index 0000000..6f0c194
--- /dev/null
+++ b/library_service/static/utils.js
@@ -0,0 +1,123 @@
+const Utils = {
+ escapeHtml: (text) => {
+ if (!text) return "";
+ return text.replace(/[&<>"']/g, function (m) {
+ return {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ }[m];
+ });
+ },
+
+ showToast: (message, type = "info") => {
+ const container = document.getElementById("toast-container");
+ if (!container) return;
+
+ const el = document.createElement("div");
+ const colors =
+ type === "error"
+ ? "bg-red-500"
+ : type === "success"
+ ? "bg-green-500"
+ : "bg-blue-500";
+ el.className = `${colors} text-white px-6 py-3 rounded shadow-lg transform transition-all duration-300 translate-y-10 opacity-0 mb-3`;
+ el.textContent = message;
+
+ container.appendChild(el);
+
+ requestAnimationFrame(() => {
+ el.classList.remove("translate-y-10", "opacity-0");
+ });
+
+ setTimeout(() => {
+ el.classList.add("translate-y-10", "opacity-0");
+ setTimeout(() => el.remove(), 300);
+ }, 3000);
+ },
+
+ getGravatarUrl: async (email) => {
+ if (!email) return "";
+ const msgBuffer = new TextEncoder().encode(email.trim().toLowerCase());
+ const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
+ const hashHex = hashArray
+ .map((b) => b.toString(16).padStart(2, "0"))
+ .join("");
+ return `https://www.gravatar.com/avatar/${hashHex}?d=identicon&s=200`;
+ },
+};
+
+const Api = {
+ async request(endpoint, options = {}) {
+ const token = localStorage.getItem("access_token");
+ const headers = {
+ "Content-Type": "application/json",
+ ...options.headers,
+ };
+
+ if (token) {
+ headers["Authorization"] = `Bearer ${token}`;
+ }
+
+ const config = { ...options, headers };
+
+ try {
+ const response = await fetch(endpoint, config);
+ if (response.status === 401) {
+ Auth.logout();
+ return null;
+ }
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ throw new Error(errorData.detail || `Error ${response.status}`);
+ }
+ return response.json();
+ } catch (error) {
+ throw error;
+ }
+ },
+
+ get(endpoint) {
+ return this.request(endpoint, { method: "GET" });
+ },
+
+ post(endpoint, body) {
+ return this.request(endpoint, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ },
+
+ postForm(endpoint, formData) {
+ return this.request(endpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: formData.toString(),
+ });
+ },
+};
+
+const Auth = {
+ logout: () => {
+ localStorage.removeItem("access_token");
+ localStorage.removeItem("refresh_token");
+ window.location.reload();
+ },
+
+ init: async () => {
+ const token = localStorage.getItem("access_token");
+ if (!token) return;
+
+ try {
+ const user = await Api.get("/api/auth/me");
+ if (user) {
+ document.dispatchEvent(new CustomEvent("auth:login", { detail: user }));
+ }
+ } catch (e) {
+ console.error("Auth check failed", e);
+ }
+ },
+};
diff --git a/library_service/templates/auth.html b/library_service/templates/auth.html
index 715ec25..b73cf24 100644
--- a/library_service/templates/auth.html
+++ b/library_service/templates/auth.html
@@ -19,7 +19,6 @@ block content %}
Регистрация
-