From 5096b452435235c70617b0067cce757c528e23ff Mon Sep 17 00:00:00 2001 From: wowlikon Date: Sun, 21 Dec 2025 00:12:17 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D0=B7=D0=B0=D1=80=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=8B=20=D0=B4=D0=BB=D1=8F=20=D1=80=D0=BE=D0=BB=D0=B5=D0=B9,?= =?UTF-8?q?=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BE=D0=B2=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=B8=D0=B3,=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D1=8D?= =?UTF-8?q?=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data.py | 4 +- library_service/auth.py | 6 +- library_service/models/dto/combined.py | 8 +- library_service/models/dto/role.py | 1 + library_service/routers/books.py | 2 +- library_service/static/auth.js | 344 ++------- library_service/static/author.js | 358 ++-------- library_service/static/authors.js | 582 +++++---------- library_service/static/avatar.svg | 15 - library_service/static/base.js | 116 +++ library_service/static/book.js | 418 +++-------- library_service/static/books.js | 661 +++++++----------- library_service/static/index.js | 122 +--- library_service/static/profile.js | 624 ++++------------- library_service/static/styles.css | 9 +- library_service/static/utils.js | 123 ++++ library_service/templates/auth.html | 1 - library_service/templates/author.html | 105 ++- library_service/templates/authors.html | 165 +++-- library_service/templates/base.html | 215 ++++-- library_service/templates/book.html | 128 +++- library_service/templates/books.html | 166 +++-- library_service/templates/profile.html | 210 ++++-- .../versions/a8e40ab24138_role_payroll.py | 41 ++ 24 files changed, 1811 insertions(+), 2613 deletions(-) delete mode 100644 library_service/static/avatar.svg create mode 100644 library_service/static/base.js create mode 100644 library_service/static/utils.js create mode 100644 migrations/versions/a8e40ab24138_role_payroll.py 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} -
- - -
-
-

${escapeHtml(author.name)}

- ID: ${author.id} -
- -
- - - - ${booksCount} ${booksWord} в библиотеке -
- - - - - - - Вернуться к списку авторов - -
-
- `); - } - - 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(` -
    - -
    - Аватар - - - ${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 %} Регистрация
    -
    +
    +

    Книги автора

    +
    +
    + {% endblock %} {% block scripts %} - -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/library_service/templates/authors.html b/library_service/templates/authors.html index 656cf12..4efee0b 100644 --- a/library_service/templates/authors.html +++ b/library_service/templates/authors.html @@ -1,20 +1,88 @@ -{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %} {% block -content %} -
    -
    {% endblock %} {% block scripts %} - -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/library_service/templates/books.html b/library_service/templates/books.html index b9fc378..50d95c9 100644 --- a/library_service/templates/books.html +++ b/library_service/templates/books.html @@ -1,79 +1,129 @@ -{% extends "base.html" %} {% block title %}LiB - Главная{% endblock %} {% block -content %} -
    -