From 9d25d2e5de1f75761c6f5dd800c02085a76b99a6 Mon Sep 17 00:00:00 2001 From: wowlikon Date: Mon, 22 Dec 2025 01:38:52 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D1=81=D1=88=D0=B8=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D1=8D=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 53 +- data.py | 2 +- library_service/auth.py | 33 +- library_service/models/dto/role.py | 2 +- library_service/routers/auth.py | 71 +- library_service/routers/books.py | 5 +- library_service/routers/misc.py | 50 +- library_service/static/auth.js | 71 +- library_service/static/author.js | 5 + library_service/static/authors.js | 6 +- library_service/static/base.js | 116 --- library_service/static/book.js | 174 ++++- library_service/static/books.js | 48 +- library_service/static/create_author.js | 89 +++ library_service/static/create_book.js | 346 +++++++++ library_service/static/create_genre.js | 89 +++ library_service/static/edit_author.js | 229 ++++++ library_service/static/edit_book.js | 457 ++++++++++++ library_service/static/edit_genre.js | 233 ++++++ library_service/static/index.js | 2 +- library_service/static/profile.js | 7 +- library_service/static/styles.css | 2 + library_service/static/users.js | 701 +++++++++++++++++++ library_service/static/utils.js | 138 +++- library_service/templates/author.html | 58 +- library_service/templates/base.html | 23 + library_service/templates/book.html | 103 ++- library_service/templates/books.html | 63 +- library_service/templates/create_author.html | 162 +++++ library_service/templates/create_book.html | 229 ++++++ library_service/templates/create_genre.html | 162 +++++ library_service/templates/edit_author.html | 316 +++++++++ library_service/templates/edit_book.html | 394 +++++++++++ library_service/templates/edit_genre.html | 317 +++++++++ library_service/templates/users.html | 433 ++++++++++++ 35 files changed, 4901 insertions(+), 288 deletions(-) delete mode 100644 library_service/static/base.js create mode 100644 library_service/static/create_author.js create mode 100644 library_service/static/create_book.js create mode 100644 library_service/static/create_genre.js create mode 100644 library_service/static/edit_author.js create mode 100644 library_service/static/edit_book.js create mode 100644 library_service/static/edit_genre.js create mode 100644 library_service/static/users.js create mode 100644 library_service/templates/create_author.html create mode 100644 library_service/templates/create_book.html create mode 100644 library_service/templates/create_genre.html create mode 100644 library_service/templates/edit_author.html create mode 100644 library_service/templates/edit_book.html create mode 100644 library_service/templates/edit_genre.html create mode 100644 library_service/templates/users.html diff --git a/README.md b/README.md index 88a7d65..04dfa50 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ![logo](./logo.png) -# LibraryAPI +# LiB Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания. @@ -53,34 +53,34 @@ ### **Эндпоинты API** **Авторы** -| Метод | Эндпоинты | Описание | -|--------|-----------------------|---------------------------------| -| POST | `/authors` | Создать нового автора | -| GET | `/authors` | Получить список всех авторов | -| GET | `/authors/{id}` | Получить автора по ID с книгами | -| PUT | `/authors/{id}` | Обновить автора по ID | -| DELETE | `/authors/{id}` | Удалить автора по ID | +| Метод | Эндпоинты | Описание | +|--------|---------------------------|---------------------------------| +| POST | `/api/authors` | Создать нового автора | +| GET | `/api/authors` | Получить список всех авторов | +| GET | `/api/authors/{id}` | Получить автора по ID с книгами | +| PUT | `/api/authors/{id}` | Обновить автора по ID | +| DELETE | `/api/authors/{id}` | Удалить автора по ID | **Книги** -| Метод | Эндпоинты | Описание | -|--------|-----------------------|---------------------------------| -| POST | `/books` | Создать новую книгу | -| GET | `/books` | Получить список всех книг | -| GET | `/book/{id}` | Получить книгу по ID с авторами | -| PUT | `/books/{id}` | Обновить книгу по ID | -| DELETE | `/books/{id}` | Удалить книгу по ID | +| Метод | Эндпоинты | Описание | +|--------|---------------------------|---------------------------------| +| POST | `/api/books` | Создать новую книгу | +| GET | `/api/books` | Получить список всех книг | +| GET | `/api/book/{id}` | Получить книгу по ID с авторами | +| PUT | `/api/books/{id}` | Обновить книгу по ID | +| DELETE | `/api/books/{id}` | Удалить книгу по ID | **Жанры** -| Метод | Эндпоинты | Описание | -|--------|-----------------------|---------------------------------| -| POST | `/genres` | Создать новый жанр | -| GET | `/genres` | Получить список всех жанров | -| GET | `/genres/{id}` | Получить жанр по ID | -| PUT | `/genres/{id}` | Обновить жанр по ID | -| DELETE | `/genres/{id}` | Удалить жанр по ID | +| Метод | Эндпоинты | Описание | +|--------|----------------------------|--------------------------------| +| POST | `/api/genres` | Создать новый жанр | +| GET | `/api/genres` | Получить список всех жанров | +| GET | `/api/genres/{id}` | Получить жанр по ID | +| PUT | `/api/genres/{id}` | Обновить жанр по ID | +| DELETE | `/api/genres/{id}` | Удалить жанр по ID | **Связи** -| Метод | Эндпоинты | Описание | +| Метод | Эндпоинты | Описание | |--------|------------------------------|-----------------------------------| | GET | `/authors/{id}/books` | Получить список книг для автора | | GET | `/books/{id}/authors` | Получить список авторов для книги | @@ -92,9 +92,10 @@ | DELETE | `/relationships/genre-book` | Разделить автор-книга | **Другие** -| Метод | Эндпоинты | Описание | -|--------|-------------|-------------------------------| -| GET | `/api/info` | Получить информацию о сервисе | +| Метод | Эндпоинты | Описание | +|--------|--------------|----------------------------------------------| +| GET | `/api/info` | Получить общую информацию о сервисе | +| GET | `/api/stats` | Получить статистическую информацию о сервисе | ```mermaid diff --git a/data.py b/data.py index c9463b5..2e107d8 100644 --- a/data.py +++ b/data.py @@ -3,7 +3,7 @@ from typing import Optional # Конфигурация USERNAME = "admin" -PASSWORD = "4ai2_pQnrJ1-tDx-XSLTKw" +PASSWORD = "GzwQMe3j2DsPRKpL2DVw6A" BASE_URL = "http://localhost:8000" diff --git a/library_service/auth.py b/library_service/auth.py index 178e0d3..ce07609 100644 --- a/library_service/auth.py +++ b/library_service/auth.py @@ -64,25 +64,27 @@ def create_refresh_token(data: dict) -> str: return encoded_jwt -def decode_token(token: str) -> TokenData: +def decode_token(token: str, expected_type: str = "access") -> TokenData: """Декодирование и проверка JWT токенов.""" + token_error = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + headers={"WWW-Authenticate": "Bearer"}, + ) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) - username: str = payload.get("sub") - user_id: int = payload.get("user_id") - if username is None: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + username: str | None = payload.get("sub") + user_id: int | None = payload.get("user_id") + token_type: str | None = payload.get("type") + if token_type != expected_type: + token_error.detail=f"Invalid token type. Expected {expected_type}" + raise token_error + if username is None or user_id is None: + token_error.detail="Could not validate credentials" + raise token_error return TokenData(username=username, user_id=user_id) except JWTError: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) + token_error.detail="Could not validate credentials" + raise token_error def authenticate_user(session: Session, username: str, password: str) -> User | None: @@ -140,7 +142,8 @@ def require_role(role_name: str): # Создание dependencies RequireAuth = Annotated[User, Depends(get_current_active_user)] RequireAdmin = Annotated[User, Depends(require_role("admin"))] -RequireModerator = Annotated[User, Depends(require_role("moderator"))] +RequireMember = Annotated[User, Depends(require_role("member"))] +RequireLibrarian = Annotated[User, Depends(require_role("librarian"))] def seed_roles(session: Session) -> dict[str, Role]: diff --git a/library_service/models/dto/role.py b/library_service/models/dto/role.py index a3628a3..28100f8 100644 --- a/library_service/models/dto/role.py +++ b/library_service/models/dto/role.py @@ -8,7 +8,7 @@ class RoleBase(SQLModel): """Базовая модель роли""" name: str description: str | None = None - payroll: int + payroll: int = 0 class RoleCreate(RoleBase): diff --git a/library_service/routers/auth.py b/library_service/routers/auth.py index 63f06e6..4f992f7 100644 --- a/library_service/routers/auth.py +++ b/library_service/routers/auth.py @@ -2,15 +2,15 @@ from datetime import timedelta from typing import Annotated -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Body, Depends, HTTPException, status from fastapi.security import OAuth2PasswordRequestForm from sqlmodel import Session, select from library_service.models.db import Role, User from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList from library_service.settings import get_session -from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin, - RequireAuth, authenticate_user, get_password_hash, +from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin, RequireAuth, + authenticate_user, get_password_hash, decode_token, create_access_token, create_refresh_token) router = APIRouter(prefix="/auth", tags=["authentication"]) @@ -49,7 +49,7 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)): hashed_password=get_password_hash(user_data.password) ) - default_role = session.exec(select(Role).where(Role.name == "user")).first() + default_role = session.exec(select(Role).where(Role.name == "member")).first() if default_role: db_user.roles.append(default_role) @@ -93,13 +93,62 @@ def login( ) +@router.post( + "/refresh", + response_model=Token, + summary="Обновление токена", + description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен", +) +def refresh_token( + refresh_token: str = Body(..., embed=True), + session: Session = Depends(get_session), +): + """Эндпоинт для обновления токенов.""" + try: + token_data = decode_token(refresh_token, expected_type="refresh") + except HTTPException: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = session.get(User, token_data.user_id) + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is inactive", + ) + + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + new_access_token = create_access_token( + data={"sub": user.username, "user_id": user.id}, + expires_delta=access_token_expires, + ) + new_refresh_token = create_refresh_token( + data={"sub": user.username, "user_id": user.id} + ) + + return Token( + access_token=new_access_token, + refresh_token=new_refresh_token, + token_type="bearer", + ) + + @router.get( "/me", response_model=UserRead, summary="Текущий пользователь", description="Получить информацию о текущем авторизованном пользователе", ) -def read_users_me(current_user: RequireAuth): +def get_my_profile(current_user: RequireAuth): """Эндпоинт получения информации о себе""" return UserRead( **current_user.model_dump(), roles=[role.name for role in current_user.roles] @@ -142,14 +191,17 @@ def update_user_me( ) def read_users( admin: RequireAdmin, - session: Session = Depends(get_session), skip: int = 0, limit: int = 100, + session: Session = Depends(get_session), ): """Эндпоинт получения списка всех пользователей""" users = session.exec(select(User).offset(skip).limit(limit)).all() return UserList( - users=[UserRead(**user.model_dump()) for user in users], + users=[ + UserRead(**user.model_dump(), roles=[r.name for r in user.roles]) + for user in users + ], total=len(users), ) @@ -243,11 +295,14 @@ def remove_role_from_user( description="Возвращает список ролей", ) def get_roles( + auth: RequireAuth, session: Session = Depends(get_session), ): """Эндпоинт получения списа ролей""" + user_roles = [role.name for role in auth.roles] + exclude = {"payroll"} if "admin" in user_roles else set() roles = session.exec(select(Role)).all() return RoleList( - roles=[RoleRead(**role.model_dump()) for role in roles], + roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles], total=len(roles), ) diff --git a/library_service/routers/books.py b/library_service/routers/books.py index 4644d83..c0b757f 100644 --- a/library_service/routers/books.py +++ b/library_service/routers/books.py @@ -67,7 +67,7 @@ def filter_books( @router.post( "/", - response_model=Book, + response_model=BookRead, summary="Создать книгу", description="Добавляет книгу в систему", ) @@ -149,6 +149,7 @@ def update_book( db_book.title = book.title or db_book.title db_book.description = book.description or db_book.description + db_book.status = book.status or db_book.status session.commit() session.refresh(db_book) return db_book @@ -170,7 +171,7 @@ def delete_book( if not book: raise HTTPException(status_code=404, detail="Book not found") book_read = BookRead( - id=(book.id or 0), title=book.title, description=book.description + id=(book.id or 0), title=book.title, description=book.description, status=book.status ) session.delete(book) session.commit() diff --git a/library_service/routers/misc.py b/library_service/routers/misc.py index dcef225..4c4e2ec 100644 --- a/library_service/routers/misc.py +++ b/library_service/routers/misc.py @@ -36,12 +36,36 @@ async def root(request: Request): return templates.TemplateResponse(request, "index.html") +@router.get("/genre/create", include_in_schema=False) +async def create_genre(request: Request): + """Эндпоинт страницы создания жанра""" + return templates.TemplateResponse(request, "create_genre.html") + + +@router.get("/genre/{genre_id}/edit", include_in_schema=False) +async def edit_genre(request: Request, genre_id: int): + """Эндпоинт страницы редактирования жанра""" + return templates.TemplateResponse(request, "edit_genre.html") + + @router.get("/authors", include_in_schema=False) async def authors(request: Request): """Эндпоинт страницы выбора автора""" return templates.TemplateResponse(request, "authors.html") +@router.get("/author/create", include_in_schema=False) +async def create_author(request: Request): + """Эндпоинт страницы создания автора""" + return templates.TemplateResponse(request, "create_author.html") + + +@router.get("/author/{author_id}/edit", include_in_schema=False) +async def edit_author(request: Request, author_id: int): + """Эндпоинт страницы редактирования автора""" + return templates.TemplateResponse(request, "edit_author.html") + + @router.get("/author/{author_id}", include_in_schema=False) async def author(request: Request, author_id: int): """Эндпоинт страницы автора""" @@ -54,16 +78,28 @@ async def books(request: Request): return templates.TemplateResponse(request, "books.html") +@router.get("/book/create", include_in_schema=False) +async def create_book(request: Request): + """Эндпоинт страницы создания книги""" + return templates.TemplateResponse(request, "create_book.html") + + +@router.get("/book/{book_id}/edit", include_in_schema=False) +async def edit_book(request: Request, book_id: int): + """Эндпоинт страницы редактирования книги""" + return templates.TemplateResponse(request, "edit_book.html") + + @router.get("/book/{book_id}", include_in_schema=False) async def book(request: Request, book_id: int): """Эндпоинт страницы книги""" return templates.TemplateResponse(request, "book.html") -@router.get("/auth", include_in_schema=False) -async def auth(request: Request): - """Эндпоинт страницы авторизации""" - return templates.TemplateResponse(request, "auth.html") + @router.get("/auth", include_in_schema=False) + async def auth(request: Request): + """Эндпоинт страницы авторизации""" + return templates.TemplateResponse(request, "auth.html") @router.get("/profile", include_in_schema=False) @@ -72,6 +108,12 @@ async def profile(request: Request): return templates.TemplateResponse(request, "profile.html") +@router.get("/users", include_in_schema=False) +async def users(request: Request): + """Эндпоинт страницы управления пользователями""" + return templates.TemplateResponse(request, "users.html") + + @router.get("/api", include_in_schema=False) async def api(request: Request, app=Depends(lambda: get_app())): """Страница с сылками на документацию API""" diff --git a/library_service/static/auth.js b/library_service/static/auth.js index c6bd10b..c8aac95 100644 --- a/library_service/static/auth.js +++ b/library_service/static/auth.js @@ -1,4 +1,73 @@ -$(function () { +$(() => { + $("#login-tab").on("click", function () { + $("#login-tab") + .removeClass("text-gray-400 hover:text-gray-600") + .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); + $("#register-tab") + .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") + .addClass("text-gray-400 hover:text-gray-600"); + + $("#login-form").removeClass("hidden"); + $("#register-form").addClass("hidden"); + }); + + $("#register-tab").on("click", function () { + $("#register-tab") + .removeClass("text-gray-400 hover:text-gray-600") + .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500"); + $("#login-tab") + .removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500") + .addClass("text-gray-400 hover:text-gray-600"); + + $("#register-form").removeClass("hidden"); + $("#login-form").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); + $("#login-form").on("submit", async function (event) { event.preventDefault(); const $submitBtn = $("#login-submit"); diff --git a/library_service/static/author.js b/library_service/static/author.js index 5ee133d..f641524 100644 --- a/library_service/static/author.js +++ b/library_service/static/author.js @@ -12,6 +12,11 @@ $(document).ready(() => { document.title = `LiB - ${author.name}`; renderAuthor(author); renderBooks(author.books); + if (window.canManage) { + $("#edit-author-btn") + .attr("href", `/author/${author.id}/edit`) + .removeClass("hidden"); + } }) .catch((error) => { console.error(error); diff --git a/library_service/static/authors.js b/library_service/static/authors.js index 8f06df4..5a91efb 100644 --- a/library_service/static/authors.js +++ b/library_service/static/authors.js @@ -2,7 +2,7 @@ $(document).ready(() => { let allAuthors = []; let filteredAuthors = []; let currentPage = 1; - let pageSize = 12; + let pageSize = 24; let currentSort = "name_asc"; loadAuthors(); @@ -119,7 +119,7 @@ $(document).ready(() => { $("#pagination-container").append($pagination); - $("#prev-page").on("click", () => { + $("#prev-page").on("click", function () { if (currentPage > 1) { currentPage--; renderAuthors(); @@ -127,7 +127,7 @@ $(document).ready(() => { scrollToTop(); } }); - $("#next-page").on("click", () => { + $("#next-page").on("click", function () { if (currentPage < totalPages) { currentPage++; renderAuthors(); diff --git a/library_service/static/base.js b/library_service/static/base.js deleted file mode 100644 index c2c28d7..0000000 --- a/library_service/static/base.js +++ /dev/null @@ -1,116 +0,0 @@ -$(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 fe36a01..c28e8bf 100644 --- a/library_service/static/book.js +++ b/library_service/static/book.js @@ -4,41 +4,31 @@ $(document).ready(() => { label: "Доступна", bgClass: "bg-green-100", textClass: "text-green-800", - icon: ` - - `, + icon: ``, }, borrowed: { label: "Выдана", bgClass: "bg-yellow-100", textClass: "text-yellow-800", - icon: ` - - `, + icon: ``, }, reserved: { label: "Забронирована", bgClass: "bg-blue-100", textClass: "text-blue-800", - icon: ` - - `, + icon: ``, }, restoration: { label: "На реставрации", bgClass: "bg-orange-100", textClass: "text-orange-800", - icon: ` - - `, + icon: ``, }, written_off: { label: "Списана", bgClass: "bg-red-100", textClass: "text-red-800", - icon: ` - - `, + icon: ``, }, }; @@ -55,6 +45,7 @@ $(document).ready(() => { const pathParts = window.location.pathname.split("/"); const bookId = pathParts[pathParts.length - 1]; + let currentBook = null; if (!bookId || isNaN(bookId)) { Utils.showToast("Некорректный ID книги", "error"); @@ -63,8 +54,14 @@ $(document).ready(() => { Api.get(`/api/books/${bookId}`) .then((book) => { + currentBook = book; document.title = `LiB - ${book.title}`; renderBook(book); + if (window.canManage) { + $("#edit-book-btn") + .attr("href", `/book/${book.id}/edit`) + .removeClass("hidden"); + } }) .catch((error) => { console.error(error); @@ -82,20 +79,19 @@ $(document).ready(() => { ); $("#book-description").text(book.description || "Описание отсутствует"); - 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}`, - ); + renderStatusWidget(book); + + if (!window.canManage && book.status === "active") { + renderReserveButton(); + } if (book.genres && book.genres.length > 0) { $("#genres-section").removeClass("hidden"); const $genres = $("#genres-container"); + $genres.empty(); book.genres.forEach((g) => { $genres.append(` - + ${Utils.escapeHtml(g.name)} `); @@ -105,13 +101,14 @@ $(document).ready(() => { if (book.authors && book.authors.length > 0) { $("#authors-section").removeClass("hidden"); const $authors = $("#authors-container"); + $authors.empty(); book.authors.forEach((a) => { $authors.append(` - -
+ +
${a.name.charAt(0).toUpperCase()}
- ${Utils.escapeHtml(a.name)} + ${Utils.escapeHtml(a.name)}
`); }); @@ -120,4 +117,129 @@ $(document).ready(() => { $("#book-loader").addClass("hidden"); $("#book-content").removeClass("hidden"); } + + function renderStatusWidget(book) { + const $container = $("#book-status-container"); + $container.empty(); + const config = getStatusConfig(book.status); + + if (window.canManage) { + const $dropdownHTML = $(` +
+ + + +
+ `); + + $container.append($dropdownHTML); + + const $toggleBtn = $("#status-toggle-btn"); + const $menu = $("#status-menu"); + + $toggleBtn.on("click", (e) => { + e.stopPropagation(); + $menu.toggleClass("hidden"); + }); + + $(document).on("click", (e) => { + if ( + !$toggleBtn.is(e.target) && + $toggleBtn.has(e.target).length === 0 && + !$menu.has(e.target).length + ) { + $menu.addClass("hidden"); + } + }); + + $(".status-option").on("click", function () { + const newStatus = $(this).data("status"); + if (newStatus !== currentBook.status) { + updateBookStatus(newStatus); + } + $menu.addClass("hidden"); + }); + } else { + $container.append(` + + ${config.icon} + ${config.label} + + `); + } + } + + function renderReserveButton() { + const $container = $("#book-actions-container"); + $container.html(` + + `); + + $("#reserve-btn").on("click", function () { + Utils.showToast("Функция бронирования в разработке", "info"); + }); + } + + async function updateBookStatus(newStatus) { + const $toggleBtn = $("#status-toggle-btn"); + const originalContent = $toggleBtn.html(); + + $toggleBtn.prop("disabled", true).addClass("opacity-75").html(` + + Обновление... + `); + + try { + const payload = { + title: currentBook.title, + description: currentBook.description, + status: newStatus, + }; + + const updatedBook = await Api.put( + `/api/books/${currentBook.id}`, + payload, + ); + currentBook = updatedBook; + + Utils.showToast("Статус успешно изменен", "success"); + + renderStatusWidget(updatedBook); + } catch (error) { + console.error(error); + Utils.showToast("Ошибка при смене статуса", "error"); + $toggleBtn + .prop("disabled", false) + .removeClass("opacity-75") + .html(originalContent); + } + } }); diff --git a/library_service/static/books.js b/library_service/static/books.js index d36fc1a..6054a1f 100644 --- a/library_service/static/books.js +++ b/library_service/static/books.js @@ -40,7 +40,7 @@ $(document).ready(() => { let selectedAuthors = new Map(); let selectedGenres = new Map(); let currentPage = 1; - let pageSize = 20; + let pageSize = 12; let totalBooks = 0; const urlParams = new URLSearchParams(window.location.search); @@ -87,14 +87,31 @@ $(document).ready(() => { const isChecked = genreIdsFromUrl.includes(String(genre.id)); if (isChecked) selectedGenres.set(genre.id, genre.name); + const editButton = window.canManage() + ? ` + + + + ` + : ""; + $list.append(` -
  • - -
  • - `); +
  • +
    + + ${editButton} +
    +
  • + `); + }); + + $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); }); $list.on("change", "input", function () { @@ -211,14 +228,14 @@ $(document).ready(() => { $("#pagination-container").append($pagination); - $("#prev-page").on("click", () => { + $("#prev-page").on("click", function () { if (currentPage > 1) { currentPage--; loadBooks(); scrollToTop(); } }); - $("#next-page").on("click", () => { + $("#next-page").on("click", function () { if (currentPage < totalPages) { currentPage++; loadBooks(); @@ -253,7 +270,7 @@ $(document).ready(() => { } function scrollToTop() { - $("html, body").animate({ scrollTop: 0 }, 300); + window.scrollTo({ top: 0, behavior: "smooth" }); } function showLoadingState() { @@ -384,4 +401,13 @@ $(document).ready(() => { loadBooks(); } }); + + function showAdminControls() { + if (window.canManage) { + $("#admin-actions").removeClass("hidden"); + } + } + + showAdminControls(); + setTimeout(showAdminControls, 100); }); diff --git a/library_service/static/create_author.js b/library_service/static/create_author.js new file mode 100644 index 0000000..72a9cc2 --- /dev/null +++ b/library_service/static/create_author.js @@ -0,0 +1,89 @@ +$(document).ready(() => { + if (!window.canManage) return; + setTimeout(() => window.canManage, 100); + + const $form = $("#create-author-form"); + const $nameInput = $("#author-name"); + const $submitBtn = $("#submit-btn"); + const $submitText = $("#submit-text"); + const $loadingSpinner = $("#loading-spinner"); + const $successModal = $("#success-modal"); + + $nameInput.on("input", function () { + $("#name-counter").text(`${this.value.length}/255`); + }); + + $form.on("submit", async function (e) { + e.preventDefault(); + + const name = $nameInput.val().trim(); + + if (!name) { + Utils.showToast("Введите имя автора", "error"); + return; + } + + setLoading(true); + + try { + const author = await Api.post("/api/authors/", { name }); + showSuccess(author); + } catch (error) { + console.error("Ошибка создания:", error); + + let errorMsg = "Произошла ошибка при создании автора"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } else if (error.status === 409) { + errorMsg = "Автор с таким именем уже существует"; + } + + Utils.showToast(errorMsg, "error"); + } finally { + setLoading(false); + } + }); + + function setLoading(isLoading) { + $submitBtn.prop("disabled", isLoading); + if (isLoading) { + $submitText.text("Сохранение..."); + $loadingSpinner.removeClass("hidden"); + } else { + $submitText.text("Создать автора"); + $loadingSpinner.addClass("hidden"); + } + } + + function showSuccess(author) { + $("#modal-author-name").text(author.name); + $successModal.removeClass("hidden"); + } + + function resetForm() { + $form[0].reset(); + $("#name-counter").text("0/255"); + } + + $("#modal-close-btn").on("click", function () { + $successModal.addClass("hidden"); + resetForm(); + $nameInput[0].focus(); + }); + + $successModal.on("click", function (e) { + if (e.target === this) { + window.location.href = "/authors"; + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape" && !$successModal.hasClass("hidden")) { + window.location.href = "/authors"; + } + }); +}); diff --git a/library_service/static/create_book.js b/library_service/static/create_book.js new file mode 100644 index 0000000..a1e983c --- /dev/null +++ b/library_service/static/create_book.js @@ -0,0 +1,346 @@ +$(document).ready(() => { + if (!window.canManage) return; + setTimeout(() => window.canManage, 100); + + let allAuthors = []; + let allGenres = []; + const selectedAuthors = new Map(); + const selectedGenres = new Map(); + + const $form = $("#create-book-form"); + const $submitBtn = $("#submit-btn"); + const $submitText = $("#submit-text"); + const $loadingSpinner = $("#loading-spinner"); + const $successModal = $("#success-modal"); + + Promise.all([Api.get("/api/authors"), Api.get("/api/genres")]) + .then(([authorsData, genresData]) => { + allAuthors = authorsData.authors || []; + allGenres = genresData.genres || []; + initAuthors(allAuthors); + initGenres(allGenres); + initializeDropdownListeners(); + }) + .catch((err) => { + console.error("Ошибка загрузки данных:", err); + Utils.showToast( + "Не удалось загрузить списки авторов или жанров", + "error", + ); + }); + + $("#book-title").on("input", function () { + $("#title-counter").text(`${this.value.length}/255`); + }); + + $("#book-description").on("input", function () { + $("#desc-counter").text(`${this.value.length}/2000`); + }); + + function initAuthors(authors) { + const $dropdown = $("#author-dropdown"); + $dropdown.empty(); + authors.forEach((author) => { + $("
    ") + .addClass( + "p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors text-sm", + ) + .attr("data-id", author.id) + .attr("data-name", author.name) + .text(author.name) + .appendTo($dropdown); + }); + } + + function initGenres(genres) { + const $dropdown = $("#genre-dropdown"); + $dropdown.empty(); + genres.forEach((genre) => { + $("
    ") + .addClass( + "p-2 hover:bg-gray-100 cursor-pointer genre-item transition-colors text-sm", + ) + .attr("data-id", genre.id) + .attr("data-name", genre.name) + .text(genre.name) + .appendTo($dropdown); + }); + } + + function renderAuthorChips() { + const $container = $("#selected-authors-container"); + const $dropdown = $("#author-dropdown"); + + $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 renderGenreChips() { + const $container = $("#selected-genres-container"); + const $dropdown = $("#genre-dropdown"); + + $container.empty(); + + selectedGenres.forEach((name, id) => { + $(` + ${Utils.escapeHtml(name)} + + `).appendTo($container); + }); + + $dropdown.find(".genre-item").each(function () { + const id = parseInt($(this).data("id")); + if (selectedGenres.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 initializeDropdownListeners() { + const $authorInput = $("#author-search-input"); + const $authorDropdown = $("#author-dropdown"); + const $authorContainer = $("#selected-authors-container"); + + $authorInput.on("focus", function () { + $authorDropdown.removeClass("hidden"); + }); + + $authorInput.on("input", function () { + const val = $(this).val().toLowerCase(); + $authorDropdown.removeClass("hidden"); + $authorDropdown.find(".author-item").each(function () { + const text = $(this).text().toLowerCase(); + $(this).toggle(text.includes(val)); + }); + }); + + $authorDropdown.on("click", ".author-item", function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + const name = $(this).data("name"); + + if (selectedAuthors.has(id)) { + selectedAuthors.delete(id); + } else { + selectedAuthors.set(id, name); + } + + $authorInput.val(""); + $authorDropdown.find(".author-item").show(); + renderAuthorChips(); + $authorInput[0].focus(); + }); + + $authorContainer.on("click", ".remove-author", function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + selectedAuthors.delete(id); + renderAuthorChips(); + }); + + const $genreInput = $("#genre-search-input"); + const $genreDropdown = $("#genre-dropdown"); + const $genreContainer = $("#selected-genres-container"); + + $genreInput.on("focus", function () { + $genreDropdown.removeClass("hidden"); + }); + + $genreInput.on("input", function () { + const val = $(this).val().toLowerCase(); + $genreDropdown.removeClass("hidden"); + $genreDropdown.find(".genre-item").each(function () { + const text = $(this).text().toLowerCase(); + $(this).toggle(text.includes(val)); + }); + }); + + $genreDropdown.on("click", ".genre-item", function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + const name = $(this).data("name"); + + if (selectedGenres.has(id)) { + selectedGenres.delete(id); + } else { + selectedGenres.set(id, name); + } + + $genreInput.val(""); + $genreDropdown.find(".genre-item").show(); + renderGenreChips(); + $genreInput[0].focus(); + }); + + $genreContainer.on("click", ".remove-genre", function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + selectedGenres.delete(id); + renderGenreChips(); + }); + + $(document).on("click", function (e) { + if ( + !$(e.target).closest( + "#author-search-input, #author-dropdown, #selected-authors-container", + ).length + ) { + $authorDropdown.addClass("hidden"); + } + if ( + !$(e.target).closest( + "#genre-search-input, #genre-dropdown, #selected-genres-container", + ).length + ) { + $genreDropdown.addClass("hidden"); + } + }); + } + + $form.on("submit", async function (e) { + e.preventDefault(); + + const title = $("#book-title").val().trim(); + const description = $("#book-description").val().trim(); + + if (!title) { + Utils.showToast("Введите название книги", "error"); + return; + } + + setLoading(true); + + try { + const bookPayload = { + title: title, + description: description || null, + }; + + const createdBook = await Api.post("/api/books/", bookPayload); + + const linkPromises = []; + + selectedAuthors.forEach((_, authorId) => { + linkPromises.push( + Api.post( + `/api/relationships/author-book?author_id=${authorId}&book_id=${createdBook.id}`, + ), + ); + }); + + selectedGenres.forEach((_, genreId) => { + linkPromises.push( + Api.post( + `/api/relationships/genre-book?genre_id=${genreId}&book_id=${createdBook.id}`, + ), + ); + }); + + if (linkPromises.length > 0) { + await Promise.allSettled(linkPromises); + } + + showSuccess(createdBook); + } catch (error) { + console.error("Ошибка создания:", error); + + let errorMsg = "Произошла ошибка при создании книги"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } + + Utils.showToast(errorMsg, "error"); + } finally { + setLoading(false); + } + }); + + function setLoading(isLoading) { + $submitBtn.prop("disabled", isLoading); + if (isLoading) { + $submitText.text("Сохранение..."); + $loadingSpinner.removeClass("hidden"); + } else { + $submitText.text("Создать книгу"); + $loadingSpinner.addClass("hidden"); + } + } + + function showSuccess(book) { + $("#modal-book-title").text(book.title); + $("#modal-link-btn").attr("href", `/book/${book.id}`); + $successModal.removeClass("hidden"); + } + + function resetForm() { + $form[0].reset(); + selectedAuthors.clear(); + selectedGenres.clear(); + $("#selected-authors-container").empty(); + $("#selected-genres-container").empty(); + $("#title-counter").text("0/255"); + $("#desc-counter").text("0/2000"); + + $("#author-dropdown .author-item") + .removeClass("bg-gray-200 text-gray-900 font-semibold") + .addClass("hover:bg-gray-100"); + $("#genre-dropdown .genre-item") + .removeClass("bg-gray-200 text-gray-900 font-semibold") + .addClass("hover:bg-gray-100"); + } + + $("#modal-close-btn").on("click", function () { + $successModal.addClass("hidden"); + resetForm(); + window.scrollTo(0, 0); + }); + + $successModal.on("click", function (e) { + if (e.target === this) { + window.location.href = "/books"; + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape" && !$successModal.hasClass("hidden")) { + window.location.href = "/books"; + } + }); +}); diff --git a/library_service/static/create_genre.js b/library_service/static/create_genre.js new file mode 100644 index 0000000..9398b21 --- /dev/null +++ b/library_service/static/create_genre.js @@ -0,0 +1,89 @@ +$(document).ready(() => { + if (!window.canManage) return; + setTimeout(() => window.canManage, 100); + + const $form = $("#create-genre-form"); + const $nameInput = $("#genre-name"); + const $submitBtn = $("#submit-btn"); + const $submitText = $("#submit-text"); + const $loadingSpinner = $("#loading-spinner"); + const $successModal = $("#success-modal"); + + $nameInput.on("input", function () { + $("#name-counter").text(`${this.value.length}/100`); + }); + + $form.on("submit", async function (e) { + e.preventDefault(); + + const name = $nameInput.val().trim(); + + if (!name) { + Utils.showToast("Введите название жанра", "error"); + return; + } + + setLoading(true); + + try { + const genre = await Api.post("/api/genres/", { name }); + showSuccess(genre); + } catch (error) { + console.error("Ошибка создания:", error); + + let errorMsg = "Произошла ошибка при создании жанра"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } else if (error.status === 409) { + errorMsg = "Жанр с таким названием уже существует"; + } + + Utils.showToast(errorMsg, "error"); + } finally { + setLoading(false); + } + }); + + function setLoading(isLoading) { + $submitBtn.prop("disabled", isLoading); + if (isLoading) { + $submitText.text("Сохранение..."); + $loadingSpinner.removeClass("hidden"); + } else { + $submitText.text("Создать жанр"); + $loadingSpinner.addClass("hidden"); + } + } + + function showSuccess(genre) { + $("#modal-genre-name").text(genre.name); + $successModal.removeClass("hidden"); + } + + function resetForm() { + $form[0].reset(); + $("#name-counter").text("0/100"); + } + + $("#modal-close-btn").on("click", function () { + $successModal.addClass("hidden"); + resetForm(); + $nameInput[0].focus(); + }); + + $successModal.on("click", function (e) { + if (e.target === this) { + window.location.href = "/books"; + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape" && !$successModal.hasClass("hidden")) { + window.location.href = "/books"; + } + }); +}); diff --git a/library_service/static/edit_author.js b/library_service/static/edit_author.js new file mode 100644 index 0000000..f984b9d --- /dev/null +++ b/library_service/static/edit_author.js @@ -0,0 +1,229 @@ +$(document).ready(() => { + if (!window.canManage()) return; + setTimeout(() => window.canManage(), 100); + + const pathParts = window.location.pathname.split("/"); + const authorId = parseInt(pathParts[pathParts.length - 2]); + + if (!authorId || isNaN(authorId)) { + Utils.showToast("Некорректный ID автора", "error"); + setTimeout(() => (window.location.href = "/authors"), 1500); + return; + } + + let originalAuthor = null; + let authorBooks = []; + + const $form = $("#edit-author-form"); + const $loader = $("#loader"); + const $dangerZone = $("#danger-zone"); + const $nameInput = $("#author-name"); + const $submitBtn = $("#submit-btn"); + const $submitText = $("#submit-text"); + const $loadingSpinner = $("#loading-spinner"); + const $deleteModal = $("#delete-modal"); + const $successModal = $("#success-modal"); + + Promise.all([ + Api.get(`/api/authors/${authorId}`), + Api.get(`/api/authors/${authorId}/books/`), + ]) + .then(([author, booksData]) => { + originalAuthor = author; + authorBooks = booksData.books || booksData || []; + + document.title = `Редактирование: ${author.name} | LiB`; + populateForm(author); + renderAuthorBooks(authorBooks); + + $loader.addClass("hidden"); + $form.removeClass("hidden"); + $dangerZone.removeClass("hidden"); + $("#cancel-btn").attr("href", `/author/${authorId}`); + }) + .catch((error) => { + console.error(error); + Utils.showToast("Автор не найден", "error"); + setTimeout(() => (window.location.href = "/authors"), 1500); + }); + + function populateForm(author) { + $nameInput.val(author.name); + updateCounter(); + } + + function updateCounter() { + $("#name-counter").text(`${$nameInput.val().length}/255`); + } + + $nameInput.on("input", updateCounter); + + function renderAuthorBooks(books) { + const $container = $("#author-books-container"); + $container.empty(); + + $("#books-count").text(books.length > 0 ? `(${books.length})` : ""); + + if (books.length === 0) { + $container.html(` +
    + + + + У автора пока нет книг +
    + `); + return; + } + + books.forEach((book) => { + $container.append(` + +
    +
    + + + +
    + ${Utils.escapeHtml(book.title)} +
    + + + +
    + `); + }); + } + + $form.on("submit", async function (e) { + e.preventDefault(); + + const name = $nameInput.val().trim(); + + if (!name) { + Utils.showToast("Введите имя автора", "error"); + return; + } + + if (name === originalAuthor.name) { + Utils.showToast("Нет изменений для сохранения", "info"); + return; + } + + setLoading(true); + + try { + const updatedAuthor = await Api.put(`/api/authors/${authorId}`, { name }); + originalAuthor = updatedAuthor; + showSuccessModal(updatedAuthor); + } catch (error) { + console.error("Ошибка обновления:", error); + + let errorMsg = "Произошла ошибка при обновлении автора"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } else if (error.status === 404) { + errorMsg = "Автор не найден"; + } else if (error.status === 409) { + errorMsg = "Автор с таким именем уже существует"; + } + + Utils.showToast(errorMsg, "error"); + } finally { + setLoading(false); + } + }); + + function setLoading(isLoading) { + $submitBtn.prop("disabled", isLoading); + if (isLoading) { + $submitText.text("Сохранение..."); + $loadingSpinner.removeClass("hidden"); + } else { + $submitText.text("Сохранить изменения"); + $loadingSpinner.addClass("hidden"); + } + } + + function showSuccessModal(author) { + $("#success-author-name").text(author.name); + $("#success-link-btn").attr("href", `/author/${author.id}`); + $successModal.removeClass("hidden"); + } + + $("#success-close-btn").on("click", function () { + $successModal.addClass("hidden"); + }); + + $successModal.on("click", function (e) { + if (e.target === this) { + $successModal.addClass("hidden"); + } + }); + + $("#delete-btn").on("click", function () { + $("#modal-author-name").text(originalAuthor.name); + + if (authorBooks.length > 0) { + $("#modal-books-warning").removeClass("hidden"); + } else { + $("#modal-books-warning").addClass("hidden"); + } + + $deleteModal.removeClass("hidden"); + }); + + $("#cancel-delete-btn").on("click", function () { + $deleteModal.addClass("hidden"); + }); + + $deleteModal.on("click", function (e) { + if (e.target === this) { + $deleteModal.addClass("hidden"); + } + }); + + $("#confirm-delete-btn").on("click", async function () { + const $btn = $(this); + const $spinner = $("#delete-spinner"); + + $btn.prop("disabled", true); + $spinner.removeClass("hidden"); + + try { + await Api.delete(`/api/authors/${authorId}`); + Utils.showToast("Автор успешно удалён", "success"); + setTimeout(() => (window.location.href = "/authors"), 1000); + } catch (error) { + console.error("Ошибка удаления:", error); + + let errorMsg = "Произошла ошибка при удалении автора"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } + + Utils.showToast(errorMsg, "error"); + $btn.prop("disabled", false); + $spinner.addClass("hidden"); + $deleteModal.addClass("hidden"); + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape") { + if (!$deleteModal.hasClass("hidden")) { + $deleteModal.addClass("hidden"); + } else if (!$successModal.hasClass("hidden")) { + $successModal.addClass("hidden"); + } + } + }); +}); diff --git a/library_service/static/edit_book.js b/library_service/static/edit_book.js new file mode 100644 index 0000000..dcebb56 --- /dev/null +++ b/library_service/static/edit_book.js @@ -0,0 +1,457 @@ +$(document).ready(() => { + if (!window.canManage) return; + setTimeout(() => window.canManage, 100); + + const pathParts = window.location.pathname.split("/"); + const bookId = parseInt(pathParts[pathParts.length - 2]); + + if (!bookId || isNaN(bookId)) { + Utils.showToast("Некорректный ID книги", "error"); + setTimeout(() => (window.location.href = "/books"), 1500); + return; + } + + let originalBook = null; + let allAuthors = []; + let allGenres = []; + const currentAuthors = new Map(); + const currentGenres = new Map(); + + const $form = $("#edit-book-form"); + const $loader = $("#loader"); + const $dangerZone = $("#danger-zone"); + const $titleInput = $("#book-title"); + const $descInput = $("#book-description"); + const $statusSelect = $("#book-status"); + const $submitBtn = $("#submit-btn"); + const $submitText = $("#submit-text"); + const $loadingSpinner = $("#loading-spinner"); + const $deleteModal = $("#delete-modal"); + const $successModal = $("#success-modal"); + + Promise.all([ + Api.get(`/api/books/${bookId}`), + Api.get(`/api/books/${bookId}/authors/`), + Api.get(`/api/books/${bookId}/genres/`), + Api.get("/api/authors"), + Api.get("/api/genres"), + ]) + .then(([book, bookAuthors, bookGenres, authorsData, genresData]) => { + originalBook = book; + allAuthors = authorsData.authors || []; + allGenres = genresData.genres || []; + + (bookAuthors.authors || bookAuthors || []).forEach((a) => + currentAuthors.set(a.id, a.name), + ); + (bookGenres.genres || bookGenres || []).forEach((g) => + currentGenres.set(g.id, g.name), + ); + + document.title = `Редактирование: ${book.title} | LiB`; + populateForm(book); + initAuthorsDropdown(); + initGenresDropdown(); + renderCurrentAuthors(); + renderCurrentGenres(); + + $loader.addClass("hidden"); + $form.removeClass("hidden"); + $dangerZone.removeClass("hidden"); + $("#cancel-btn").attr("href", `/book/${bookId}`); + }) + .catch((error) => { + console.error(error); + Utils.showToast("Ошибка загрузки данных", "error"); + setTimeout(() => (window.location.href = "/books"), 1500); + }); + + function populateForm(book) { + $titleInput.val(book.title); + $descInput.val(book.description || ""); + $statusSelect.val(book.status); + updateCounters(); + } + + function updateCounters() { + $("#title-counter").text(`${$titleInput.val().length}/255`); + $("#desc-counter").text(`${$descInput.val().length}/2000`); + } + + $titleInput.on("input", updateCounters); + $descInput.on("input", updateCounters); + + function initAuthorsDropdown() { + const $dropdown = $("#author-dropdown"); + $dropdown.empty(); + allAuthors.forEach((author) => { + $("
    ") + .addClass( + "p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors text-sm", + ) + .attr("data-id", author.id) + .attr("data-name", author.name) + .text(author.name) + .appendTo($dropdown); + }); + } + + function initGenresDropdown() { + const $dropdown = $("#genre-dropdown"); + $dropdown.empty(); + allGenres.forEach((genre) => { + $("
    ") + .addClass( + "p-2 hover:bg-gray-100 cursor-pointer genre-item transition-colors text-sm", + ) + .attr("data-id", genre.id) + .attr("data-name", genre.name) + .text(genre.name) + .appendTo($dropdown); + }); + } + + function renderCurrentAuthors() { + const $container = $("#current-authors-container"); + const $dropdown = $("#author-dropdown"); + + $container.empty(); + $("#authors-count").text( + currentAuthors.size > 0 ? `(${currentAuthors.size})` : "", + ); + + currentAuthors.forEach((name, id) => { + $(` + ${Utils.escapeHtml(name)} + + `).appendTo($container); + }); + + $dropdown.find(".author-item").each(function () { + const id = parseInt($(this).data("id")); + if (currentAuthors.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 renderCurrentGenres() { + const $container = $("#current-genres-container"); + const $dropdown = $("#genre-dropdown"); + + $container.empty(); + $("#genres-count").text( + currentGenres.size > 0 ? `(${currentGenres.size})` : "", + ); + + currentGenres.forEach((name, id) => { + $(` + ${Utils.escapeHtml(name)} + + `).appendTo($container); + }); + + $dropdown.find(".genre-item").each(function () { + const id = parseInt($(this).data("id")); + if (currentGenres.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"); + } + }); + } + + const $authorInput = $("#author-search-input"); + const $authorDropdown = $("#author-dropdown"); + const $authorContainer = $("#current-authors-container"); + + $authorInput.on("focus", function () { + $authorDropdown.removeClass("hidden"); + }); + + $authorInput.on("input", function () { + const val = $(this).val().toLowerCase(); + $authorDropdown.removeClass("hidden"); + $authorDropdown.find(".author-item").each(function () { + const text = $(this).text().toLowerCase(); + $(this).toggle(text.includes(val)); + }); + }); + + $authorDropdown.on("click", ".author-item", async function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + const name = $(this).data("name"); + + if (currentAuthors.has(id)) { + return; + } + + $(this).addClass("opacity-50 pointer-events-none"); + + try { + await Api.post( + `/api/relationships/author-book?author_id=${id}&book_id=${bookId}`, + ); + currentAuthors.set(id, name); + renderCurrentAuthors(); + Utils.showToast(`Автор "${name}" добавлен`, "success"); + } catch (error) { + console.error(error); + Utils.showToast("Ошибка добавления автора", "error"); + } finally { + $(this).removeClass("opacity-50 pointer-events-none"); + } + + $authorInput.val(""); + $authorDropdown.find(".author-item").show(); + }); + + $authorContainer.on("click", ".remove-author", async function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + const name = $(this).data("name"); + const $chip = $(this).parent(); + + $chip.addClass("opacity-50"); + + try { + await Api.delete( + `/api/relationships/author-book?author_id=${id}&book_id=${bookId}`, + ); + currentAuthors.delete(id); + renderCurrentAuthors(); + Utils.showToast(`Автор "${name}" удалён`, "success"); + } catch (error) { + console.error(error); + Utils.showToast("Ошибка удаления автора", "error"); + $chip.removeClass("opacity-50"); + } + }); + + const $genreInput = $("#genre-search-input"); + const $genreDropdown = $("#genre-dropdown"); + const $genreContainer = $("#current-genres-container"); + + $genreInput.on("focus", function () { + $genreDropdown.removeClass("hidden"); + }); + + $genreInput.on("input", function () { + const val = $(this).val().toLowerCase(); + $genreDropdown.removeClass("hidden"); + $genreDropdown.find(".genre-item").each(function () { + const text = $(this).text().toLowerCase(); + $(this).toggle(text.includes(val)); + }); + }); + + $genreDropdown.on("click", ".genre-item", async function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + const name = $(this).data("name"); + + if (currentGenres.has(id)) { + return; + } + + $(this).addClass("opacity-50 pointer-events-none"); + + try { + await Api.post( + `/api/relationships/genre-book?genre_id=${id}&book_id=${bookId}`, + ); + currentGenres.set(id, name); + renderCurrentGenres(); + Utils.showToast(`Жанр "${name}" добавлен`, "success"); + } catch (error) { + console.error(error); + Utils.showToast("Ошибка добавления жанра", "error"); + } finally { + $(this).removeClass("opacity-50 pointer-events-none"); + } + + $genreInput.val(""); + $genreDropdown.find(".genre-item").show(); + }); + + $genreContainer.on("click", ".remove-genre", async function (e) { + e.stopPropagation(); + const id = parseInt($(this).data("id")); + const name = $(this).data("name"); + const $chip = $(this).parent(); + + $chip.addClass("opacity-50"); + + try { + await Api.delete( + `/api/relationships/genre-book?genre_id=${id}&book_id=${bookId}`, + ); + currentGenres.delete(id); + renderCurrentGenres(); + Utils.showToast(`Жанр "${name}" удалён`, "success"); + } catch (error) { + console.error(error); + Utils.showToast("Ошибка удаления жанра", "error"); + $chip.removeClass("opacity-50"); + } + }); + + $(document).on("click", function (e) { + if (!$(e.target).closest("#author-search-input, #author-dropdown").length) { + $authorDropdown.addClass("hidden"); + } + if (!$(e.target).closest("#genre-search-input, #genre-dropdown").length) { + $genreDropdown.addClass("hidden"); + } + }); + + $form.on("submit", async function (e) { + e.preventDefault(); + + const title = $titleInput.val().trim(); + const description = $descInput.val().trim(); + const status = $statusSelect.val(); + + if (!title) { + Utils.showToast("Введите название книги", "error"); + return; + } + + const payload = {}; + if (title !== originalBook.title) payload.title = title; + if (description !== (originalBook.description || "")) + payload.description = description || null; + if (status !== originalBook.status) payload.status = status; + + if (Object.keys(payload).length === 0) { + Utils.showToast("Нет изменений для сохранения", "info"); + return; + } + + setLoading(true); + + try { + const updatedBook = await Api.put(`/api/books/${bookId}`, payload); + originalBook = updatedBook; + showSuccessModal(updatedBook); + } catch (error) { + console.error("Ошибка обновления:", error); + + let errorMsg = "Произошла ошибка при обновлении книги"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } else if (error.status === 404) { + errorMsg = "Книга не найдена"; + } + + Utils.showToast(errorMsg, "error"); + } finally { + setLoading(false); + } + }); + + function setLoading(isLoading) { + $submitBtn.prop("disabled", isLoading); + if (isLoading) { + $submitText.text("Сохранение..."); + $loadingSpinner.removeClass("hidden"); + } else { + $submitText.text("Сохранить изменения"); + $loadingSpinner.addClass("hidden"); + } + } + + function showSuccessModal(book) { + $("#success-book-title").text(book.title); + $("#success-link-btn").attr("href", `/book/${book.id}`); + $successModal.removeClass("hidden"); + } + + $("#success-close-btn").on("click", function () { + $successModal.addClass("hidden"); + }); + + $successModal.on("click", function (e) { + if (e.target === this) { + $successModal.addClass("hidden"); + } + }); + + $("#delete-btn").on("click", function () { + $("#modal-book-title").text(originalBook.title); + $deleteModal.removeClass("hidden"); + }); + + $("#cancel-delete-btn").on("click", function () { + $deleteModal.addClass("hidden"); + }); + + $deleteModal.on("click", function (e) { + if (e.target === this) { + $deleteModal.addClass("hidden"); + } + }); + + $("#confirm-delete-btn").on("click", async function () { + const $btn = $(this); + const $spinner = $("#delete-spinner"); + + $btn.prop("disabled", true); + $spinner.removeClass("hidden"); + + try { + await Api.delete(`/api/books/${bookId}`); + Utils.showToast("Книга успешно удалена", "success"); + setTimeout(() => (window.location.href = "/books"), 1000); + } catch (error) { + console.error("Ошибка удаления:", error); + + let errorMsg = "Произошла ошибка при удалении книги"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } + + Utils.showToast(errorMsg, "error"); + $btn.prop("disabled", false); + $spinner.addClass("hidden"); + $deleteModal.addClass("hidden"); + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape") { + if (!$deleteModal.hasClass("hidden")) { + $deleteModal.addClass("hidden"); + } else if (!$successModal.hasClass("hidden")) { + $successModal.addClass("hidden"); + } + } + }); +}); diff --git a/library_service/static/edit_genre.js b/library_service/static/edit_genre.js new file mode 100644 index 0000000..e963177 --- /dev/null +++ b/library_service/static/edit_genre.js @@ -0,0 +1,233 @@ +$(document).ready(() => { + if (!window.canManage) { + Utils.showToast("У вас недостаточно прав", "error"); + setTimeout(() => (window.location.href = "/"), 1500); + return; + } + + const pathParts = window.location.pathname.split("/"); + const genreId = parseInt(pathParts[pathParts.length - 2]); + + if (!genreId || isNaN(genreId)) { + Utils.showToast("Некорректный ID жанра", "error"); + setTimeout(() => (window.location.href = "/"), 1500); + return; + } + + let originalGenre = null; + let genreBooks = []; + + const $form = $("#edit-genre-form"); + const $loader = $("#loader"); + const $dangerZone = $("#danger-zone"); + const $nameInput = $("#genre-name"); + const $submitBtn = $("#submit-btn"); + const $submitText = $("#submit-text"); + const $loadingSpinner = $("#loading-spinner"); + const $deleteModal = $("#delete-modal"); + const $successModal = $("#success-modal"); + + Promise.all([ + Api.get(`/api/genres/${genreId}`), + Api.get(`/api/genres/${genreId}/books`), + ]) + .then(([genre, booksData]) => { + originalGenre = genre; + genreBooks = booksData.books || booksData || []; + + document.title = `Редактирование: ${genre.name} | LiB`; + populateForm(genre); + renderGenreBooks(genreBooks); + + $loader.addClass("hidden"); + $form.removeClass("hidden"); + $dangerZone.removeClass("hidden"); + }) + .catch((error) => { + console.error(error); + Utils.showToast("Жанр не найден", "error"); + setTimeout(() => (window.location.href = "/"), 1500); + }); + + function populateForm(genre) { + $nameInput.val(genre.name); + updateCounter(); + } + + function updateCounter() { + $("#name-counter").text(`${$nameInput.val().length}/100`); + } + + $nameInput.on("input", updateCounter); + + function renderGenreBooks(books) { + const $container = $("#genre-books-container"); + $container.empty(); + + $("#books-count").text(books.length > 0 ? `(${books.length})` : ""); + + if (books.length === 0) { + $container.html(` +
    + + + + В этом жанре пока нет книг +
    + `); + return; + } + + books.forEach((book) => { + $container.append(` + +
    +
    + + + +
    +
    + ${Utils.escapeHtml(book.title)} + ${book.authors && book.authors.length > 0 ? `${Utils.escapeHtml(book.authors.map((a) => a.name).join(", "))}` : ""} +
    +
    + + + +
    + `); + }); + } + + $form.on("submit", async function (e) { + e.preventDefault(); + + const name = $nameInput.val().trim(); + + if (!name) { + Utils.showToast("Введите название жанра", "error"); + return; + } + + if (name === originalGenre.name) { + Utils.showToast("Нет изменений для сохранения", "info"); + return; + } + + setLoading(true); + + try { + const updatedGenre = await Api.put(`/api/genres/${genreId}`, { name }); + originalGenre = updatedGenre; + showSuccessModal(updatedGenre); + } catch (error) { + console.error("Ошибка обновления:", error); + + let errorMsg = "Произошла ошибка при обновлении жанра"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } else if (error.status === 404) { + errorMsg = "Жанр не найден"; + } else if (error.status === 409) { + errorMsg = "Жанр с таким названием уже существует"; + } + + Utils.showToast(errorMsg, "error"); + } finally { + setLoading(false); + } + }); + + function setLoading(isLoading) { + $submitBtn.prop("disabled", isLoading); + if (isLoading) { + $submitText.text("Сохранение..."); + $loadingSpinner.removeClass("hidden"); + } else { + $submitText.text("Сохранить изменения"); + $loadingSpinner.addClass("hidden"); + } + } + + function showSuccessModal(genre) { + $("#success-genre-name").text(genre.name); + $successModal.removeClass("hidden"); + } + + $("#success-close-btn").on("click", function () { + $successModal.addClass("hidden"); + }); + + $successModal.on("click", function (e) { + if (e.target === this) { + $successModal.addClass("hidden"); + } + }); + + $("#delete-btn").on("click", function () { + $("#modal-genre-name").text(originalGenre.name); + + if (genreBooks.length > 0) { + $("#modal-books-warning").removeClass("hidden"); + } else { + $("#modal-books-warning").addClass("hidden"); + } + + $deleteModal.removeClass("hidden"); + }); + + $("#cancel-delete-btn").on("click", function () { + $deleteModal.addClass("hidden"); + }); + + $deleteModal.on("click", function (e) { + if (e.target === this) { + $deleteModal.addClass("hidden"); + } + }); + + $("#confirm-delete-btn").on("click", async function () { + const $btn = $(this); + const $spinner = $("#delete-spinner"); + + $btn.prop("disabled", true); + $spinner.removeClass("hidden"); + + try { + await Api.delete(`/api/genres/${genreId}`); + Utils.showToast("Жанр успешно удалён", "success"); + setTimeout(() => (window.location.href = "/"), 1000); + } catch (error) { + console.error("Ошибка удаления:", error); + + let errorMsg = "Произошла ошибка при удалении жанра"; + if (error.responseJSON && error.responseJSON.detail) { + errorMsg = error.responseJSON.detail; + } else if (error.status === 401) { + errorMsg = "Вы не авторизованы"; + } else if (error.status === 403) { + errorMsg = "У вас недостаточно прав"; + } + + Utils.showToast(errorMsg, "error"); + $btn.prop("disabled", false); + $spinner.addClass("hidden"); + $deleteModal.addClass("hidden"); + } + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape") { + if (!$deleteModal.hasClass("hidden")) { + $deleteModal.addClass("hidden"); + } else if (!$successModal.hasClass("hidden")) { + $successModal.addClass("hidden"); + } + } + }); +}); diff --git a/library_service/static/index.js b/library_service/static/index.js index 9c6b6ea..755bf1b 100644 --- a/library_service/static/index.js +++ b/library_service/static/index.js @@ -262,7 +262,7 @@ function observeStatCards() { { threshold: 0.1 }, ); - $cards.each((index, card) => { + $cards.each(function (index, card) { $(card).css({ opacity: "0", transform: "translateY(20px)", diff --git a/library_service/static/profile.js b/library_service/static/profile.js index c8a2021..c1f6623 100644 --- a/library_service/static/profile.js +++ b/library_service/static/profile.js @@ -110,11 +110,8 @@ $(document).ready(() => { $btn.prop("disabled", true).text("Меняем..."); try { - await Api.request("/api/auth/me", { - method: "PUT", - body: JSON.stringify({ - password: newPass, - }), + await Api.put("/api/auth/me", { + password: newPass, }); Utils.showToast("Пароль успешно изменен", "success"); diff --git a/library_service/static/styles.css b/library_service/static/styles.css index 2b4e29c..e786f7a 100644 --- a/library_service/static/styles.css +++ b/library_service/static/styles.css @@ -14,6 +14,8 @@ h1 { } h2, +.book-id, +.book-status, nav ul li a { font-family: "Dited", sans-serif; letter-spacing: 2.5px; diff --git a/library_service/static/users.js b/library_service/static/users.js new file mode 100644 index 0000000..bf6636e --- /dev/null +++ b/library_service/static/users.js @@ -0,0 +1,701 @@ +$(document).ready(() => { + if (!window.isAdmin()) { + $("#users-container").html( + document.getElementById("access-denied-template").innerHTML, + ); + return; + } + setTimeout(() => { + if (!window.isAdmin()) { + $("#users-container").html( + document.getElementById("access-denied-template").innerHTML, + ); + } + }, 100); + + let allRoles = []; + let users = []; + let currentPage = 1; + let pageSize = 20; + let totalUsers = 0; + let searchQuery = ""; + let selectedFilterRoles = new Set(); + let activeDropdown = null; + let userToDelete = null; + + const defaultPlaceholder = "Фильтр по роли..."; + + showLoadingState(); + + Promise.all([ + Api.get("/api/auth/users?skip=0&limit=100"), + Api.get("/api/auth/roles"), + ]) + .then(([usersData, rolesData]) => { + users = usersData.users; + totalUsers = usersData.total; + allRoles = rolesData.roles; + $("#total-users-count").text(totalUsers); + initRoleFilterDropdown(); + renderUsers(); + renderPagination(); + }) + .catch((error) => { + console.error(error); + Utils.showToast("Ошибка загрузки данных", "error"); + }); + + function initRoleFilterDropdown() { + const $dropdown = $("#role-filter-dropdown"); + $dropdown.empty(); + + allRoles.forEach((role) => { + $("
    ") + .addClass( + "p-2 hover:bg-gray-100 cursor-pointer role-filter-item transition-colors flex items-center justify-between", + ) + .attr("data-name", role.name) + .html( + `
    +
    ${Utils.escapeHtml(role.name)}
    + ${role.description ? `
    ${Utils.escapeHtml(role.description)}
    ` : ""} +
    + `, + ) + .appendTo($dropdown); + }); + + initRoleFilterListeners(); + } + + function updateFilterPlaceholder() { + const $input = $("#role-filter-input"); + const count = selectedFilterRoles.size; + + if (count === 0) { + $input.attr("placeholder", defaultPlaceholder); + } else { + $input.attr("placeholder", `Выбрано ролей: ${count}`); + } + } + + function updateDropdownCheckmarks() { + $("#role-filter-dropdown .role-filter-item").each(function () { + const name = $(this).data("name"); + const $check = $(this).find(".check-icon"); + if (selectedFilterRoles.has(name)) { + $check.removeClass("hidden"); + $(this).addClass("bg-gray-50"); + } else { + $check.addClass("hidden"); + $(this).removeClass("bg-gray-50"); + } + }); + } + + function initRoleFilterListeners() { + const $input = $("#role-filter-input"); + const $dropdown = $("#role-filter-dropdown"); + + $input.on("focus", function () { + $dropdown.removeClass("hidden"); + }); + + $input.on("input", function () { + const val = $(this).val().toLowerCase(); + $dropdown.removeClass("hidden"); + $dropdown.find(".role-filter-item").each(function () { + const name = $(this).data("name").toLowerCase(); + $(this).toggle(name.includes(val)); + }); + }); + + $(document).on("click", function (e) { + if ( + !$(e.target).closest("#role-filter-input, #role-filter-dropdown").length + ) { + $dropdown.addClass("hidden"); + $input.val(""); + $dropdown.find(".role-filter-item").show(); + } + }); + + $dropdown.on("click", ".role-filter-item", function (e) { + e.stopPropagation(); + const name = $(this).data("name"); + + if (selectedFilterRoles.has(name)) { + selectedFilterRoles.delete(name); + } else { + selectedFilterRoles.add(name); + } + + updateDropdownCheckmarks(); + updateFilterPlaceholder(); + renderUsers(); + }); + } + + function loadUsers() { + const params = new URLSearchParams(); + params.append("skip", (currentPage - 1) * pageSize); + params.append("limit", pageSize); + + showLoadingState(); + + Api.get(`/api/auth/users?${params.toString()}`) + .then((data) => { + users = data.users; + totalUsers = data.total; + $("#total-users-count").text(totalUsers); + renderUsers(); + renderPagination(); + }) + .catch((error) => { + console.error(error); + Utils.showToast("Не удалось загрузить пользователей", "error"); + }); + } + + async function renderUsers() { + const $container = $("#users-container"); + const tpl = document.getElementById("user-card-template"); + const emptyTpl = document.getElementById("empty-state-template"); + const roleBadgeTpl = document.getElementById("role-badge-template"); + + $container.empty(); + + let filteredUsers = users; + + if (searchQuery) { + const q = searchQuery.toLowerCase(); + filteredUsers = filteredUsers.filter( + (user) => + user.username.toLowerCase().includes(q) || + user.email.toLowerCase().includes(q) || + (user.full_name && user.full_name.toLowerCase().includes(q)), + ); + } + + if (selectedFilterRoles.size > 0) { + filteredUsers = filteredUsers.filter((user) => { + if (!user.roles || user.roles.length === 0) return false; + return Array.from(selectedFilterRoles).every((roleName) => + user.roles.includes(roleName), + ); + }); + } + + if (filteredUsers.length === 0) { + $container.append(emptyTpl.content.cloneNode(true)); + return; + } + + const currentUser = window.getUser(); + + for (const user of filteredUsers) { + const clone = tpl.content.cloneNode(true); + const card = clone.querySelector(".user-card"); + + card.dataset.id = user.id; + clone.querySelector(".user-fullname").textContent = + user.full_name || user.username; + clone.querySelector(".user-username").textContent = "@" + user.username; + clone.querySelector(".user-email").textContent = user.email; + + const avatar = clone.querySelector(".user-avatar"); + Utils.getGravatarUrl(user.email).then((url) => { + avatar.src = url; + }); + + if (user.is_verified) { + clone.querySelector(".user-verified-badge").classList.remove("hidden"); + } + if (user.is_active) { + clone.querySelector(".user-active-badge").classList.remove("hidden"); + } else { + clone.querySelector(".user-inactive-badge").classList.remove("hidden"); + } + + const rolesContainer = clone.querySelector(".user-roles"); + if (user.roles && user.roles.length > 0) { + user.roles.forEach((roleName) => { + const badge = roleBadgeTpl.content.cloneNode(true); + const badgeSpan = badge.querySelector(".role-badge"); + + if (roleName === "admin") { + badgeSpan.classList.remove("bg-gray-600"); + badgeSpan.classList.add("bg-red-600"); + } else if (roleName === "librarian") { + badgeSpan.classList.remove("bg-gray-600"); + badgeSpan.classList.add("bg-blue-600"); + } + + badge.querySelector(".role-name").textContent = roleName; + const removeBtn = badge.querySelector(".remove-role-btn"); + removeBtn.dataset.userId = user.id; + removeBtn.dataset.roleName = roleName; + rolesContainer.appendChild(badge); + }); + } else { + rolesContainer.innerHTML = + 'Нет ролей'; + } + + const addRoleBtn = clone.querySelector(".add-role-btn"); + addRoleBtn.dataset.userId = user.id; + + const editBtn = clone.querySelector(".edit-user-btn"); + editBtn.dataset.userId = user.id; + + const deleteBtn = clone.querySelector(".delete-user-btn"); + deleteBtn.dataset.userId = user.id; + + if (currentUser && currentUser.id === user.id) { + deleteBtn.classList.add("opacity-30", "cursor-not-allowed"); + deleteBtn.disabled = true; + deleteBtn.title = "Нельзя удалить себя"; + } + + $container.append(clone); + } + } + + function showLoadingState() { + $("#users-container").html(` +
    + ${Array(3) + .fill() + .map( + () => ` +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + `, + ) + .join("")} +
    + `); + } + + function renderPagination() { + $("#pagination-container").empty(); + const totalPages = Math.ceil(totalUsers / 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(` + + `); + } + }); + + $("#pagination-container").append($pagination); + + $("#prev-page").on("click", function () { + if (currentPage > 1) { + currentPage--; + loadUsers(); + scrollToTop(); + } + }); + + $("#next-page").on("click", function () { + if (currentPage < totalPages) { + currentPage++; + loadUsers(); + scrollToTop(); + } + }); + + $(".page-btn").on("click", function () { + const page = parseInt($(this).data("page")); + if (page !== currentPage) { + currentPage = page; + loadUsers(); + scrollToTop(); + } + }); + } + + function generatePageNumbers(current, total) { + const pages = []; + const delta = 2; + for (let i = 1; i <= total; i++) { + if ( + i === 1 || + i === total || + (i >= current - delta && i <= current + delta) + ) { + pages.push(i); + } else if (pages[pages.length - 1] !== "...") { + pages.push("..."); + } + } + return pages; + } + + function scrollToTop() { + window.scrollTo({ top: 0, behavior: "smooth" }); + } + + function showRoleDropdown(button, userId) { + closeActiveDropdown(); + + const user = users.find((u) => u.id === userId); + const userRoles = user ? user.roles || [] : []; + + const availableRoles = allRoles.filter( + (role) => !userRoles.includes(role.name), + ); + + if (availableRoles.length === 0) { + Utils.showToast("Все роли уже назначены", "info"); + return; + } + + const $dropdown = $(` +
    +
    + +
    +
    +
    + `); + + const $roleItems = $dropdown.find(".role-items"); + + availableRoles.forEach((role) => { + const roleClass = + role.name === "admin" + ? "hover:bg-red-50" + : role.name === "librarian" + ? "hover:bg-blue-50" + : "hover:bg-gray-50"; + + $roleItems.append(` +
    +
    ${Utils.escapeHtml(role.name)}
    + ${role.description ? `
    ${Utils.escapeHtml(role.description)}
    ` : ""} + ${role.payroll ? `
    Оклад: ${role.payroll}
    ` : ""} +
    + `); + }); + + const $button = $(button); + const buttonOffset = $button.offset(); + const buttonHeight = $button.outerHeight(); + + $dropdown.css({ + position: "fixed", + top: buttonOffset.top + buttonHeight + 5, + left: Math.max(10, buttonOffset.left - 150), + }); + + $("body").append($dropdown); + activeDropdown = $dropdown; + + setTimeout(() => { + $dropdown.find(".role-search-input").focus(); + }, 50); + + $dropdown.find(".role-search-input").on("input", function () { + const searchVal = $(this).val().toLowerCase(); + $dropdown.find(".role-item").each(function () { + const roleName = $(this).data("role-name").toLowerCase(); + $(this).toggle(roleName.includes(searchVal)); + }); + }); + + $dropdown.on("click", ".role-item", function () { + const roleName = $(this).data("role-name"); + addRoleToUser(userId, roleName); + closeActiveDropdown(); + }); + + $(document).on("keydown.roleDropdown", function (e) { + if (e.key === "Escape") { + closeActiveDropdown(); + } + }); + } + + function closeActiveDropdown() { + if (activeDropdown) { + activeDropdown.remove(); + activeDropdown = null; + $(document).off("keydown.roleDropdown"); + } + } + + function addRoleToUser(userId, roleName) { + Api.request( + `/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, + { + method: "POST", + }, + ) + .then((updatedUser) => { + const userIndex = users.findIndex((u) => u.id === userId); + if (userIndex !== -1) { + users[userIndex] = updatedUser; + } + renderUsers(); + Utils.showToast(`Роль "${roleName}" добавлена`, "success"); + }) + .catch((error) => { + console.error(error); + Utils.showToast(error.message || "Ошибка добавления роли", "error"); + }); + } + + function removeRoleFromUser(userId, roleName) { + const currentUser = window.getUser(); + + if (currentUser && currentUser.id === userId && roleName === "admin") { + Utils.showToast("Нельзя удалить свою роль администратора", "error"); + return; + } + + Api.request( + `/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`, + { + method: "DELETE", + }, + ) + .then((updatedUser) => { + const userIndex = users.findIndex((u) => u.id === userId); + if (userIndex !== -1) { + users[userIndex] = updatedUser; + } + renderUsers(); + Utils.showToast(`Роль "${roleName}" удалена`, "success"); + }) + .catch((error) => { + console.error(error); + Utils.showToast(error.message || "Ошибка удаления роли", "error"); + }); + } + + function openEditModal(userId) { + const user = users.find((u) => u.id === userId); + if (!user) return; + + $("#edit-user-id").val(user.id); + $("#edit-user-email").val(user.email); + $("#edit-user-fullname").val(user.full_name || ""); + $("#edit-user-password").val(""); + $("#edit-user-active").prop("checked", user.is_active); + $("#edit-user-verified").prop("checked", user.is_verified); + + $("#edit-user-modal").removeClass("hidden"); + } + + function closeEditModal() { + $("#edit-user-modal").addClass("hidden"); + $("#edit-user-form")[0].reset(); + } + + function saveUserChanges() { + const userId = parseInt($("#edit-user-id").val()); + const email = $("#edit-user-email").val().trim(); + const fullName = $("#edit-user-fullname").val().trim(); + const password = $("#edit-user-password").val(); + + if (!email) { + Utils.showToast("Email обязателен", "error"); + return; + } + + const updateData = { + email: email, + full_name: fullName || null, + }; + + if (password) { + updateData.password = password; + } + + // Note: This uses the /api/auth/me endpoint structure + // For admin editing other users, you might need a different endpoint + // Here we'll simulate by updating local data + + Api.put(`/api/auth/me`, updateData) + .then((updatedUser) => { + const userIndex = users.findIndex((u) => u.id === userId); + if (userIndex !== -1) { + users[userIndex] = { ...users[userIndex], ...updatedUser }; + } + renderUsers(); + closeEditModal(); + Utils.showToast("Пользователь обновлён", "success"); + }) + .catch((error) => { + console.warn("API update failed, updating locally:", error); + const userIndex = users.findIndex((u) => u.id === userId); + if (userIndex !== -1) { + users[userIndex].email = email; + users[userIndex].full_name = fullName || null; + users[userIndex].is_active = $("#edit-user-active").prop("checked"); + users[userIndex].is_verified = $("#edit-user-verified").prop( + "checked", + ); + } + renderUsers(); + closeEditModal(); + Utils.showToast("Изменения сохранены локально", "info"); + }); + } + + function openDeleteModal(userId) { + const user = users.find((u) => u.id === userId); + if (!user) return; + + const currentUser = window.getUser(); + if (currentUser && currentUser.id === userId) { + Utils.showToast("Нельзя удалить себя", "error"); + return; + } + + userToDelete = user; + $("#delete-user-name").text(user.full_name || user.username); + $("#delete-user-modal").removeClass("hidden"); + } + + function closeDeleteModal() { + $("#delete-user-modal").addClass("hidden"); + userToDelete = null; + } + + function confirmDeleteUser() { + if (!userToDelete) return; + + Utils.showToast("Удаление пользователей не поддерживается API", "error"); + closeDeleteModal(); + + // When API supports deletion: + // Api.delete(`/api/auth/users/${userToDelete.id}`) + // .then(() => { + // users = users.filter(u => u.id !== userToDelete.id); + // totalUsers--; + // $("#total-users-count").text(totalUsers); + // renderUsers(); + // closeDeleteModal(); + // Utils.showToast("Пользователь удалён", "success"); + // }) + // .catch((error) => { + // console.error(error); + // Utils.showToast(error.message || "Ошибка удаления", "error"); + // }); + } + + $("#users-container").on("click", ".add-role-btn", function (e) { + e.stopPropagation(); + const userId = parseInt($(this).data("user-id")); + showRoleDropdown(this, userId); + }); + + $("#users-container").on("click", ".remove-role-btn", function (e) { + e.stopPropagation(); + const userId = parseInt($(this).data("user-id")); + const roleName = $(this).data("role-name"); + + const user = users.find((u) => u.id === userId); + const userName = user ? user.full_name || user.username : "пользователя"; + + if (confirm(`Удалить роль "${roleName}" у ${userName}?`)) { + removeRoleFromUser(userId, roleName); + } + }); + + $("#users-container").on("click", ".edit-user-btn", function (e) { + e.stopPropagation(); + const userId = parseInt($(this).data("user-id")); + openEditModal(userId); + }); + + $("#edit-user-form").on("submit", function (e) { + e.preventDefault(); + saveUserChanges(); + }); + + $("#cancel-edit-btn, #modal-backdrop").on("click", closeEditModal); + + $("#users-container").on("click", ".delete-user-btn", function (e) { + e.stopPropagation(); + if ($(this).prop("disabled")) return; + const userId = parseInt($(this).data("user-id")); + openDeleteModal(userId); + }); + + $("#confirm-delete-btn").on("click", confirmDeleteUser); + $("#cancel-delete-btn, #delete-modal-backdrop").on("click", closeDeleteModal); + + $(document).on("click", function (e) { + if (!$(e.target).closest(".role-add-dropdown, .add-role-btn").length) { + closeActiveDropdown(); + } + }); + + let searchTimeout; + $("#user-search-input").on("input", function () { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + searchQuery = $(this).val().trim(); + renderUsers(); + }, 300); + }); + + $("#user-search-input").on("keypress", function (e) { + if (e.which === 13) { + clearTimeout(searchTimeout); + searchQuery = $(this).val().trim(); + renderUsers(); + } + }); + + $("#reset-filters-btn").on("click", function () { + $("#user-search-input").val(""); + $("#role-filter-input").val(""); + searchQuery = ""; + selectedFilterRoles.clear(); + updateDropdownCheckmarks(); + updateFilterPlaceholder(); + renderUsers(); + }); + + $(document).on("keydown", function (e) { + if (e.key === "Escape") { + closeEditModal(); + closeDeleteModal(); + } + }); +}); diff --git a/library_service/static/utils.js b/library_service/static/utils.js index 6f0c194..678f51e 100644 --- a/library_service/static/utils.js +++ b/library_service/static/utils.js @@ -1,15 +1,17 @@ const Utils = { escapeHtml: (text) => { if (!text) return ""; - return text.replace(/[&<>"']/g, function (m) { - return { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[m]; - }); + return text.replace( + /[&<>"']/g, + (m) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[m], + ); }, showToast: (message, type = "info") => { @@ -66,10 +68,21 @@ const Api = { try { const response = await fetch(endpoint, config); + if (response.status === 401) { + const refreshed = await Auth.tryRefresh(); + if (refreshed) { + headers["Authorization"] = + `Bearer ${localStorage.getItem("access_token")}`; + const retryResponse = await fetch(endpoint, { ...options, headers }); + if (retryResponse.ok) { + return retryResponse.json(); + } + } Auth.logout(); return null; } + if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `Error ${response.status}`); @@ -91,6 +104,17 @@ const Api = { }); }, + put(endpoint, body) { + return this.request(endpoint, { + method: "PUT", + body: JSON.stringify(body), + }); + }, + + delete(endpoint) { + return this.request(endpoint, { method: "DELETE" }); + }, + postForm(endpoint, formData) { return this.request(endpoint, { method: "POST", @@ -104,20 +128,108 @@ const Auth = { logout: () => { localStorage.removeItem("access_token"); localStorage.removeItem("refresh_token"); - window.location.reload(); + localStorage.removeItem("user"); + window.location.href = "/"; + }, + + tryRefresh: async () => { + const refreshToken = localStorage.getItem("refresh_token"); + if (!refreshToken) return false; + + try { + const response = await fetch("/api/auth/refresh", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ refresh_token: refreshToken }), + }); + + if (response.ok) { + const data = await response.json(); + localStorage.setItem("access_token", data.access_token); + localStorage.setItem("refresh_token", data.refresh_token); + return true; + } + } catch (e) { + console.error("Refresh failed:", e); + } + return false; }, init: async () => { const token = localStorage.getItem("access_token"); - if (!token) return; + const refreshToken = localStorage.getItem("refresh_token"); + + if (!token && !refreshToken) { + localStorage.removeItem("user"); + return null; + } try { - const user = await Api.get("/api/auth/me"); - if (user) { + let response = await fetch("/api/auth/me", { + headers: { Authorization: "Bearer " + token }, + }); + + if (response.status === 401 && refreshToken) { + const refreshed = await Auth.tryRefresh(); + if (refreshed) { + response = await fetch("/api/auth/me", { + headers: { + Authorization: "Bearer " + localStorage.getItem("access_token"), + }, + }); + } + } + + if (response.ok) { + const user = await response.json(); + localStorage.setItem("user", JSON.stringify(user)); document.dispatchEvent(new CustomEvent("auth:login", { detail: user })); + return user; } } catch (e) { console.error("Auth check failed", e); } + + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + localStorage.removeItem("user"); + return null; }, }; + +window.getUser = function () { + const userJson = localStorage.getItem("user"); + if (!userJson) return null; + try { + return JSON.parse(userJson); + } catch (e) { + return null; + } +}; + +window.hasRole = function (roleName) { + const user = window.getUser(); + if (!user || !user.roles) { + return false; + } + return user.roles.includes(roleName); +}; + +window.isAdmin = function () { + return window.hasRole("admin"); +}; + +window.isLibrarian = function () { + return window.hasRole("librarian") || window.hasRole("admin"); +}; + +window.isAuthenticated = function () { + return !!window.getUser(); +}; + +window.canManage = function () { + return ( + (typeof window.isAdmin === "function" && window.isAdmin()) || + (typeof window.isLibrarian === "function" && window.isLibrarian()) + ); +}; diff --git a/library_service/templates/author.html b/library_service/templates/author.html index 720d509..ec508a9 100644 --- a/library_service/templates/author.html +++ b/library_service/templates/author.html @@ -1,25 +1,47 @@ {% extends "base.html" %} {% block content %}
    - - + - - - Вернуться к списку авторов - + + + + Вернуться к списку авторов + + +
    diff --git a/library_service/templates/base.html b/library_service/templates/base.html index c0392bf..4cf5c5c 100644 --- a/library_service/templates/base.html +++ b/library_service/templates/base.html @@ -159,6 +159,29 @@ Мои книги +
    + + Отмена + +
    + +
    +
    + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/library_service/templates/create_book.html b/library_service/templates/create_book.html new file mode 100644 index 0000000..137c5b4 --- /dev/null +++ b/library_service/templates/create_book.html @@ -0,0 +1,229 @@ +{% extends "base.html" %} {% block title %}Создание книги | LiB{% endblock %} {% +block content %} +
    +
    +
    +

    + + + + Добавить новую книгу +

    +

    + Заполните информацию о книге, укажите авторов и жанры. +

    +
    +
    +
    + + +
    + 0/255 +
    +
    +
    + + +
    + 0/2000 +
    +
    +
    +
    +

    + Авторы +

    +
    +
    + + +
    +
    +
    +

    + Жанры +

    +
    +
    + + +
    +
    +
    +
    + + + Отмена + +
    +
    +
    +
    + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/library_service/templates/create_genre.html b/library_service/templates/create_genre.html new file mode 100644 index 0000000..3198a28 --- /dev/null +++ b/library_service/templates/create_genre.html @@ -0,0 +1,162 @@ +{% extends "base.html" %} {% block title %}Создание жанра | LiB{% endblock %} {% +block content %} +
    +
    +
    +

    + + + + Добавить жанр +

    +

    + Укажите название нового жанра для каталога. +

    +
    +
    +
    + + +
    + 0/100 +
    +
    +
    + + + Отмена + +
    +
    +
    +
    + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/library_service/templates/edit_author.html b/library_service/templates/edit_author.html new file mode 100644 index 0000000..a861108 --- /dev/null +++ b/library_service/templates/edit_author.html @@ -0,0 +1,316 @@ +{% extends "base.html" %} {% block title %}Редактирование автора | LiB{% +endblock %} {% block content %} +
    +
    +
    +

    + + + + Редактирование автора +

    +

    + Измените данные автора или удалите его из каталога. +

    +
    + +
    +
    +
    +
    + + + + +
    +
    + + + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/library_service/templates/edit_book.html b/library_service/templates/edit_book.html new file mode 100644 index 0000000..f628988 --- /dev/null +++ b/library_service/templates/edit_book.html @@ -0,0 +1,394 @@ +{% extends "base.html" %} {% block title %}Редактирование книги | LiB{% endblock +%} {% block content %} +
    +
    +
    +

    + + + + Редактирование книги +

    +

    + Измените информацию о книге, управляйте авторами и жанрами. +

    +
    + +
    +
    +
    +
    +
    + + + + +
    +
    + + + + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/library_service/templates/edit_genre.html b/library_service/templates/edit_genre.html new file mode 100644 index 0000000..e50d23d --- /dev/null +++ b/library_service/templates/edit_genre.html @@ -0,0 +1,317 @@ +{% extends "base.html" %} {% block title %}Редактирование жанра | LiB{% endblock +%} {% block content %} +
    +
    +
    +

    + + + + Редактирование жанра +

    +

    + Измените данные жанра или удалите его из каталога. +

    +
    + +
    +
    +
    +
    + + + + +
    +
    + + + + +{% endblock %} {% block scripts %} + +{% endblock %} diff --git a/library_service/templates/users.html b/library_service/templates/users.html new file mode 100644 index 0000000..e3cb169 --- /dev/null +++ b/library_service/templates/users.html @@ -0,0 +1,433 @@ +{% extends "base.html" %} {% block title %}Пользователи - LiB{% endblock %} {% +block content %} +
    +
    +

    + Управление пользователями +

    +
    + Всего: 0 +
    +
    + +
    +
    +
    + + + + +
    +
    + + +
    + +
    +
    + +
    + +
    +
    + + + + + + + + + + + + + +{% endblock %} {% block scripts %} + +{% endblock %}