Расширение фронтэнда

This commit is contained in:
2025-12-22 01:38:52 +03:00
parent 5096b45243
commit 9d25d2e5de
35 changed files with 4901 additions and 288 deletions
+22 -21
View File
@@ -1,5 +1,5 @@
![logo](./logo.png)
# LibraryAPI
# LiB
Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания.
@@ -54,30 +54,30 @@
**Авторы**
| Метод | Эндпоинты | Описание |
|--------|-----------------------|---------------------------------|
| 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 |
**Связи**
| Метод | Эндпоинты | Описание |
@@ -93,8 +93,9 @@
**Другие**
| Метод | Эндпоинты | Описание |
|--------|-------------|-------------------------------|
| GET | `/api/info` | Получить информацию о сервисе |
|--------|--------------|----------------------------------------------|
| GET | `/api/info` | Получить общую информацию о сервисе |
| GET | `/api/stats` | Получить статистическую информацию о сервисе |
```mermaid
+1 -1
View File
@@ -3,7 +3,7 @@ from typing import Optional
# Конфигурация
USERNAME = "admin"
PASSWORD = "4ai2_pQnrJ1-tDx-XSLTKw"
PASSWORD = "GzwQMe3j2DsPRKpL2DVw6A"
BASE_URL = "http://localhost:8000"
+18 -15
View File
@@ -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]:
+1 -1
View File
@@ -8,7 +8,7 @@ class RoleBase(SQLModel):
"""Базовая модель роли"""
name: str
description: str | None = None
payroll: int
payroll: int = 0
class RoleCreate(RoleBase):
+63 -8
View File
@@ -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),
)
+3 -2
View File
@@ -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()
+44 -2
View File
@@ -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,14 +78,26 @@ 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):
@router.get("/auth", include_in_schema=False)
async def auth(request: Request):
"""Эндпоинт страницы авторизации"""
return templates.TemplateResponse(request, "auth.html")
@@ -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"""
+70 -1
View File
@@ -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");
+5
View File
@@ -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);
+3 -3
View File
@@ -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();
-116
View File
@@ -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();
});
}
});
+148 -26
View File
@@ -4,41 +4,31 @@ $(document).ready(() => {
label: "Доступна",
bgClass: "bg-green-100",
textClass: "text-green-800",
icon: `<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>`,
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>`,
},
borrowed: {
label: "Выдана",
bgClass: "bg-yellow-100",
textClass: "text-yellow-800",
icon: `<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`,
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`,
},
reserved: {
label: "Забронирована",
bgClass: "bg-blue-100",
textClass: "text-blue-800",
icon: `<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"></path>
</svg>`,
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`,
},
restoration: {
label: "На реставрации",
bgClass: "bg-orange-100",
textClass: "text-orange-800",
icon: `<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path>
</svg>`,
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path></svg>`,
},
written_off: {
label: "Списана",
bgClass: "bg-red-100",
textClass: "text-red-800",
icon: `<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
</svg>`,
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path></svg>`,
},
};
@@ -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(`
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors">
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
${Utils.escapeHtml(g.name)}
</a>
`);
@@ -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 href="/author/${a.id}" class="flex items-center bg-gray-50 hover:bg-gray-100 rounded-lg p-2 border transition-colors">
<div class="w-8 h-8 bg-gray-500 text-white rounded-full flex items-center justify-center text-sm font-bold mr-2">
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
${a.name.charAt(0).toUpperCase()}
</div>
<span class="text-gray-900 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
</a>
`);
});
@@ -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 = $(`
<div class="relative inline-block text-left w-full md:w-auto">
<button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">
${config.icon}
<span class="ml-2">${config.label}</span>
<svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20">
<div class="py-1" role="menu">
${Object.entries(STATUS_CONFIG)
.map(
([key, conf]) => `
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${book.status === key ? "bg-gray-50 font-medium" : "text-gray-700"}"
data-status="${key}">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}">
${conf.icon}
</span>
<span>${conf.label}</span>
${book.status === key ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""}
</button>
`,
)
.join("")}
</div>
</div>
</div>
`);
$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(`
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm">
${config.icon}
${config.label}
</span>
`);
}
}
function renderReserveButton() {
const $container = $("#book-actions-container");
$container.html(`
<button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Зарезервировать
</button>
`);
$("#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(`
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Обновление...
`);
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);
}
}
});
+32 -6
View File
@@ -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,12 +87,23 @@ $(document).ready(() => {
const isChecked = genreIdsFromUrl.includes(String(genre.id));
if (isChecked) selectedGenres.set(genre.id, genre.name);
const editButton = window.canManage()
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</a>`
: "";
$list.append(`
<li class="mb-1">
<label class="custom-checkbox flex items-center">
<input type="checkbox" data-id="${genre.id}" data-name="${genre.name}" ${isChecked ? "checked" : ""} />
<div class="flex items-center">
<label class="custom-checkbox flex items-center flex-1">
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
</label>
${editButton}
</div>
</li>
`);
});
@@ -102,6 +113,12 @@ $(document).ready(() => {
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
}
function loadBooks() {
@@ -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);
});
+89
View File
@@ -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";
}
});
});
+346
View File
@@ -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) => {
$("<div>")
.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) => {
$("<div>")
.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) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).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) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-genre mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).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";
}
});
});
+89
View File
@@ -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";
}
});
});
+229
View File
@@ -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(`
<div class="text-sm text-gray-500 text-center py-4">
<svg class="w-8 h-8 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
У автора пока нет книг
</div>
`);
return;
}
books.forEach((book) => {
$container.append(`
<a href="/book/${book.id}" class="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded-lg border transition-colors group">
<div class="flex items-center min-w-0">
<div class="w-8 h-10 bg-gradient-to-br from-gray-400 to-gray-500 rounded flex items-center justify-center flex-shrink-0 mr-3">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
<span class="text-sm font-medium text-gray-900 truncate">${Utils.escapeHtml(book.title)}</span>
</div>
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
`);
});
}
$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");
}
}
});
});
+457
View File
@@ -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) => {
$("<div>")
.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) => {
$("<div>")
.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) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}" data-name="${Utils.escapeHtml(name)}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).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) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-genre mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}" data-name="${Utils.escapeHtml(name)}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).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");
}
}
});
});
+233
View File
@@ -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(`
<div class="text-sm text-gray-500 text-center py-4">
<svg class="w-8 h-8 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
В этом жанре пока нет книг
</div>
`);
return;
}
books.forEach((book) => {
$container.append(`
<a href="/book/${book.id}" class="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded-lg border transition-colors group">
<div class="flex items-center min-w-0">
<div class="w-8 h-10 bg-gradient-to-br from-gray-400 to-gray-500 rounded flex items-center justify-center flex-shrink-0 mr-3">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
<div class="min-w-0">
<span class="text-sm font-medium text-gray-900 truncate block">${Utils.escapeHtml(book.title)}</span>
${book.authors && book.authors.length > 0 ? `<span class="text-xs text-gray-500 truncate block">${Utils.escapeHtml(book.authors.map((a) => a.name).join(", "))}</span>` : ""}
</div>
</div>
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
`);
});
}
$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");
}
}
});
});
+1 -1
View File
@@ -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)",
+1 -4
View File
@@ -110,11 +110,8 @@ $(document).ready(() => {
$btn.prop("disabled", true).text("Меняем...");
try {
await Api.request("/api/auth/me", {
method: "PUT",
body: JSON.stringify({
await Api.put("/api/auth/me", {
password: newPass,
}),
});
Utils.showToast("Пароль успешно изменен", "success");
+2
View File
@@ -14,6 +14,8 @@ h1 {
}
h2,
.book-id,
.book-status,
nav ul li a {
font-family: "Dited", sans-serif;
letter-spacing: 2.5px;
+701
View File
@@ -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) => {
$("<div>")
.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(
`<div>
<div class="font-medium text-sm">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
</div>
<svg class="check-icon w-4 h-4 text-green-600 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>`,
)
.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 =
'<span class="text-gray-400 text-sm italic">Нет ролей</span>';
}
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(`
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="flex items-start gap-4">
<div class="w-14 h-14 bg-gray-200 rounded-full"></div>
<div class="flex-1">
<div class="h-5 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2 mb-3"></div>
<div class="flex gap-2">
<div class="h-6 bg-gray-200 rounded-full w-16"></div>
<div class="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</div>
</div>
</div>
`,
)
.join("")}
</div>
`);
}
function renderPagination() {
$("#pagination-container").empty();
const totalPages = Math.ceil(totalUsers / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`);
}
});
$("#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 = $(`
<div class="role-add-dropdown absolute z-50 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden" style="min-width: 200px;">
<div class="p-2 border-b border-gray-100">
<input type="text" class="role-search-input w-full border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" placeholder="Поиск роли..." autocomplete="off" />
</div>
<div class="role-items max-h-48 overflow-y-auto"></div>
</div>
`);
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(`
<div class="role-item p-2 ${roleClass} cursor-pointer transition-colors border-b border-gray-50 last:border-0" data-role-name="${Utils.escapeHtml(role.name)}">
<div class="font-medium text-sm text-gray-800">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
${role.payroll ? `<div class="text-xs text-green-600">Оклад: ${role.payroll}</div>` : ""}
</div>
`);
});
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();
}
});
});
+120 -8
View File
@@ -1,15 +1,17 @@
const Utils = {
escapeHtml: (text) => {
if (!text) return "";
return text.replace(/[&<>"']/g, function (m) {
return {
return text.replace(
/[&<>"']/g,
(m) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
}[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())
);
};
+23 -1
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% block content %}
<div class="container mx-auto p-4 max-w-4xl">
<div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<a
href="/authors"
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors mb-4"
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors"
>
<svg
class="w-4 h-4 mr-1"
@@ -20,6 +21,27 @@
</svg>
Вернуться к списку авторов
</a>
<a
id="edit-author-btn"
href="#"
class="hidden p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Редактировать автора"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</a>
</div>
<div id="author-loader" class="flex items-start animate-pulse">
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
<div class="flex-1">
+23
View File
@@ -159,6 +159,29 @@
</svg>
Мои книги
</a>
<template
x-if="user.roles && user.roles.includes('admin')"
>
<a
href="/users"
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
Пользователи
</a>
</template>
<div class="border-t border-gray-200">
<button
@click="Auth.logout()"
+50 -19
View File
@@ -1,9 +1,10 @@
{% extends "base.html" %} {% block content %}
<div class="container mx-auto p-4 max-w-4xl">
<div id="book-card" class="bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<a
href="/books"
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors mb-4"
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors"
>
<svg
class="w-4 h-4 mr-1"
@@ -20,6 +21,27 @@
</svg>
Вернуться к списку книг
</a>
<a
id="edit-book-btn"
href="#"
class="hidden p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Редактировать книгу"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</a>
</div>
<div
id="book-loader"
class="flex flex-col md:flex-row items-start animate-pulse"
@@ -41,13 +63,13 @@
class="hidden flex flex-col md:flex-row items-start"
>
<div
class="flex flex-col items-center mb-4 md:mb-0 md:mr-6 flex-shrink-0"
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
>
<div
class="w-32 h-40 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md"
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
>
<svg
class="w-16 h-16 text-white"
class="w-20 h-20 text-white opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -60,37 +82,45 @@
></path>
</svg>
</div>
<div id="book-status-container" class="mt-3">
<span
id="book-status"
class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
></span>
<div
id="book-status-container"
class="relative w-full flex justify-center z-10"
></div>
<div id="book-actions-container" class="mt-4 w-full"></div>
</div>
</div>
<div class="flex-1">
<div class="flex items-start justify-between mb-2">
<div class="flex-1 w-full">
<div
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
>
<h1
id="book-title"
class="text-3xl font-bold text-gray-900"
class="text-3xl font-bold text-gray-900 leading-tight"
></h1>
<span
id="book-id"
class="text-sm text-gray-500 ml-4 px-2 py-1 border border-gray-300 rounded-lg"
class="hidden md:inline-block text-xs font-mono text-gray-400 mt-2 md:mt-0 md:ml-4"
></span>
</div>
<p
id="book-authors-text"
class="text-lg text-gray-600 mb-4"
class="text-lg text-gray-600 font-medium mb-6"
></p>
<div class="prose prose-gray max-w-none mb-6">
<div class="prose prose-gray max-w-none mb-8">
<h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
>
Описание
</h3>
<p
id="book-description"
class="text-gray-700 leading-relaxed"
></p>
</div>
<div id="genres-section" class="mb-6 hidden">
<h3
class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-2"
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
>
Жанры
</h3>
@@ -99,11 +129,12 @@
class="flex flex-wrap gap-2"
></div>
</div>
<div id="authors-section" class="mb-6 hidden">
<h3
class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-2"
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
>
Авторы
Авторы (детально)
</h3>
<div
id="authors-container"
+62 -1
View File
@@ -1,6 +1,68 @@
{% extends "base.html" %} {% block content %}
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
<aside class="w-full md:w-1/4">
<div
id="admin-actions"
class="hidden bg-white px-4 py-2 rounded-lg shadow-md mb-6"
>
<a
href="/author/create"
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Добавить автора
</a>
<a
href="/genre/create"
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Добавить жанр
</a>
<a
href="/book/create"
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Добавить книгу
</a>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-bold mb-4">Поиск</h2>
<div class="relative">
@@ -27,7 +89,6 @@
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-bold mb-4">Авторы</h2>
<div
id="selected-authors-container"
class="flex flex-wrap gap-2 mb-2 min-h-[0px]"
@@ -0,0 +1,162 @@
{% extends "base.html" %} {% block title %}Создание автора | LiB{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
Добавить автора
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Укажите имя нового автора для каталога.
</p>
</div>
<form id="create-author-form" class="space-y-6">
<div>
<label
for="author-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Имя автора <span class="text-red-500">*</span>
</label>
<input
type="text"
id="author-name"
name="name"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Имя или псевдоним автора"
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span id="submit-text">Создать автора</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
href="/authors"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Автор успешно добавлен!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Автор
<span
id="modal-author-name"
class="font-bold text-gray-800"
></span>
сохранён в каталоге.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="modal-link-btn"
href="/authors"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
К списку авторов
</a>
<button
id="modal-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Создать ещё
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_author.js"></script>
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
{% extends "base.html" %} {% block title %}Создание книги | LiB{% endblock %} {%
block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Добавить новую книгу
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Заполните информацию о книге, укажите авторов и жанры.
</p>
</div>
<form id="create-book-form" class="space-y-6">
<div>
<label
for="book-title"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название книги <span class="text-red-500">*</span>
</label>
<input
type="text"
id="book-title"
name="title"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Название книги..."
/>
<div class="flex justify-end mt-1">
<span id="title-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div>
<label
for="book-description"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Описание
</label>
<textarea
id="book-description"
name="description"
rows="5"
maxlength="2000"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none transition"
placeholder="Краткое описание сюжета..."
></textarea>
<div class="flex justify-end mt-1">
<span id="desc-counter" class="text-xs text-gray-400"
>0/2000</span
>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
Авторы
</h2>
<div
id="selected-authors-container"
class="flex flex-wrap gap-2 mb-3 min-h-[0px]"
></div>
<div class="relative">
<input
type="text"
id="author-search-input"
placeholder="Поиск автора..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="author-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
Жанры
</h2>
<div
id="selected-genres-container"
class="flex flex-wrap gap-2 mb-3 min-h-[0px]"
></div>
<div class="relative">
<input
type="text"
id="genre-search-input"
placeholder="Поиск жанра..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="genre-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span id="submit-text">Создать книгу</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
href="/books"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Книга успешно добавлена!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Книга
<span
id="modal-book-title"
class="font-bold text-gray-800"
></span>
сохранена в каталоге.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="modal-link-btn"
href="#"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Перейти к книге
</a>
<button
id="modal-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Создать ещё
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_book.js"></script>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %} {% block title %}Создание жанра | LiB{% endblock %} {%
block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
Добавить жанр
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Укажите название нового жанра для каталога.
</p>
</div>
<form id="create-genre-form" class="space-y-6">
<div>
<label
for="genre-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название жанра <span class="text-red-500">*</span>
</label>
<input
type="text"
id="genre-name"
name="name"
required
maxlength="100"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Например: Научная фантастика"
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/100</span
>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span id="submit-text">Создать жанр</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
href="/books"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Жанр успешно добавлен!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Жанр
<span
id="modal-genre-name"
class="font-bold text-gray-800"
></span>
сохранён в каталоге.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="modal-link-btn"
href="/books"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
К списку книг
</a>
<button
id="modal-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Создать ещё
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_genre.js"></script>
{% endblock %}
+316
View File
@@ -0,0 +1,316 @@
{% extends "base.html" %} {% block title %}Редактирование автора | LiB{%
endblock %} {% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
<span>Редактирование автора</span>
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Измените данные автора или удалите его из каталога.
</p>
</div>
<div id="loader" class="animate-pulse space-y-4">
<div class="h-12 bg-gray-200 rounded w-full"></div>
<div class="h-24 bg-gray-200 rounded w-full"></div>
</div>
<form id="edit-author-form" class="hidden space-y-6">
<div>
<label
for="author-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Имя автора <span class="text-red-500">*</span>
</label>
<input
type="text"
id="author-name"
name="name"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Имя автора..."
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Книги автора</span>
<span
id="books-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="author-books-container"
class="space-y-2 max-h-64 overflow-y-auto"
>
<div class="text-sm text-gray-500 text-center py-4">
Загрузка...
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span id="submit-text">Сохранить изменения</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
id="cancel-btn"
href="/authors"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
Опасная зона
</h3>
<p class="text-sm text-gray-600 mb-4">
Удаление автора необратимо. Связи с книгами будут удалены, но
сами книги сохранятся.
</p>
<button
id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
Удалить автора
</button>
</div>
</div>
</div>
<div
id="delete-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Удалить автора?
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить автора
<span
id="modal-author-name"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
</p>
<p
id="modal-books-warning"
class="hidden text-sm text-orange-600 mt-2"
>
У автора есть связанные книги. Связи будут удалены.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<button
id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
>
<span>Удалить</span>
<svg
id="delete-spinner"
class="hidden animate-spin ml-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<button
id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Отмена
</button>
</div>
</div>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Изменения сохранены!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Автор
<span
id="success-author-name"
class="font-bold text-gray-800"
></span>
успешно обновлён.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="success-link-btn"
href="/authors"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
К списку авторов
</a>
<button
id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Продолжить
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_author.js"></script>
{% endblock %}
+394
View File
@@ -0,0 +1,394 @@
{% extends "base.html" %} {% block title %}Редактирование книги | LiB{% endblock
%} {% block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
<span>Редактирование книги</span>
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Измените информацию о книге, управляйте авторами и жанрами.
</p>
</div>
<div id="loader" class="animate-pulse space-y-4">
<div class="h-12 bg-gray-200 rounded w-full"></div>
<div class="h-32 bg-gray-200 rounded w-full"></div>
<div class="h-12 bg-gray-200 rounded w-1/3"></div>
</div>
<form id="edit-book-form" class="hidden space-y-6">
<div>
<label
for="book-title"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название книги <span class="text-red-500">*</span>
</label>
<input
type="text"
id="book-title"
name="title"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Название книги..."
/>
<div class="flex justify-end mt-1">
<span id="title-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div>
<label
for="book-description"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Описание
</label>
<textarea
id="book-description"
name="description"
rows="5"
maxlength="2000"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none transition"
placeholder="Краткое описание сюжета..."
></textarea>
<div class="flex justify-end mt-1">
<span id="desc-counter" class="text-xs text-gray-400"
>0/2000</span
>
</div>
</div>
<div>
<label
for="book-status"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Статус
</label>
<select
id="book-status"
name="status"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition bg-white"
>
<option value="active">Доступна</option>
<option value="borrowed">Выдана</option>
<option value="reserved">Забронирована</option>
<option value="restoration">На реставрации</option>
<option value="written_off">Списана</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Авторы</span>
<span
id="authors-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="current-authors-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div class="relative">
<input
type="text"
id="author-search-input"
placeholder="Добавить автора..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="author-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Жанры</span>
<span
id="genres-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="current-genres-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div class="relative">
<input
type="text"
id="genre-search-input"
placeholder="Добавить жанр..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="genre-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span id="submit-text">Сохранить изменения</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
id="cancel-btn"
href="#"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
Опасная зона
</h3>
<p class="text-sm text-gray-600 mb-4">
Удаление книги необратимо. Все связи с авторами и жанрами будут
удалены.
</p>
<button
id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
Удалить книгу
</button>
</div>
</div>
</div>
<div
id="delete-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Удалить книгу?
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить книгу
<span
id="modal-book-title"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<button
id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
>
<span>Удалить</span>
<svg
id="delete-spinner"
class="hidden animate-spin ml-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<button
id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Отмена
</button>
</div>
</div>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Изменения сохранены!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Книга
<span
id="success-book-title"
class="font-bold text-gray-800"
></span>
успешно обновлена.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="success-link-btn"
href="#"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Перейти к книге
</a>
<button
id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Продолжить
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_book.js"></script>
{% endblock %}
+317
View File
@@ -0,0 +1,317 @@
{% extends "base.html" %} {% block title %}Редактирование жанра | LiB{% endblock
%} {% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
<span>Редактирование жанра</span>
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Измените данные жанра или удалите его из каталога.
</p>
</div>
<div id="loader" class="animate-pulse space-y-4">
<div class="h-12 bg-gray-200 rounded w-full"></div>
<div class="h-24 bg-gray-200 rounded w-full"></div>
</div>
<form id="edit-genre-form" class="hidden space-y-6">
<div>
<label
for="genre-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название жанра <span class="text-red-500">*</span>
</label>
<input
type="text"
id="genre-name"
name="name"
required
maxlength="100"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Название жанра..."
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/100</span
>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Книги в жанре</span>
<span
id="books-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="genre-books-container"
class="space-y-2 max-h-64 overflow-y-auto"
>
<div class="text-sm text-gray-500 text-center py-4">
Загрузка...
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span id="submit-text">Сохранить изменения</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
id="cancel-btn"
href="/"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
Опасная зона
</h3>
<p class="text-sm text-gray-600 mb-4">
Удаление жанра необратимо. Связи с книгами будут удалены, но
сами книги сохранятся.
</p>
<button
id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
Удалить жанр
</button>
</div>
</div>
</div>
<div
id="delete-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Удалить жанр?
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить жанр
<span
id="modal-genre-name"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
</p>
<p
id="modal-books-warning"
class="hidden text-sm text-orange-600 mt-2"
>
В этом жанре есть книги. Связи будут удалены.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<button
id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
>
<span>Удалить</span>
<svg
id="delete-spinner"
class="hidden animate-spin ml-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<button
id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Отмена
</button>
</div>
</div>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Изменения сохранены!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Жанр
<span
id="success-genre-name"
class="font-bold text-gray-800"
></span>
успешно обновлён.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="success-link-btn"
href="/"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
На главную
</a>
<button
id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Продолжить
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_genre.js"></script>
{% endblock %}
+433
View File
@@ -0,0 +1,433 @@
{% extends "base.html" %} {% block title %}Пользователи - LiB{% endblock %} {%
block content %}
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">
Управление пользователями
</h1>
<div class="text-sm text-gray-500">
Всего: <span id="total-users-count">0</span>
</div>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1 relative">
<input
type="text"
id="user-search-input"
placeholder="Поиск по имени, username или email..."
class="w-full border rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
/>
<svg
class="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<div class="relative">
<input
type="text"
id="role-filter-input"
placeholder="Фильтр по роли..."
class="w-full md:w-56 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="role-filter-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
<button
id="reset-filters-btn"
class="px-4 py-2 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
Сбросить
</button>
</div>
</div>
<div id="users-container" class="space-y-4"></div>
<div id="pagination-container"></div>
</div>
<template id="user-card-template">
<div
class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 user-card"
>
<div class="flex items-start gap-4">
<img
class="user-avatar w-14 h-14 rounded-full border-2 border-gray-200 object-cover bg-gray-100"
src=""
alt=""
/>
<div class="flex-1 min-w-0">
<div
class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3
class="user-fullname text-lg font-bold text-gray-900 truncate"
></h3>
<span
class="user-verified-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
<svg
class="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
Подтвержден
</span>
</div>
<p class="user-username text-sm text-gray-500"></p>
<p
class="user-email text-sm text-gray-600 truncate"
></p>
</div>
<div class="flex items-center gap-2">
<span
class="user-active-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
Активен
</span>
<span
class="user-inactive-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"
>
Неактивен
</span>
<button
class="edit-user-btn p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Редактировать"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</button>
<button
class="delete-user-btn p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Удалить"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</button>
</div>
</div>
<div class="mt-3">
<div class="flex items-center flex-wrap gap-2">
<span class="text-sm font-medium text-gray-700"
>Роли:</span
>
<div class="user-roles flex flex-wrap gap-1"></div>
<div class="relative inline-block">
<button
class="add-role-btn p-1 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors"
title="Добавить роль"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template id="role-badge-template">
<span
class="role-badge inline-flex items-center bg-gray-600 text-white text-xs font-medium px-2.5 py-1 rounded-full"
>
<span class="role-name"></span>
<button
type="button"
class="remove-role-btn ml-1.5 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-red-500 rounded-full w-4 h-4 transition-colors"
title="Удалить роль"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
</template>
<template id="empty-state-template">
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Пользователи не найдены
</h3>
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
</div>
</template>
<template id="access-denied-template">
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg
class="mx-auto h-12 w-12 text-red-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3>
<p class="text-gray-500">У вас нет прав для просмотра этой страницы</p>
<a
href="/"
class="inline-block mt-4 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
>
На главную
</a>
</div>
</template>
<div id="edit-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0"
>
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
id="modal-backdrop"
></div>
<div
class="relative inline-block bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full"
>
<form id="edit-user-form">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">
Редактирование пользователя
</h3>
<input type="hidden" id="edit-user-id" />
<div class="space-y-4">
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>Email</label
>
<input
type="email"
id="edit-user-email"
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>Полное имя</label
>
<input
type="text"
id="edit-user-fullname"
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>Новый пароль (оставьте пустым, чтобы не
менять)</label
>
<input
type="password"
id="edit-user-password"
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
placeholder="••••••••"
/>
</div>
<div class="flex items-center gap-4">
<label
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
id="edit-user-active"
class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span class="text-sm text-gray-700"
>Активен</span
>
</label>
<label
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
id="edit-user-verified"
class="w-4 h-4 text-green-600 rounded focus:ring-green-500"
/>
<span class="text-sm text-gray-700"
>Подтверждён</span
>
</label>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2"
>
<button
type="submit"
class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-white font-medium hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
>
Сохранить
</button>
<button
type="button"
id="cancel-edit-btn"
class="mt-3 sm:mt-0 w-full sm:w-auto inline-flex justify-center rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-gray-700 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
>
Отмена
</button>
</div>
</form>
</div>
</div>
</div>
<div id="delete-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0"
>
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
id="delete-modal-backdrop"
></div>
<div
class="relative inline-block bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg font-medium text-gray-900">
Удаление пользователя
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить пользователя
<strong id="delete-user-name"></strong>? Это
действие необратимо.
</p>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2"
>
<button
type="button"
id="confirm-delete-btn"
class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-red-600 text-white font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition"
>
Удалить
</button>
<button
type="button"
id="cancel-delete-btn"
class="mt-3 sm:mt-0 w-full sm:w-auto inline-flex justify-center rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-gray-700 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
>
Отмена
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/users.js"></script>
{% endblock %}