mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Добавление зарплаты для ролей, добавление статусов книг, обновление фронтэнда
This commit is contained in:
@@ -3,7 +3,7 @@ from typing import Optional
|
|||||||
|
|
||||||
# Конфигурация
|
# Конфигурация
|
||||||
USERNAME = "admin"
|
USERNAME = "admin"
|
||||||
PASSWORD = "TzUlDpUCHutFa-oGCd1cBw"
|
PASSWORD = "4ai2_pQnrJ1-tDx-XSLTKw"
|
||||||
BASE_URL = "http://localhost:8000"
|
BASE_URL = "http://localhost:8000"
|
||||||
|
|
||||||
|
|
||||||
@@ -339,7 +339,7 @@ def main():
|
|||||||
for author_name in book_info["authors"]:
|
for author_name in book_info["authors"]:
|
||||||
if author_name in authors:
|
if author_name in authors:
|
||||||
api.link_author_book(authors[author_name], book_id)
|
api.link_author_book(authors[author_name], book_id)
|
||||||
|
|
||||||
for genre_name in book_info["genres"]:
|
for genre_name in book_info["genres"]:
|
||||||
if genre_name in genres:
|
if genre_name in genres:
|
||||||
api.link_genre_book(genres[genre_name], book_id)
|
api.link_genre_book(genres[genre_name], book_id)
|
||||||
|
|||||||
@@ -146,9 +146,9 @@ RequireModerator = Annotated[User, Depends(require_role("moderator"))]
|
|||||||
def seed_roles(session: Session) -> dict[str, Role]:
|
def seed_roles(session: Session) -> dict[str, Role]:
|
||||||
"""Создаёт роли по умолчанию, если их нет."""
|
"""Создаёт роли по умолчанию, если их нет."""
|
||||||
default_roles = [
|
default_roles = [
|
||||||
{"name": "admin", "description": "Администратор системы"},
|
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
|
||||||
{"name": "librarian", "description": "Библиотекарь"},
|
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
|
||||||
{"name": "member", "description": "Посетитель библиотеки"},
|
{"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
|
||||||
]
|
]
|
||||||
|
|
||||||
roles = {}
|
roles = {}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from .author import AuthorRead
|
|||||||
from .genre import GenreRead
|
from .genre import GenreRead
|
||||||
from .book import BookRead
|
from .book import BookRead
|
||||||
from .loan import LoanRead
|
from .loan import LoanRead
|
||||||
|
from ..enums import BookStatus
|
||||||
|
|
||||||
class AuthorWithBooks(SQLModel):
|
class AuthorWithBooks(SQLModel):
|
||||||
"""Модель автора с книгами"""
|
"""Модель автора с книгами"""
|
||||||
@@ -35,6 +35,7 @@ class BookWithGenres(SQLModel):
|
|||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
|
status: BookStatus | None = None
|
||||||
genres: List[GenreRead] = Field(default_factory=list)
|
genres: List[GenreRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,6 +44,7 @@ class BookWithAuthorsAndGenres(SQLModel):
|
|||||||
id: int
|
id: int
|
||||||
title: str
|
title: str
|
||||||
description: str
|
description: str
|
||||||
|
status: BookStatus | None = None
|
||||||
authors: List[AuthorRead] = Field(default_factory=list)
|
authors: List[AuthorRead] = Field(default_factory=list)
|
||||||
genres: List[GenreRead] = Field(default_factory=list)
|
genres: List[GenreRead] = Field(default_factory=list)
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ class BookFilteredList(SQLModel):
|
|||||||
class LoanWithBook(LoanRead):
|
class LoanWithBook(LoanRead):
|
||||||
"""Модель выдачи, включающая данные о книге"""
|
"""Модель выдачи, включающая данные о книге"""
|
||||||
book: BookRead
|
book: BookRead
|
||||||
|
|
||||||
class BookStatusUpdate(SQLModel):
|
class BookStatusUpdate(SQLModel):
|
||||||
"""Модель для ручного изменения статуса библиотекарем"""
|
"""Модель для ручного изменения статуса библиотекарем"""
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class RoleBase(SQLModel):
|
|||||||
"""Базовая модель роли"""
|
"""Базовая модель роли"""
|
||||||
name: str
|
name: str
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
payroll: int
|
||||||
|
|
||||||
|
|
||||||
class RoleCreate(RoleBase):
|
class RoleCreate(RoleBase):
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/books", tags=["books"])
|
|||||||
)
|
)
|
||||||
def filter_books(
|
def filter_books(
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
|
q: str | None = Query(None, max_length=50, description="Поиск"),
|
||||||
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
||||||
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
||||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||||
|
|||||||
+63
-281
@@ -1,287 +1,69 @@
|
|||||||
$(function () {
|
$(function () {
|
||||||
const $loginTab = $("#login-tab");
|
$("#login-form").on("submit", async function (event) {
|
||||||
const $registerTab = $("#register-tab");
|
event.preventDefault();
|
||||||
const $loginForm = $("#login-form");
|
const $submitBtn = $("#login-submit");
|
||||||
const $registerForm = $("#register-form");
|
const username = $("#login-username").val();
|
||||||
|
const password = $("#login-password").val();
|
||||||
const $guestLink = $("#guest-link");
|
|
||||||
const $userBtn = $("#user-btn");
|
$submitBtn.prop("disabled", true).text("Вход...");
|
||||||
const $userDropdown = $("#user-dropdown");
|
|
||||||
const $userArrow = $("#user-arrow");
|
try {
|
||||||
const $userAvatar = $("#user-avatar");
|
const formData = new URLSearchParams();
|
||||||
const $dropdownName = $("#dropdown-name");
|
formData.append("username", username);
|
||||||
const $dropdownUsername = $("#dropdown-username");
|
formData.append("password", password);
|
||||||
const $dropdownEmail = $("#dropdown-email");
|
|
||||||
const $logoutBtn = $("#logout-btn");
|
const data = await Api.postForm("/api/auth/token", formData);
|
||||||
const $menuContainer = $("#user-menu-container");
|
|
||||||
|
localStorage.setItem("access_token", data.access_token);
|
||||||
function switchToLogin() {
|
if (data.refresh_token)
|
||||||
$loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
localStorage.setItem("refresh_token", data.refresh_token);
|
||||||
$registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
|
||||||
$loginForm.removeClass("hidden"); $registerForm.addClass("hidden");
|
|
||||||
history.replaceState(null, "", "/auth#login");
|
|
||||||
}
|
|
||||||
|
|
||||||
function switchToRegister() {
|
|
||||||
$registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
|
||||||
$loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
|
||||||
$registerForm.removeClass("hidden"); $loginForm.addClass("hidden");
|
|
||||||
history.replaceState(null, "", "/auth#register");
|
|
||||||
}
|
|
||||||
|
|
||||||
$loginTab.on("click", switchToLogin);
|
|
||||||
$registerTab.on("click", switchToRegister);
|
|
||||||
|
|
||||||
$("body").on("click", ".toggle-password", function () {
|
|
||||||
const $btn = $(this);
|
|
||||||
const $input = $btn.siblings("input");
|
|
||||||
const $eyeOpen = $btn.find(".eye-open");
|
|
||||||
const $eyeClosed = $btn.find(".eye-closed");
|
|
||||||
|
|
||||||
if ($input.attr("type") === "password") {
|
|
||||||
$input.attr("type", "text");
|
|
||||||
$eyeOpen.addClass("hidden");
|
|
||||||
$eyeClosed.removeClass("hidden");
|
|
||||||
} else {
|
|
||||||
$input.attr("type", "password");
|
|
||||||
$eyeOpen.removeClass("hidden");
|
|
||||||
$eyeClosed.addClass("hidden");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#register-password").on("input", function () {
|
|
||||||
const password = $(this).val();
|
|
||||||
let strength = 0;
|
|
||||||
if (password.length >= 8) strength++;
|
|
||||||
if (password.length >= 12) strength++;
|
|
||||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
|
||||||
if (/\d/.test(password)) strength++;
|
|
||||||
if (/[^a-zA-Z0-9]/.test(password)) strength++;
|
|
||||||
|
|
||||||
const levels = [
|
|
||||||
{ width: "0%", color: "", text: "" },
|
|
||||||
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
|
|
||||||
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
|
|
||||||
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
|
|
||||||
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
|
|
||||||
{ width: "100%", color: "bg-green-500", text: "Отличный" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const level = levels[strength];
|
|
||||||
const $bar = $("#password-strength-bar");
|
|
||||||
|
|
||||||
$bar.css("width", level.width);
|
|
||||||
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
|
|
||||||
$("#password-strength-text").text(level.text);
|
|
||||||
|
|
||||||
checkPasswordMatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
function checkPasswordMatch() {
|
|
||||||
const password = $("#register-password").val();
|
|
||||||
const confirm = $("#register-password-confirm").val();
|
|
||||||
const $error = $("#password-match-error");
|
|
||||||
|
|
||||||
if (confirm && password !== confirm) {
|
|
||||||
$error.removeClass("hidden");
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
$error.addClass("hidden");
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#register-password-confirm").on("input", checkPasswordMatch);
|
|
||||||
|
|
||||||
$loginForm.on("submit", async function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const $errorDiv = $("#login-error");
|
|
||||||
const $submitBtn = $("#login-submit");
|
|
||||||
const username = $("#login-username").val();
|
|
||||||
const password = $("#login-password").val();
|
|
||||||
|
|
||||||
$errorDiv.addClass("hidden");
|
|
||||||
$submitBtn.prop("disabled", true).text("Вход...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new URLSearchParams();
|
|
||||||
formData.append("username", username);
|
|
||||||
formData.append("password", password);
|
|
||||||
|
|
||||||
const response = await fetch("/api/auth/token", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: formData.toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
localStorage.setItem("access_token", data.access_token);
|
|
||||||
if (data.refresh_token) {
|
|
||||||
localStorage.setItem("refresh_token", data.refresh_token);
|
|
||||||
}
|
|
||||||
window.location.href = "/";
|
|
||||||
} else {
|
|
||||||
$errorDiv.text(data.detail || "Неверное имя пользователя или пароль");
|
|
||||||
$errorDiv.removeClass("hidden");
|
|
||||||
$submitBtn.prop("disabled", false).text("Войти");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Login error:", error);
|
|
||||||
$errorDiv.text("Ошибка соединения с сервером");
|
|
||||||
$errorDiv.removeClass("hidden");
|
|
||||||
$submitBtn.prop("disabled", false).text("Войти");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$registerForm.on("submit", async function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const $errorDiv = $("#register-error");
|
|
||||||
const $successDiv = $("#register-success");
|
|
||||||
const $submitBtn = $("#register-submit");
|
|
||||||
|
|
||||||
if (!checkPasswordMatch()) {
|
|
||||||
$errorDiv.text("Пароли не совпадают").removeClass("hidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userData = {
|
|
||||||
username: $("#register-username").val(),
|
|
||||||
email: $("#register-email").val(),
|
|
||||||
full_name: $("#register-fullname").val() || null,
|
|
||||||
password: $("#register-password").val(),
|
|
||||||
};
|
|
||||||
|
|
||||||
$errorDiv.addClass("hidden");
|
|
||||||
$successDiv.addClass("hidden");
|
|
||||||
$submitBtn.prop("disabled", true).text("Регистрация...");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/auth/register", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(userData),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
$successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden");
|
|
||||||
setTimeout(() => {
|
|
||||||
$("#login-username").val(userData.username);
|
|
||||||
switchToLogin();
|
|
||||||
}, 2000);
|
|
||||||
} else {
|
|
||||||
let errorMessage = data.detail;
|
|
||||||
if (Array.isArray(data.detail)) {
|
|
||||||
errorMessage = data.detail.map((err) => err.msg).join(". ");
|
|
||||||
}
|
|
||||||
$errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Register error:", error);
|
|
||||||
$errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
|
|
||||||
} finally {
|
|
||||||
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let isDropdownOpen = false;
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
isDropdownOpen = true;
|
|
||||||
$userDropdown.removeClass("hidden");
|
|
||||||
$userArrow.addClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpen = false;
|
|
||||||
$userDropdown.addClass("hidden");
|
|
||||||
$userArrow.removeClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userBtn.on("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("click", function (e) {
|
|
||||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("keydown", function (e) {
|
|
||||||
if (e.key === "Escape" && isDropdownOpen) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$logoutBtn.on("click", function () {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
window.location.href = "/";
|
window.location.href = "/";
|
||||||
});
|
} catch (error) {
|
||||||
|
Utils.showToast(error.message || "Ошибка входа", "error");
|
||||||
function showGuest() {
|
} finally {
|
||||||
$guestLink.removeClass("hidden");
|
$submitBtn.prop("disabled", false).text("Войти");
|
||||||
$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) {
|
$("#register-form").on("submit", async function (event) {
|
||||||
if (!email) return;
|
event.preventDefault();
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
const $submitBtn = $("#register-submit");
|
||||||
const emailHash = sha256(cleanEmail);
|
const pass = $("#register-password").val();
|
||||||
|
const confirm = $("#register-password-confirm").val();
|
||||||
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
if (pass !== confirm) {
|
||||||
const avatarImg = document.getElementById('user-avatar');
|
Utils.showToast("Пароли не совпадают", "error");
|
||||||
if (avatarImg) { avatarImg.src = avatarUrl; }
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
if (window.location.hash === "#register") { switchToRegister(); }
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
showGuest();
|
|
||||||
} else {
|
|
||||||
fetch("/api/auth/me", {
|
|
||||||
headers: { Authorization: "Bearer " + token },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) return response.json();
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
})
|
|
||||||
.then((user) => {
|
|
||||||
showUser(user);
|
|
||||||
updateUserAvatar(user.email);
|
|
||||||
|
|
||||||
document.getElementById('user-btn').classList.remove('hidden');
|
|
||||||
document.getElementById('guest-link').classList.add('hidden');
|
|
||||||
if (window.location.pathname === "/auth") { window.location.href = "/"; }
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
showGuest();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const userData = {
|
||||||
|
username: $("#register-username").val(),
|
||||||
|
email: $("#register-email").val(),
|
||||||
|
full_name: $("#register-fullname").val() || null,
|
||||||
|
password: pass,
|
||||||
|
};
|
||||||
|
|
||||||
|
$submitBtn.prop("disabled", true).text("Регистрация...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Api.post("/api/auth/register", userData);
|
||||||
|
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
|
||||||
|
setTimeout(() => window.location.reload(), 1500);
|
||||||
|
} catch (error) {
|
||||||
|
let msg = error.message;
|
||||||
|
if (Array.isArray(error.detail)) {
|
||||||
|
msg = error.detail.map((e) => e.msg).join(". ");
|
||||||
|
}
|
||||||
|
Utils.showToast(msg || "Ошибка регистрации", "error");
|
||||||
|
} finally {
|
||||||
|
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("body").on("click", ".toggle-password", function () {
|
||||||
|
const $input = $(this).siblings("input");
|
||||||
|
const type = $input.attr("type") === "password" ? "text" : "password";
|
||||||
|
$input.attr("type", type);
|
||||||
|
$(this).find("svg").toggleClass("hidden");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,305 +1,61 @@
|
|||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
const pathParts = window.location.pathname.split("/");
|
const pathParts = window.location.pathname.split("/");
|
||||||
const authorId = pathParts[pathParts.length - 1];
|
const authorId = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
if (!authorId || isNaN(authorId)) {
|
if (!authorId || isNaN(authorId)) {
|
||||||
showErrorState("Некорректный ID автора");
|
Utils.showToast("Некорректный ID автора", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Api.get(`/api/authors/${authorId}`)
|
||||||
|
.then((author) => {
|
||||||
|
document.title = `LiB - ${author.name}`;
|
||||||
|
renderAuthor(author);
|
||||||
|
renderBooks(author.books);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
Utils.showToast("Автор не найден", "error");
|
||||||
|
$("#author-loader").html('<p class="text-red-500">Ошибка загрузки</p>');
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderAuthor(author) {
|
||||||
|
$("#author-name").text(author.name);
|
||||||
|
$("#author-id").text(`ID: ${author.id}`);
|
||||||
|
$("#author-avatar").text(author.name.charAt(0).toUpperCase());
|
||||||
|
|
||||||
|
const count = author.books ? author.books.length : 0;
|
||||||
|
$("#author-books-count").text(`${count} книг в библиотеке`);
|
||||||
|
|
||||||
|
$("#author-loader").addClass("hidden");
|
||||||
|
$("#author-content").removeClass("hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBooks(books) {
|
||||||
|
const $container = $("#books-container");
|
||||||
|
const tpl = document.getElementById("book-item-template");
|
||||||
|
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
if (!books || books.length === 0) {
|
||||||
|
$container.html('<p class="text-gray-500 italic">Книг пока нет</p>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadAuthor(authorId);
|
books.forEach((book) => {
|
||||||
|
const clone = tpl.content.cloneNode(true);
|
||||||
function loadAuthor(id) {
|
const card = clone.querySelector(".book-card");
|
||||||
showLoadingState();
|
|
||||||
|
card.dataset.id = book.id;
|
||||||
fetch(`/api/authors/${id}`)
|
clone.querySelector(".book-title").textContent = book.title;
|
||||||
.then((response) => {
|
clone.querySelector(".book-desc").textContent =
|
||||||
if (!response.ok) {
|
book.description || "Описание отсутствует";
|
||||||
if (response.status === 404) {
|
|
||||||
throw new Error("Автор не найден");
|
$container.append(clone);
|
||||||
}
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then((author) => {
|
|
||||||
renderAuthor(author);
|
|
||||||
renderBooks(author.books);
|
|
||||||
document.title = `LiB - ${author.name}`;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error loading author:", error);
|
|
||||||
showErrorState(error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAuthor(author) {
|
|
||||||
const $card = $("#author-card");
|
|
||||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
|
||||||
const booksCount = author.books ? author.books.length : 0;
|
|
||||||
const booksWord = getWordForm(booksCount, ["книга", "книги", "книг"]);
|
|
||||||
|
|
||||||
$card.html(`
|
|
||||||
<div class="flex items-start">
|
|
||||||
<!-- Аватар -->
|
|
||||||
<div class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0">
|
|
||||||
${firstLetter}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Информация -->
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(author.name)}</h1>
|
|
||||||
<span class="text-sm text-gray-500">ID: ${author.id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center text-gray-600 mb-4">
|
|
||||||
<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 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>
|
|
||||||
<span>${booksCount} ${booksWord} в библиотеке</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Кнопка назад -->
|
|
||||||
<a href="/authors" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
|
||||||
<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="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
Вернуться к списку авторов
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBooks(books) {
|
|
||||||
const $container = $("#books-container");
|
|
||||||
$container.empty();
|
|
||||||
|
|
||||||
if (!books || books.length === 0) {
|
|
||||||
$container.html(`
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<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 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>
|
|
||||||
<p class="text-gray-500">У этого автора пока нет книг в библиотеке</p>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $grid = $('<div class="space-y-4"></div>');
|
|
||||||
|
|
||||||
books.forEach((book) => {
|
|
||||||
const $bookCard = $(`
|
|
||||||
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors mb-2">
|
|
||||||
${escapeHtml(book.title)}
|
|
||||||
</h3>
|
|
||||||
<p class="text-gray-600 text-sm line-clamp-3">
|
|
||||||
${escapeHtml(book.description || "Описание отсутствует")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<svg class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0" 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"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$grid.append($bookCard);
|
|
||||||
});
|
|
||||||
|
|
||||||
$container.append($grid);
|
|
||||||
|
|
||||||
$container.off("click", ".book-card").on("click", ".book-card", function () {
|
|
||||||
const bookId = $(this).data("id");
|
|
||||||
window.location.href = `/book/${bookId}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoadingState() {
|
|
||||||
const $authorCard = $("#author-card");
|
|
||||||
const $booksContainer = $("#books-container");
|
|
||||||
|
|
||||||
$authorCard.html(`
|
|
||||||
<div class="flex items-start animate-pulse">
|
|
||||||
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$booksContainer.html(`
|
|
||||||
<div class="space-y-4">
|
|
||||||
${Array(3)
|
|
||||||
.fill()
|
|
||||||
.map(
|
|
||||||
() => `
|
|
||||||
<div class="border border-gray-200 rounded-lg p-4 animate-pulse">
|
|
||||||
<div class="h-5 bg-gray-200 rounded w-1/2 mb-2"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-full mb-1"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showErrorState(message) {
|
|
||||||
const $authorCard = $("#author-card");
|
|
||||||
const $booksSection = $("#books-section");
|
|
||||||
|
|
||||||
$booksSection.hide();
|
|
||||||
|
|
||||||
$authorCard.html(`
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<svg class="mx-auto h-16 w-16 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 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"/>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
|
||||||
<p class="text-gray-500 mb-6">Не удалось загрузить информацию об авторе</p>
|
|
||||||
<div class="flex justify-center gap-4">
|
|
||||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
|
||||||
Попробовать снова
|
|
||||||
</button>
|
|
||||||
<a href="/authors" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
|
||||||
К списку авторов
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$("#retry-btn").on("click", function () {
|
|
||||||
$booksSection.show();
|
|
||||||
loadAuthor(authorId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWordForm(number, forms) {
|
|
||||||
const cases = [2, 0, 1, 1, 1, 2];
|
|
||||||
const index =
|
|
||||||
number % 100 > 4 && number % 100 < 20
|
|
||||||
? 2
|
|
||||||
: cases[Math.min(number % 10, 5)];
|
|
||||||
return forms[index];
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return "";
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $guestLink = $("#guest-link");
|
|
||||||
const $userBtn = $("#user-btn");
|
|
||||||
const $userDropdown = $("#user-dropdown");
|
|
||||||
const $userArrow = $("#user-arrow");
|
|
||||||
const $userAvatar = $("#user-avatar");
|
|
||||||
const $dropdownName = $("#dropdown-name");
|
|
||||||
const $dropdownUsername = $("#dropdown-username");
|
|
||||||
const $dropdownEmail = $("#dropdown-email");
|
|
||||||
const $logoutBtn = $("#logout-btn");
|
|
||||||
|
|
||||||
let isDropdownOpen = false;
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
isDropdownOpen = true;
|
|
||||||
$userDropdown.removeClass("hidden");
|
|
||||||
$userArrow.addClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpen = false;
|
|
||||||
$userDropdown.addClass("hidden");
|
|
||||||
$userArrow.removeClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userBtn.on("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
$(document).on("click", function (e) {
|
|
||||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
$("#books-container").on("click", ".book-card", function () {
|
||||||
closeDropdown();
|
window.location.href = `/book/${$(this).data("id")}`;
|
||||||
}
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on("keydown", function (e) {
|
|
||||||
if (e.key === "Escape" && isDropdownOpen) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$logoutBtn.on("click", function () {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
function showGuest() {
|
|
||||||
$guestLink.removeClass("hidden");
|
|
||||||
$userBtn.addClass("hidden").removeClass("flex");
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUser(user) {
|
|
||||||
$guestLink.addClass("hidden");
|
|
||||||
$userBtn.removeClass("hidden").addClass("flex");
|
|
||||||
|
|
||||||
const displayName = user.full_name || user.username;
|
|
||||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
$userAvatar.text(firstLetter);
|
|
||||||
$dropdownName.text(displayName);
|
|
||||||
$dropdownUsername.text("@" + user.username);
|
|
||||||
$dropdownEmail.text(user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUserAvatar(email) {
|
|
||||||
if (!email) return;
|
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
|
||||||
const emailHash = sha256(cleanEmail);
|
|
||||||
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
|
||||||
const avatarImg = document.getElementById("user-avatar");
|
|
||||||
if (avatarImg) {
|
|
||||||
avatarImg.src = avatarUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
showGuest();
|
|
||||||
} else {
|
|
||||||
fetch("/api/auth/me", {
|
|
||||||
headers: { Authorization: "Bearer " + token },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) return response.json();
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
})
|
|
||||||
.then((user) => {
|
|
||||||
showUser(user);
|
|
||||||
updateUserAvatar(user.email);
|
|
||||||
|
|
||||||
document.getElementById("user-btn").classList.remove("hidden");
|
|
||||||
document.getElementById("guest-link").classList.add("hidden");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
showGuest();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
+174
-408
@@ -1,417 +1,183 @@
|
|||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
let allAuthors = [];
|
let allAuthors = [];
|
||||||
let filteredAuthors = [];
|
let filteredAuthors = [];
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let pageSize = 12;
|
let pageSize = 12;
|
||||||
let currentSort = "name_asc";
|
let currentSort = "name_asc";
|
||||||
|
|
||||||
loadAuthors();
|
loadAuthors();
|
||||||
|
|
||||||
function loadAuthors() {
|
function loadAuthors() {
|
||||||
showLoadingState();
|
showLoadingState();
|
||||||
|
|
||||||
fetch("/api/authors")
|
Api.get("/api/authors")
|
||||||
.then((response) => {
|
.then((data) => {
|
||||||
if (!response.ok) {
|
allAuthors = data.authors;
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
applyFiltersAndSort();
|
||||||
}
|
})
|
||||||
return response.json();
|
.catch((error) => {
|
||||||
})
|
console.error(error);
|
||||||
.then((data) => {
|
Utils.showToast("Не удалось загрузить авторов", "error");
|
||||||
allAuthors = data.authors;
|
$("#authors-container").empty();
|
||||||
applyFiltersAndSort();
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error loading authors:", error);
|
|
||||||
showErrorState();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFiltersAndSort() {
|
|
||||||
const searchQuery = $("#author-search-input").val().trim().toLowerCase();
|
|
||||||
|
|
||||||
filteredAuthors = allAuthors.filter((author) =>
|
|
||||||
author.name.toLowerCase().includes(searchQuery)
|
|
||||||
);
|
|
||||||
|
|
||||||
filteredAuthors.sort((a, b) => {
|
|
||||||
const nameA = a.name.toLowerCase();
|
|
||||||
const nameB = b.name.toLowerCase();
|
|
||||||
|
|
||||||
if (currentSort === "name_asc") {
|
|
||||||
return nameA.localeCompare(nameB, "ru");
|
|
||||||
} else {
|
|
||||||
return nameB.localeCompare(nameA, "ru");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
updateResultsCounter();
|
|
||||||
|
function applyFiltersAndSort() {
|
||||||
renderAuthors();
|
const searchQuery = $("#author-search-input").val().trim().toLowerCase();
|
||||||
renderPagination();
|
|
||||||
|
filteredAuthors = allAuthors.filter((author) =>
|
||||||
|
author.name.toLowerCase().includes(searchQuery),
|
||||||
|
);
|
||||||
|
|
||||||
|
filteredAuthors.sort((a, b) => {
|
||||||
|
const nameA = a.name.toLowerCase();
|
||||||
|
const nameB = b.name.toLowerCase();
|
||||||
|
return currentSort === "name_asc"
|
||||||
|
? nameA.localeCompare(nameB, "ru")
|
||||||
|
: nameB.localeCompare(nameA, "ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = filteredAuthors.length;
|
||||||
|
$("#results-counter").text(
|
||||||
|
total === 0 ? "Авторы не найдены" : `Найдено: ${total}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
renderAuthors();
|
||||||
|
renderPagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAuthors() {
|
||||||
|
const $container = $("#authors-container");
|
||||||
|
const tpl = document.getElementById("author-card-template");
|
||||||
|
const emptyTpl = document.getElementById("empty-state-template");
|
||||||
|
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
if (filteredAuthors.length === 0) {
|
||||||
|
$container.append(emptyTpl.content.cloneNode(true));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateResultsCounter() {
|
const startIndex = (currentPage - 1) * pageSize;
|
||||||
const $counter = $("#results-counter");
|
const pageAuthors = filteredAuthors.slice(
|
||||||
const total = filteredAuthors.length;
|
startIndex,
|
||||||
|
startIndex + pageSize,
|
||||||
if (total === 0) {
|
);
|
||||||
$counter.text("Авторы не найдены");
|
|
||||||
} else {
|
pageAuthors.forEach((author) => {
|
||||||
const wordForm = getWordForm(total, ["автор", "автора", "авторов"]);
|
const clone = tpl.content.cloneNode(true);
|
||||||
$counter.text(`Найдено: ${total} ${wordForm}`);
|
const card = clone.querySelector(".author-card");
|
||||||
|
|
||||||
|
card.dataset.id = author.id;
|
||||||
|
clone.querySelector(".author-name").textContent = author.name;
|
||||||
|
clone.querySelector(".author-id").textContent = `ID: ${author.id}`;
|
||||||
|
clone.querySelector(".author-avatar").textContent = author.name
|
||||||
|
.charAt(0)
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
$container.append(clone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPagination() {
|
||||||
|
$("#pagination-container").empty();
|
||||||
|
const totalPages = Math.ceil(filteredAuthors.length / pageSize);
|
||||||
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
|
const $pagination = $(`
|
||||||
|
<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 rounded-lg hover:bg-gray-50" ${currentPage === 1 ? "disabled" : ""}>←</button>
|
||||||
|
<div id="page-numbers" class="flex gap-1"></div>
|
||||||
|
<button id="next-page" class="px-3 py-2 bg-white border rounded-lg hover:bg-gray-50" ${currentPage === totalPages ? "disabled" : ""}>→</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const $pageNumbers = $pagination.find("#page-numbers");
|
||||||
|
const pages = [];
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (
|
||||||
|
i === 1 ||
|
||||||
|
i === totalPages ||
|
||||||
|
(i >= currentPage - 2 && i <= currentPage + 2)
|
||||||
|
) {
|
||||||
|
pages.push(i);
|
||||||
|
} else if (pages[pages.length - 1] !== "...") {
|
||||||
|
pages.push("...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWordForm(number, forms) {
|
pages.forEach((page) => {
|
||||||
const cases = [2, 0, 1, 1, 1, 2];
|
if (page === "...") {
|
||||||
const index =
|
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
|
||||||
number % 100 > 4 && number % 100 < 20
|
} else {
|
||||||
? 2
|
const isActive = page === currentPage;
|
||||||
: cases[Math.min(number % 10, 5)];
|
$pageNumbers.append(`
|
||||||
return forms[index];
|
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border hover:bg-gray-50"}" data-page="${page}">${page}</button>
|
||||||
}
|
`);
|
||||||
|
|
||||||
function renderAuthors() {
|
|
||||||
const $container = $("#authors-container");
|
|
||||||
$container.empty();
|
|
||||||
|
|
||||||
if (filteredAuthors.length === 0) {
|
|
||||||
$container.html(`
|
|
||||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Авторы не найдены</h3>
|
|
||||||
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
const startIndex = (currentPage - 1) * pageSize;
|
|
||||||
const endIndex = startIndex + pageSize;
|
$("#pagination-container").append($pagination);
|
||||||
const pageAuthors = filteredAuthors.slice(startIndex, endIndex);
|
|
||||||
|
$("#prev-page").on("click", () => {
|
||||||
const $grid = $('<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>');
|
if (currentPage > 1) {
|
||||||
|
currentPage--;
|
||||||
pageAuthors.forEach((author) => {
|
renderAuthors();
|
||||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
renderPagination();
|
||||||
|
scrollToTop();
|
||||||
const $authorCard = $(`
|
|
||||||
<div class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer author-card" data-id="${author.id}">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="w-12 h-12 bg-gray-500 text-white rounded-full flex items-center justify-center text-xl font-bold mr-4">
|
|
||||||
${firstLetter}
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
|
|
||||||
${escapeHtml(author.name)}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-500">ID: ${author.id}</p>
|
|
||||||
</div>
|
|
||||||
<svg class="w-5 h-5 text-gray-400" 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"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$grid.append($authorCard);
|
|
||||||
});
|
|
||||||
|
|
||||||
$container.append($grid);
|
|
||||||
|
|
||||||
$container.off("click", ".author-card").on("click", ".author-card", function () {
|
|
||||||
const authorId = $(this).data("id");
|
|
||||||
window.location.href = `/author/${authorId}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPagination() {
|
|
||||||
const $paginationContainer = $("#pagination-container");
|
|
||||||
$paginationContainer.empty();
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredAuthors.length / pageSize);
|
|
||||||
|
|
||||||
if (totalPages <= 1) return;
|
|
||||||
|
|
||||||
const $pagination = $(`
|
|
||||||
<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" ${currentPage === 1 ? "disabled" : ""}>
|
|
||||||
<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 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
</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" ${currentPage === totalPages ? "disabled" : ""}>
|
|
||||||
<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="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</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">...</span>`);
|
|
||||||
} else {
|
|
||||||
const isActive = page === currentPage;
|
|
||||||
$pageNumbers.append(`
|
|
||||||
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
|
|
||||||
${page}
|
|
||||||
</button>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$paginationContainer.append($pagination);
|
|
||||||
|
|
||||||
$paginationContainer.find("#prev-page").on("click", function () {
|
|
||||||
if (currentPage > 1) {
|
|
||||||
currentPage--;
|
|
||||||
renderAuthors();
|
|
||||||
renderPagination();
|
|
||||||
scrollToTop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$paginationContainer.find("#next-page").on("click", function () {
|
|
||||||
if (currentPage < totalPages) {
|
|
||||||
currentPage++;
|
|
||||||
renderAuthors();
|
|
||||||
renderPagination();
|
|
||||||
scrollToTop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$paginationContainer.find(".page-btn").on("click", function () {
|
|
||||||
const page = parseInt($(this).data("page"));
|
|
||||||
if (page !== currentPage) {
|
|
||||||
currentPage = page;
|
|
||||||
renderAuthors();
|
|
||||||
renderPagination();
|
|
||||||
scrollToTop();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function generatePageNumbers(current, total) {
|
|
||||||
const pages = [];
|
|
||||||
const delta = 2;
|
|
||||||
|
|
||||||
for (let i = 1; i <= total; i++) {
|
|
||||||
if (
|
|
||||||
i === 1 ||
|
|
||||||
i === total ||
|
|
||||||
(i >= current - delta && i <= current + delta)
|
|
||||||
) {
|
|
||||||
pages.push(i);
|
|
||||||
} else if (pages[pages.length - 1] !== "...") {
|
|
||||||
pages.push("...");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return pages;
|
$("#next-page").on("click", () => {
|
||||||
}
|
if (currentPage < totalPages) {
|
||||||
|
currentPage++;
|
||||||
function scrollToTop() {
|
renderAuthors();
|
||||||
$("html, body").animate({ scrollTop: 0 }, 300);
|
renderPagination();
|
||||||
}
|
scrollToTop();
|
||||||
|
}
|
||||||
function showLoadingState() {
|
});
|
||||||
const $container = $("#authors-container");
|
$(".page-btn").on("click", function () {
|
||||||
$container.html(`
|
currentPage = parseInt($(this).data("page"));
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
renderAuthors();
|
||||||
${Array(6)
|
renderPagination();
|
||||||
.fill()
|
scrollToTop();
|
||||||
.map(
|
});
|
||||||
() => `
|
}
|
||||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
|
||||||
<div class="flex items-center">
|
function showLoadingState() {
|
||||||
<div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
|
$("#authors-container").html(`
|
||||||
<div class="flex-1">
|
${Array(6)
|
||||||
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
|
.fill()
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
.map(
|
||||||
|
() => `
|
||||||
|
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse flex items-center">
|
||||||
|
<div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`,
|
||||||
</div>
|
)
|
||||||
`
|
.join("")}
|
||||||
)
|
`);
|
||||||
.join("")}
|
}
|
||||||
</div>
|
|
||||||
`);
|
function scrollToTop() {
|
||||||
}
|
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||||
|
}
|
||||||
function showErrorState() {
|
|
||||||
const $container = $("#authors-container");
|
$("#author-search-input").on("input", function () {
|
||||||
$container.html(`
|
currentPage = 1;
|
||||||
<div class="bg-red-50 p-8 rounded-lg shadow-md text-center">
|
applyFiltersAndSort();
|
||||||
<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 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"/>
|
|
||||||
</svg>
|
$('input[name="sort"]').on("change", function () {
|
||||||
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
|
currentSort = $(this).val();
|
||||||
<p class="text-red-700 mb-4">Не удалось загрузить список авторов</p>
|
currentPage = 1;
|
||||||
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
applyFiltersAndSort();
|
||||||
Попробовать снова
|
});
|
||||||
</button>
|
|
||||||
</div>
|
$("#authors-container").on("click", ".author-card", function () {
|
||||||
`);
|
window.location.href = `/author/${$(this).data("id")}`;
|
||||||
|
});
|
||||||
$("#retry-btn").on("click", loadAuthors);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return "";
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeFilters() {
|
|
||||||
const $authorSearch = $("#author-search-input");
|
|
||||||
const $resetBtn = $("#reset-filters-btn");
|
|
||||||
const $sortRadios = $('input[name="sort"]');
|
|
||||||
|
|
||||||
let searchTimeout;
|
|
||||||
$authorSearch.on("input", function () {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage = 1;
|
|
||||||
applyFiltersAndSort();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
|
|
||||||
$authorSearch.on("keypress", function (e) {
|
|
||||||
if (e.which === 13) {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
currentPage = 1;
|
|
||||||
applyFiltersAndSort();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$sortRadios.on("change", function () {
|
|
||||||
currentSort = $(this).val();
|
|
||||||
currentPage = 1;
|
|
||||||
applyFiltersAndSort();
|
|
||||||
});
|
|
||||||
|
|
||||||
$resetBtn.on("click", function () {
|
|
||||||
$authorSearch.val("");
|
|
||||||
$('input[name="sort"][value="name_asc"]').prop("checked", true);
|
|
||||||
currentSort = "name_asc";
|
|
||||||
currentPage = 1;
|
|
||||||
applyFiltersAndSort();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeFilters();
|
|
||||||
|
|
||||||
const $guestLink = $("#guest-link");
|
|
||||||
const $userBtn = $("#user-btn");
|
|
||||||
const $userDropdown = $("#user-dropdown");
|
|
||||||
const $userArrow = $("#user-arrow");
|
|
||||||
const $userAvatar = $("#user-avatar");
|
|
||||||
const $dropdownName = $("#dropdown-name");
|
|
||||||
const $dropdownUsername = $("#dropdown-username");
|
|
||||||
const $dropdownEmail = $("#dropdown-email");
|
|
||||||
const $logoutBtn = $("#logout-btn");
|
|
||||||
|
|
||||||
let isDropdownOpen = false;
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
isDropdownOpen = true;
|
|
||||||
$userDropdown.removeClass("hidden");
|
|
||||||
$userArrow.addClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpen = false;
|
|
||||||
$userDropdown.addClass("hidden");
|
|
||||||
$userArrow.removeClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userBtn.on("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("click", function (e) {
|
|
||||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("keydown", function (e) {
|
|
||||||
if (e.key === "Escape" && isDropdownOpen) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$logoutBtn.on("click", function () {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
function showGuest() {
|
|
||||||
$guestLink.removeClass("hidden");
|
|
||||||
$userBtn.addClass("hidden").removeClass("flex");
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUser(user) {
|
|
||||||
$guestLink.addClass("hidden");
|
|
||||||
$userBtn.removeClass("hidden").addClass("flex");
|
|
||||||
|
|
||||||
const displayName = user.full_name || user.username;
|
|
||||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
$userAvatar.text(firstLetter);
|
|
||||||
$dropdownName.text(displayName);
|
|
||||||
$dropdownUsername.text("@" + user.username);
|
|
||||||
$dropdownEmail.text(user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUserAvatar(email) {
|
|
||||||
if (!email) return;
|
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
|
||||||
const emailHash = sha256(cleanEmail);
|
|
||||||
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
|
||||||
const avatarImg = document.getElementById("user-avatar");
|
|
||||||
if (avatarImg) {
|
|
||||||
avatarImg.src = avatarUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
showGuest();
|
|
||||||
} else {
|
|
||||||
fetch("/api/auth/me", {
|
|
||||||
headers: { Authorization: "Bearer " + token },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) return response.json();
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
})
|
|
||||||
.then((user) => {
|
|
||||||
showUser(user);
|
|
||||||
updateUserAvatar(user.email);
|
|
||||||
|
|
||||||
document.getElementById("user-btn").classList.remove("hidden");
|
|
||||||
document.getElementById("guest-link").classList.add("hidden");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
showGuest();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8" ?>
|
|
||||||
<svg
|
|
||||||
width="800px"
|
|
||||||
height="800px"
|
|
||||||
viewBox="0 0 15 15"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
|
|
||||||
fill="#000000"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,116 @@
|
|||||||
|
$(function () {
|
||||||
|
const $guestLink = $("#guest-link");
|
||||||
|
const $userBtn = $("#user-btn");
|
||||||
|
const $userDropdown = $("#user-dropdown");
|
||||||
|
const $userArrow = $("#user-arrow");
|
||||||
|
const $userAvatar = $("#user-avatar");
|
||||||
|
const $dropdownName = $("#dropdown-name");
|
||||||
|
const $dropdownUsername = $("#dropdown-username");
|
||||||
|
const $dropdownEmail = $("#dropdown-email");
|
||||||
|
const $logoutBtn = $("#logout-btn");
|
||||||
|
const $menuContainer = $("#user-menu-area");
|
||||||
|
|
||||||
|
let isDropdownOpen = false;
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
isDropdownOpen = true;
|
||||||
|
$userDropdown.removeClass("hidden");
|
||||||
|
$userArrow.addClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDropdown() {
|
||||||
|
isDropdownOpen = false;
|
||||||
|
$userDropdown.addClass("hidden");
|
||||||
|
$userArrow.removeClass("rotate-180");
|
||||||
|
}
|
||||||
|
|
||||||
|
$userBtn.on("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
|
if (isDropdownOpen && !$(e.target).closest("#user-menu-area").length) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && isDropdownOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$logoutBtn.on("click", function () {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showGuest() {
|
||||||
|
$guestLink.removeClass("hidden");
|
||||||
|
$userBtn.addClass("hidden").removeClass("flex");
|
||||||
|
closeDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUser(user) {
|
||||||
|
$guestLink.addClass("hidden");
|
||||||
|
$userBtn.removeClass("hidden").addClass("flex");
|
||||||
|
|
||||||
|
const displayName = user.full_name || user.username;
|
||||||
|
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
$userAvatar.text(firstLetter);
|
||||||
|
|
||||||
|
$dropdownName.text(displayName);
|
||||||
|
$dropdownUsername.text("@" + user.username);
|
||||||
|
$dropdownEmail.text(user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUserAvatar(email) {
|
||||||
|
if (!email) return;
|
||||||
|
if (typeof sha256 === "undefined") {
|
||||||
|
console.warn("sha256 library not loaded yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanEmail = email.trim().toLowerCase();
|
||||||
|
const emailHash = sha256(cleanEmail);
|
||||||
|
|
||||||
|
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||||
|
const avatarImg = document.getElementById("user-avatar");
|
||||||
|
if (avatarImg) {
|
||||||
|
avatarImg.src = avatarUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
showGuest();
|
||||||
|
} else {
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
headers: { Authorization: "Bearer " + token },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json();
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
})
|
||||||
|
.then((user) => {
|
||||||
|
showUser(user);
|
||||||
|
updateUserAvatar(user.email);
|
||||||
|
|
||||||
|
document.getElementById("user-btn").classList.remove("hidden");
|
||||||
|
document.getElementById("guest-link").classList.add("hidden");
|
||||||
|
|
||||||
|
if (window.location.pathname === "/auth") {
|
||||||
|
window.location.href = "/";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
showGuest();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
+110
-308
@@ -1,321 +1,123 @@
|
|||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
const pathParts = window.location.pathname.split("/");
|
const STATUS_CONFIG = {
|
||||||
const bookId = pathParts[pathParts.length - 1];
|
active: {
|
||||||
|
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>`,
|
||||||
|
},
|
||||||
|
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>`,
|
||||||
|
},
|
||||||
|
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>`,
|
||||||
|
},
|
||||||
|
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>`,
|
||||||
|
},
|
||||||
|
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>`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
if (!bookId || isNaN(bookId)) {
|
function getStatusConfig(status) {
|
||||||
showErrorState("Некорректный ID книги");
|
return (
|
||||||
return;
|
STATUS_CONFIG[status] || {
|
||||||
}
|
label: status || "Неизвестно",
|
||||||
|
bgClass: "bg-gray-100",
|
||||||
|
textClass: "text-gray-800",
|
||||||
|
icon: "",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
loadBook(bookId);
|
const pathParts = window.location.pathname.split("/");
|
||||||
|
const bookId = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
function loadBook(id) {
|
if (!bookId || isNaN(bookId)) {
|
||||||
showLoadingState();
|
Utils.showToast("Некорректный ID книги", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetch(`/api/books/${id}`)
|
Api.get(`/api/books/${bookId}`)
|
||||||
.then((response) => {
|
.then((book) => {
|
||||||
if (!response.ok) {
|
document.title = `LiB - ${book.title}`;
|
||||||
if (response.status === 404) {
|
renderBook(book);
|
||||||
throw new Error("Книга не найдена");
|
})
|
||||||
}
|
.catch((error) => {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
console.error(error);
|
||||||
}
|
Utils.showToast("Книга не найдена", "error");
|
||||||
return response.json();
|
$("#book-loader").html(
|
||||||
})
|
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
|
||||||
.then((book) => {
|
);
|
||||||
renderBook(book);
|
|
||||||
renderAuthors(book.authors);
|
|
||||||
renderGenres(book.genres);
|
|
||||||
document.title = `LiB - ${book.title}`;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error loading book:", error);
|
|
||||||
showErrorState(error.message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBook(book) {
|
|
||||||
const $card = $("#book-card");
|
|
||||||
const authorsText = book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
|
||||||
|
|
||||||
$card.html(`
|
|
||||||
<div class="flex flex-col md:flex-row items-start">
|
|
||||||
<!-- Иконка книги -->
|
|
||||||
<div class="w-32 h-40 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center mb-4 md:mb-0 md:mr-6 flex-shrink-0 shadow-md">
|
|
||||||
<svg class="w-16 h-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" 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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Информация о книге -->
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex items-start justify-between mb-2">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(book.title)}</h1>
|
|
||||||
<span class="text-sm text-gray-500 ml-4">ID: ${book.id}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-lg text-gray-600 mb-4">
|
|
||||||
${escapeHtml(authorsText)}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="prose prose-gray max-w-none mb-6">
|
|
||||||
<p class="text-gray-700 leading-relaxed">
|
|
||||||
${escapeHtml(book.description || "Описание отсутствует")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Кнопка назад -->
|
|
||||||
<a href="/books" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
|
||||||
<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="M15 19l-7-7 7-7"/>
|
|
||||||
</svg>
|
|
||||||
Вернуться к списку книг
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAuthors(authors) {
|
|
||||||
const $container = $("#authors-container");
|
|
||||||
const $section = $("#authors-section");
|
|
||||||
$container.empty();
|
|
||||||
|
|
||||||
if (!authors || authors.length === 0) {
|
|
||||||
$section.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $grid = $('<div class="flex flex-wrap gap-3"></div>');
|
|
||||||
|
|
||||||
authors.forEach((author) => {
|
|
||||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
const $authorCard = $(`
|
|
||||||
<a href="/author/${author.id}" class="flex items-center bg-gray-50 hover:bg-gray-100 rounded-lg p-3 transition-colors duration-200 border border-gray-200">
|
|
||||||
<div class="w-10 h-10 bg-gray-500 text-white rounded-full flex items-center justify-center text-lg font-bold mr-3">
|
|
||||||
${firstLetter}
|
|
||||||
</div>
|
|
||||||
<span class="text-gray-900 font-medium">${escapeHtml(author.name)}</span>
|
|
||||||
<svg class="w-4 h-4 text-gray-400 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"/>
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$grid.append($authorCard);
|
|
||||||
});
|
|
||||||
|
|
||||||
$container.append($grid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderGenres(genres) {
|
|
||||||
const $container = $("#genres-container");
|
|
||||||
const $section = $("#genres-section");
|
|
||||||
$container.empty();
|
|
||||||
|
|
||||||
if (!genres || genres.length === 0) {
|
|
||||||
$section.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $grid = $('<div class="flex flex-wrap gap-2"></div>');
|
|
||||||
|
|
||||||
genres.forEach((genre) => {
|
|
||||||
const $genreTag = $(`
|
|
||||||
<a href="/books?genre_id=${genre.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-full transition-colors duration-200">
|
|
||||||
<svg class="w-4 h-4 mr-2 text-gray-500" 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>
|
|
||||||
${escapeHtml(genre.name)}
|
|
||||||
</a>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$grid.append($genreTag);
|
|
||||||
});
|
|
||||||
|
|
||||||
$container.append($grid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoadingState() {
|
|
||||||
const $bookCard = $("#book-card");
|
|
||||||
const $authorsContainer = $("#authors-container");
|
|
||||||
const $genresContainer = $("#genres-container");
|
|
||||||
|
|
||||||
$bookCard.html(`
|
|
||||||
<div class="flex flex-col md:flex-row items-start animate-pulse">
|
|
||||||
<div class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"></div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
|
|
||||||
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
|
|
||||||
<div class="space-y-2 mb-6">
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
|
||||||
</div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$authorsContainer.html(`
|
|
||||||
<div class="flex gap-3 animate-pulse">
|
|
||||||
<div class="flex items-center bg-gray-100 rounded-lg p-3">
|
|
||||||
<div class="w-10 h-10 bg-gray-200 rounded-full mr-3"></div>
|
|
||||||
<div class="h-5 bg-gray-200 rounded w-24"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$genresContainer.html(`
|
|
||||||
<div class="flex gap-2 animate-pulse">
|
|
||||||
<div class="h-10 bg-gray-200 rounded-full w-24"></div>
|
|
||||||
<div class="h-10 bg-gray-200 rounded-full w-32"></div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showErrorState(message) {
|
|
||||||
const $bookCard = $("#book-card");
|
|
||||||
const $authorsSection = $("#authors-section");
|
|
||||||
const $genresSection = $("#genres-section");
|
|
||||||
|
|
||||||
$authorsSection.hide();
|
|
||||||
$genresSection.hide();
|
|
||||||
|
|
||||||
$bookCard.html(`
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<svg class="mx-auto h-16 w-16 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 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"/>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
|
||||||
<p class="text-gray-500 mb-6">Не удалось загрузить информацию о книге</p>
|
|
||||||
<div class="flex justify-center gap-4">
|
|
||||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
|
||||||
Попробовать снова
|
|
||||||
</button>
|
|
||||||
<a href="/books" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
|
||||||
К списку книг
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$("#retry-btn").on("click", function () {
|
|
||||||
$authorsSection.show();
|
|
||||||
$genresSection.show();
|
|
||||||
loadBook(bookId);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return "";
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $guestLink = $("#guest-link");
|
|
||||||
const $userBtn = $("#user-btn");
|
|
||||||
const $userDropdown = $("#user-dropdown");
|
|
||||||
const $userArrow = $("#user-arrow");
|
|
||||||
const $userAvatar = $("#user-avatar");
|
|
||||||
const $dropdownName = $("#dropdown-name");
|
|
||||||
const $dropdownUsername = $("#dropdown-username");
|
|
||||||
const $dropdownEmail = $("#dropdown-email");
|
|
||||||
const $logoutBtn = $("#logout-btn");
|
|
||||||
|
|
||||||
let isDropdownOpen = false;
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
isDropdownOpen = true;
|
|
||||||
$userDropdown.removeClass("hidden");
|
|
||||||
$userArrow.addClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpen = false;
|
|
||||||
$userDropdown.addClass("hidden");
|
|
||||||
$userArrow.removeClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userBtn.on("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on("click", function (e) {
|
function renderBook(book) {
|
||||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
$("#book-title").text(book.title);
|
||||||
closeDropdown();
|
$("#book-id").text(`ID: ${book.id}`);
|
||||||
}
|
$("#book-authors-text").text(
|
||||||
});
|
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
|
||||||
|
);
|
||||||
|
$("#book-description").text(book.description || "Описание отсутствует");
|
||||||
|
|
||||||
$(document).on("keydown", function (e) {
|
const statusConfig = getStatusConfig(book.status);
|
||||||
if (e.key === "Escape" && isDropdownOpen) {
|
$("#book-status")
|
||||||
closeDropdown();
|
.html(statusConfig.icon + statusConfig.label)
|
||||||
}
|
.removeClass()
|
||||||
});
|
.addClass(
|
||||||
|
`inline-flex items-center px-3 py-1 rounded-full text-sm font-medium ${statusConfig.bgClass} ${statusConfig.textClass}`,
|
||||||
|
);
|
||||||
|
|
||||||
$logoutBtn.on("click", function () {
|
if (book.genres && book.genres.length > 0) {
|
||||||
localStorage.removeItem("access_token");
|
$("#genres-section").removeClass("hidden");
|
||||||
localStorage.removeItem("refresh_token");
|
const $genres = $("#genres-container");
|
||||||
window.location.reload();
|
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">
|
||||||
function showGuest() {
|
${Utils.escapeHtml(g.name)}
|
||||||
$guestLink.removeClass("hidden");
|
</a>
|
||||||
$userBtn.addClass("hidden").removeClass("flex");
|
`);
|
||||||
closeDropdown();
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUser(user) {
|
if (book.authors && book.authors.length > 0) {
|
||||||
$guestLink.addClass("hidden");
|
$("#authors-section").removeClass("hidden");
|
||||||
$userBtn.removeClass("hidden").addClass("flex");
|
const $authors = $("#authors-container");
|
||||||
|
book.authors.forEach((a) => {
|
||||||
const displayName = user.full_name || user.username;
|
$authors.append(`
|
||||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
<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">
|
||||||
$userAvatar.text(firstLetter);
|
${a.name.charAt(0).toUpperCase()}
|
||||||
$dropdownName.text(displayName);
|
</div>
|
||||||
$dropdownUsername.text("@" + user.username);
|
<span class="text-gray-900 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
|
||||||
$dropdownEmail.text(user.email);
|
</a>
|
||||||
|
`);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateUserAvatar(email) {
|
$("#book-loader").addClass("hidden");
|
||||||
if (!email) return;
|
$("#book-content").removeClass("hidden");
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
}
|
||||||
const emailHash = sha256(cleanEmail);
|
});
|
||||||
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
|
||||||
const avatarImg = document.getElementById("user-avatar");
|
|
||||||
if (avatarImg) {
|
|
||||||
avatarImg.src = avatarUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
showGuest();
|
|
||||||
} else {
|
|
||||||
fetch("/api/auth/me", {
|
|
||||||
headers: { Authorization: "Bearer " + token },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) return response.json();
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
})
|
|
||||||
.then((user) => {
|
|
||||||
showUser(user);
|
|
||||||
updateUserAvatar(user.email);
|
|
||||||
|
|
||||||
document.getElementById("user-btn").classList.remove("hidden");
|
|
||||||
document.getElementById("guest-link").classList.add("hidden");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
showGuest();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
+237
-424
@@ -1,4 +1,42 @@
|
|||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
|
const STATUS_CONFIG = {
|
||||||
|
active: {
|
||||||
|
label: "Доступна",
|
||||||
|
bgClass: "bg-green-100",
|
||||||
|
textClass: "text-green-800",
|
||||||
|
},
|
||||||
|
borrowed: {
|
||||||
|
label: "Выдана",
|
||||||
|
bgClass: "bg-yellow-100",
|
||||||
|
textClass: "text-yellow-800",
|
||||||
|
},
|
||||||
|
reserved: {
|
||||||
|
label: "Забронирована",
|
||||||
|
bgClass: "bg-blue-100",
|
||||||
|
textClass: "text-blue-800",
|
||||||
|
},
|
||||||
|
restoration: {
|
||||||
|
label: "На реставрации",
|
||||||
|
bgClass: "bg-orange-100",
|
||||||
|
textClass: "text-orange-800",
|
||||||
|
},
|
||||||
|
written_off: {
|
||||||
|
label: "Списана",
|
||||||
|
bgClass: "bg-red-100",
|
||||||
|
textClass: "text-red-800",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function getStatusConfig(status) {
|
||||||
|
return (
|
||||||
|
STATUS_CONFIG[status] || {
|
||||||
|
label: status || "Неизвестно",
|
||||||
|
bgClass: "bg-gray-100",
|
||||||
|
textClass: "text-gray-800",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let selectedAuthors = new Map();
|
let selectedAuthors = new Map();
|
||||||
let selectedGenres = new Map();
|
let selectedGenres = new Map();
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
@@ -10,237 +48,183 @@ $(document).ready(() => {
|
|||||||
const authorIdsFromUrl = urlParams.getAll("author_id");
|
const authorIdsFromUrl = urlParams.getAll("author_id");
|
||||||
const searchFromUrl = urlParams.get("q");
|
const searchFromUrl = urlParams.get("q");
|
||||||
|
|
||||||
Promise.all([
|
if (searchFromUrl) $("#book-search-input").val(searchFromUrl);
|
||||||
fetch("/api/authors").then((response) => response.json()),
|
|
||||||
fetch("/api/genres").then((response) => response.json()),
|
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
|
||||||
])
|
|
||||||
.then(([authorsData, genresData]) => {
|
.then(([authorsData, genresData]) => {
|
||||||
const $dropdown = $("#author-dropdown");
|
initAuthors(authorsData.authors);
|
||||||
authorsData.authors.forEach((author) => {
|
initGenres(genresData.genres);
|
||||||
$("<div>")
|
initializeAuthorDropdownListeners();
|
||||||
.addClass("p-2 hover:bg-gray-100 cursor-pointer author-item")
|
renderChips();
|
||||||
.attr("data-id", author.id)
|
|
||||||
.attr("data-name", author.name)
|
|
||||||
.text(author.name)
|
|
||||||
.appendTo($dropdown);
|
|
||||||
|
|
||||||
if (authorIdsFromUrl.includes(String(author.id))) {
|
|
||||||
selectedAuthors.set(author.id, author.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const $list = $("#genres-list");
|
|
||||||
genresData.genres.forEach((genre) => {
|
|
||||||
const isChecked = genreIdsFromUrl.includes(String(genre.id));
|
|
||||||
|
|
||||||
if (isChecked) {
|
|
||||||
selectedGenres.set(genre.id, genre.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
$("<li>")
|
|
||||||
.addClass("mb-1")
|
|
||||||
.html(
|
|
||||||
`<label class="custom-checkbox flex items-center">
|
|
||||||
<input type="checkbox" data-id="${genre.id}" data-name="${genre.name}" ${isChecked ? 'checked' : ''} />
|
|
||||||
<span class="checkmark"></span>
|
|
||||||
${genre.name}
|
|
||||||
</label>`,
|
|
||||||
)
|
|
||||||
.appendTo($list);
|
|
||||||
});
|
|
||||||
|
|
||||||
initializeAuthorDropdown();
|
|
||||||
initializeFilters();
|
|
||||||
|
|
||||||
loadBooks();
|
loadBooks();
|
||||||
})
|
})
|
||||||
.catch((error) => console.error("Error loading data:", error));
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
Utils.showToast("Ошибка загрузки данных", "error");
|
||||||
|
});
|
||||||
|
|
||||||
|
function initAuthors(authors) {
|
||||||
|
const $dropdown = $("#author-dropdown");
|
||||||
|
authors.forEach((author) => {
|
||||||
|
$("<div>")
|
||||||
|
.addClass(
|
||||||
|
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors",
|
||||||
|
)
|
||||||
|
.attr("data-id", author.id)
|
||||||
|
.attr("data-name", author.name)
|
||||||
|
.text(author.name)
|
||||||
|
.appendTo($dropdown);
|
||||||
|
|
||||||
|
if (authorIdsFromUrl.includes(String(author.id))) {
|
||||||
|
selectedAuthors.set(author.id, author.name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initGenres(genres) {
|
||||||
|
const $list = $("#genres-list");
|
||||||
|
genres.forEach((genre) => {
|
||||||
|
const isChecked = genreIdsFromUrl.includes(String(genre.id));
|
||||||
|
if (isChecked) selectedGenres.set(genre.id, genre.name);
|
||||||
|
|
||||||
|
$list.append(`
|
||||||
|
<li class="mb-1">
|
||||||
|
<label class="custom-checkbox flex items-center">
|
||||||
|
<input type="checkbox" data-id="${genre.id}" data-name="${genre.name}" ${isChecked ? "checked" : ""} />
|
||||||
|
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
$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() {
|
function loadBooks() {
|
||||||
const searchQuery = $("#book-search-input").val().trim();
|
const searchQuery = $("#book-search-input").val().trim();
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (searchQuery.length >= 3) {
|
|
||||||
params.append("q", searchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedAuthors.forEach((name, id) => {
|
params.append("q", searchQuery);
|
||||||
params.append("author_ids", id);
|
selectedAuthors.forEach((_, id) => params.append("author_ids", id));
|
||||||
});
|
selectedGenres.forEach((_, id) => params.append("genre_ids", id));
|
||||||
|
|
||||||
selectedGenres.forEach((name, id) => {
|
const browserParams = new URLSearchParams();
|
||||||
params.append("genre_ids", id);
|
browserParams.append("q", searchQuery);
|
||||||
});
|
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
|
||||||
|
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
|
||||||
|
|
||||||
function updateBrowserUrl() {
|
const newUrl =
|
||||||
const params = new URLSearchParams();
|
window.location.pathname +
|
||||||
|
(browserParams.toString() ? `?${browserParams.toString()}` : "");
|
||||||
const searchQuery = $("#book-search-input").val().trim();
|
window.history.replaceState({}, "", newUrl);
|
||||||
if (searchQuery.length >= 3) {
|
|
||||||
params.append("q", searchQuery);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedAuthors.forEach((name, id) => {
|
|
||||||
params.append("author_id", id);
|
|
||||||
});
|
|
||||||
|
|
||||||
selectedGenres.forEach((name, id) => {
|
|
||||||
params.append("genre_id", id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const newUrl = params.toString()
|
|
||||||
? `${window.location.pathname}?${params.toString()}`
|
|
||||||
: window.location.pathname;
|
|
||||||
|
|
||||||
window.history.replaceState({}, "", newUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
params.append("page", currentPage);
|
params.append("page", currentPage);
|
||||||
params.append("size", pageSize);
|
params.append("size", pageSize);
|
||||||
|
|
||||||
const url = `/api/books/filter?${params.toString()}`;
|
|
||||||
|
|
||||||
showLoadingState();
|
showLoadingState();
|
||||||
|
|
||||||
updateBrowserUrl();
|
Api.get(`/api/books/filter?${params.toString()}`)
|
||||||
|
|
||||||
fetch(url)
|
|
||||||
.then((response) => {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
})
|
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
totalBooks = data.total;
|
totalBooks = data.total;
|
||||||
renderBooks(data.books);
|
renderBooks(data.books);
|
||||||
renderPagination();
|
renderPagination();
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error("Error loading books:", error);
|
console.error(error);
|
||||||
showErrorState();
|
Utils.showToast("Не удалось загрузить книги", "error");
|
||||||
|
$("#books-container").html(
|
||||||
|
document.getElementById("empty-state-template").innerHTML,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderBooks(books) {
|
function renderBooks(books) {
|
||||||
const $container = $("#books-container");
|
const $container = $("#books-container");
|
||||||
|
const tpl = document.getElementById("book-card-template");
|
||||||
|
const emptyTpl = document.getElementById("empty-state-template");
|
||||||
|
const badgeTpl = document.getElementById("genre-badge-template");
|
||||||
|
|
||||||
$container.empty();
|
$container.empty();
|
||||||
|
|
||||||
if (books.length === 0) {
|
if (books.length === 0) {
|
||||||
$container.html(`
|
$container.append(emptyTpl.content.cloneNode(true));
|
||||||
<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 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>
|
|
||||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Книги не найдены</h3>
|
|
||||||
<p class="text-gray-500">Попробуйте изменить параметры поиска или фильтры</p>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
books.forEach((book) => {
|
books.forEach((book) => {
|
||||||
const authorsText =
|
const clone = tpl.content.cloneNode(true);
|
||||||
|
const card = clone.querySelector(".book-card");
|
||||||
|
|
||||||
|
card.dataset.id = book.id;
|
||||||
|
clone.querySelector(".book-title").textContent = book.title;
|
||||||
|
clone.querySelector(".book-authors").textContent =
|
||||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||||
const genresText =
|
clone.querySelector(".book-desc").textContent = book.description || "";
|
||||||
book.genres.map((g) => g.name).join(", ") || "Без жанра";
|
|
||||||
|
|
||||||
const $bookCard = $(`
|
const statusConfig = getStatusConfig(book.status);
|
||||||
<div class="bg-white p-4 rounded-lg shadow-md mb-4 hover:shadow-lg transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
|
const statusEl = clone.querySelector(".book-status");
|
||||||
<div class="flex justify-between items-start">
|
statusEl.textContent = statusConfig.label;
|
||||||
<div class="flex-1">
|
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
|
||||||
<h3 class="text-lg font-bold mb-1 text-gray-900 hover:text-blue-600 transition-colors">
|
|
||||||
${escapeHtml(book.title)}
|
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-600 mb-2">
|
|
||||||
<span class="font-medium">Авторы:</span> ${escapeHtml(authorsText)}
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-700 text-sm mb-2">
|
|
||||||
${escapeHtml(book.description || "Описание отсутствует")}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-1">
|
|
||||||
${book.genres
|
|
||||||
.map(
|
|
||||||
(g) => `
|
|
||||||
<span class="inline-block bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full">
|
|
||||||
${escapeHtml(g.name)}
|
|
||||||
</span>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$container.append($bookCard);
|
const genresContainer = clone.querySelector(".book-genres");
|
||||||
});
|
book.genres.forEach((g) => {
|
||||||
|
const badge = badgeTpl.content.cloneNode(true);
|
||||||
$container.on("click", ".book-card", function () {
|
const span = badge.querySelector("span");
|
||||||
const bookId = $(this).data("id");
|
span.textContent = g.name;
|
||||||
window.location.href = `/book/${bookId}`;
|
genresContainer.appendChild(badge);
|
||||||
|
});
|
||||||
|
|
||||||
|
$container.append(clone);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPagination() {
|
function renderPagination() {
|
||||||
$("#pagination-container").remove();
|
$("#pagination-container").empty();
|
||||||
|
|
||||||
const totalPages = Math.ceil(totalBooks / pageSize);
|
const totalPages = Math.ceil(totalBooks / pageSize);
|
||||||
|
|
||||||
if (totalPages <= 1) return;
|
if (totalPages <= 1) return;
|
||||||
|
|
||||||
const $pagination = $(`
|
const $pagination = $(`
|
||||||
<div id="pagination-container" class="flex justify-center items-center gap-2 mt-6 mb-4">
|
<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" ${currentPage === 1 ? "disabled" : ""}>
|
<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" ${currentPage === 1 ? "disabled" : ""}>←</button>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div id="page-numbers" class="flex gap-1"></div>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
<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" ${currentPage === totalPages ? "disabled" : ""}>→</button>
|
||||||
</svg>
|
</div>
|
||||||
</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" ${currentPage === totalPages ? "disabled" : ""}>
|
|
||||||
<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="M9 5l7 7-7 7"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
const $pageNumbers = $pagination.find("#page-numbers");
|
const $pageNumbers = $pagination.find("#page-numbers");
|
||||||
|
|
||||||
const pages = generatePageNumbers(currentPage, totalPages);
|
const pages = generatePageNumbers(currentPage, totalPages);
|
||||||
|
|
||||||
pages.forEach((page) => {
|
pages.forEach((page) => {
|
||||||
if (page === "...") {
|
if (page === "...") {
|
||||||
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
|
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
|
||||||
} else {
|
} else {
|
||||||
const isActive = page === currentPage;
|
const isActive = page === currentPage;
|
||||||
$pageNumbers.append(`
|
$pageNumbers.append(`
|
||||||
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
|
<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>
|
||||||
${page}
|
`);
|
||||||
</button>
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("#books-container").after($pagination);
|
$("#pagination-container").append($pagination);
|
||||||
|
|
||||||
$("#prev-page").on("click", function () {
|
$("#prev-page").on("click", () => {
|
||||||
if (currentPage > 1) {
|
if (currentPage > 1) {
|
||||||
currentPage--;
|
currentPage--;
|
||||||
loadBooks();
|
loadBooks();
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
$("#next-page").on("click", () => {
|
||||||
$("#next-page").on("click", function () {
|
|
||||||
if (currentPage < totalPages) {
|
if (currentPage < totalPages) {
|
||||||
currentPage++;
|
currentPage++;
|
||||||
loadBooks();
|
loadBooks();
|
||||||
scrollToTop();
|
scrollToTop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$(".page-btn").on("click", function () {
|
$(".page-btn").on("click", function () {
|
||||||
const page = parseInt($(this).data("page"));
|
const page = parseInt($(this).data("page"));
|
||||||
if (page !== currentPage) {
|
if (page !== currentPage) {
|
||||||
@@ -254,7 +238,6 @@ $(document).ready(() => {
|
|||||||
function generatePageNumbers(current, total) {
|
function generatePageNumbers(current, total) {
|
||||||
const pages = [];
|
const pages = [];
|
||||||
const delta = 2;
|
const delta = 2;
|
||||||
|
|
||||||
for (let i = 1; i <= total; i++) {
|
for (let i = 1; i <= total; i++) {
|
||||||
if (
|
if (
|
||||||
i === 1 ||
|
i === 1 ||
|
||||||
@@ -266,7 +249,6 @@ $(document).ready(() => {
|
|||||||
pages.push("...");
|
pages.push("...");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return pages;
|
return pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,115 +257,78 @@ $(document).ready(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showLoadingState() {
|
function showLoadingState() {
|
||||||
const $container = $("#books-container");
|
$("#books-container").html(`
|
||||||
$container.html(`
|
<div class="space-y-4">
|
||||||
<div class="space-y-4">
|
${Array(3)
|
||||||
${Array(3)
|
.fill()
|
||||||
.fill()
|
.map(
|
||||||
.map(
|
() => `
|
||||||
() => `
|
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||||
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||||
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
</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>
|
.join("")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
`);
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showErrorState() {
|
function renderChips() {
|
||||||
const $container = $("#books-container");
|
const $container = $("#selected-authors-container");
|
||||||
$container.html(`
|
const $dropdown = $("#author-dropdown");
|
||||||
<div class="bg-red-50 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 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"/>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
|
|
||||||
<p class="text-red-700 mb-4">Не удалось загрузить список книг</p>
|
|
||||||
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
|
|
||||||
Попробовать снова
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$("#retry-btn").on("click", loadBooks);
|
$container.empty();
|
||||||
|
|
||||||
|
selectedAuthors.forEach((name, id) => {
|
||||||
|
$(`<span class="author-chip 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-400 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 escapeHtml(text) {
|
function initializeAuthorDropdownListeners() {
|
||||||
if (!text) return "";
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeAuthorDropdown() {
|
|
||||||
const $input = $("#author-search-input");
|
const $input = $("#author-search-input");
|
||||||
const $dropdown = $("#author-dropdown");
|
const $dropdown = $("#author-dropdown");
|
||||||
const $container = $("#selected-authors-container");
|
const $container = $("#selected-authors-container");
|
||||||
|
|
||||||
function updateHighlights() {
|
$input.on("focus", function () {
|
||||||
$dropdown.find(".author-item").each(function () {
|
|
||||||
const id = $(this).attr("data-id");
|
|
||||||
const isSelected = selectedAuthors.has(parseInt(id));
|
|
||||||
$(this)
|
|
||||||
.toggleClass("bg-gray-300 text-gray-600", isSelected)
|
|
||||||
.toggleClass("hover:bg-gray-100", !isSelected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterDropdown(query) {
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
$dropdown.find(".author-item").each(function () {
|
|
||||||
$(this).toggle($(this).text().toLowerCase().includes(lowerQuery));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderChips() {
|
|
||||||
$container.find(".author-chip").remove();
|
|
||||||
selectedAuthors.forEach((name, id) => {
|
|
||||||
$(`<span class="author-chip flex items-center bg-gray-500 text-white text-sm font-medium px-2.5 py-0.5 rounded-full">
|
|
||||||
${escapeHtml(name)}
|
|
||||||
<button type="button" class="remove-author ml-1.5 inline-flex items-center p-0.5 text-gray-200 hover:text-white hover:bg-gray-600 rounded-full" data-id="${id}">
|
|
||||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 14 14">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</span>`).insertBefore($input);
|
|
||||||
});
|
|
||||||
updateHighlights();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAuthor(id, name) {
|
|
||||||
id = parseInt(id);
|
|
||||||
if (selectedAuthors.has(id)) {
|
|
||||||
selectedAuthors.delete(id);
|
|
||||||
} else {
|
|
||||||
selectedAuthors.add(id, name);
|
|
||||||
selectedAuthors.set(id, name);
|
|
||||||
}
|
|
||||||
$input.val("");
|
|
||||||
filterDropdown("");
|
|
||||||
renderChips();
|
|
||||||
}
|
|
||||||
|
|
||||||
$input.on("focus", () => $dropdown.removeClass("hidden"));
|
|
||||||
|
|
||||||
$input.on("input", function () {
|
|
||||||
filterDropdown($(this).val().toLowerCase());
|
|
||||||
$dropdown.removeClass("hidden");
|
$dropdown.removeClass("hidden");
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on("click", (e) => {
|
$input.on("input", function () {
|
||||||
|
const val = $(this).val().toLowerCase();
|
||||||
|
$dropdown.removeClass("hidden");
|
||||||
|
$dropdown.find(".author-item").each(function () {
|
||||||
|
const text = $(this).text().toLowerCase();
|
||||||
|
$(this).toggle(text.includes(val));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on("click", function (e) {
|
||||||
if (
|
if (
|
||||||
!$(e.target).closest("#selected-authors-container, #author-dropdown")
|
!$(e.target).closest(
|
||||||
.length
|
"#author-search-input, #author-dropdown, #selected-authors-container",
|
||||||
|
).length
|
||||||
) {
|
) {
|
||||||
$dropdown.addClass("hidden");
|
$dropdown.addClass("hidden");
|
||||||
}
|
}
|
||||||
@@ -391,184 +336,52 @@ $(document).ready(() => {
|
|||||||
|
|
||||||
$dropdown.on("click", ".author-item", function (e) {
|
$dropdown.on("click", ".author-item", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleAuthor($(this).attr("data-id"), $(this).attr("data-name"));
|
const id = parseInt($(this).data("id"));
|
||||||
$input.focus();
|
const name = $(this).data("name");
|
||||||
|
|
||||||
|
if (selectedAuthors.has(id)) {
|
||||||
|
selectedAuthors.delete(id);
|
||||||
|
} else {
|
||||||
|
selectedAuthors.set(id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
$input.val("");
|
||||||
|
$dropdown.find(".author-item").show();
|
||||||
|
renderChips();
|
||||||
|
$input[0].focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
$container.on("click", ".remove-author", function (e) {
|
$container.on("click", ".remove-author", function (e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
selectedAuthors.delete(parseInt($(this).attr("data-id")));
|
const id = parseInt($(this).data("id"));
|
||||||
|
selectedAuthors.delete(id);
|
||||||
renderChips();
|
renderChips();
|
||||||
$input.focus();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$container.on("click", (e) => {
|
|
||||||
if (!$(e.target).closest(".author-chip").length) {
|
|
||||||
$input.focus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
window.renderAuthorChips = renderChips;
|
|
||||||
window.updateAuthorHighlights = updateHighlights;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function initializeFilters() {
|
$("#books-container").on("click", ".book-card", function () {
|
||||||
const $bookSearch = $("#book-search-input");
|
window.location.href = `/book/${$(this).data("id")}`;
|
||||||
const $applyBtn = $("#apply-filters-btn");
|
});
|
||||||
const $resetBtn = $("#reset-filters-btn");
|
|
||||||
|
|
||||||
$("#genres-list").on("change", "input[type='checkbox']", function () {
|
$("#apply-filters-btn").on("click", function () {
|
||||||
const id = parseInt($(this).attr("data-id"));
|
currentPage = 1;
|
||||||
const name = $(this).attr("data-name");
|
loadBooks();
|
||||||
if ($(this).is(":checked")) {
|
});
|
||||||
selectedGenres.set(id, name);
|
|
||||||
} else {
|
|
||||||
selectedGenres.delete(id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$applyBtn.on("click", function () {
|
$("#reset-filters-btn").on("click", function () {
|
||||||
|
$("#book-search-input").val("");
|
||||||
|
selectedAuthors.clear();
|
||||||
|
selectedGenres.clear();
|
||||||
|
$("#genres-list input").prop("checked", false);
|
||||||
|
renderChips();
|
||||||
|
currentPage = 1;
|
||||||
|
loadBooks();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#book-search-input").on("keypress", function (e) {
|
||||||
|
if (e.which === 13) {
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
loadBooks();
|
loadBooks();
|
||||||
});
|
|
||||||
|
|
||||||
$resetBtn.on("click", function () {
|
|
||||||
$bookSearch.val("");
|
|
||||||
|
|
||||||
selectedAuthors.clear();
|
|
||||||
$("#selected-authors-container .author-chip").remove();
|
|
||||||
if (window.updateAuthorHighlights) window.updateAuthorHighlights();
|
|
||||||
|
|
||||||
selectedGenres.clear();
|
|
||||||
$("#genres-list input[type='checkbox']").prop("checked", false);
|
|
||||||
|
|
||||||
currentPage = 1;
|
|
||||||
loadBooks();
|
|
||||||
});
|
|
||||||
|
|
||||||
let searchTimeout;
|
|
||||||
$bookSearch.on("input", function () {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
const query = $(this).val().trim();
|
|
||||||
|
|
||||||
if (query.length >= 3 || query.length === 0) {
|
|
||||||
searchTimeout = setTimeout(() => {
|
|
||||||
currentPage = 1;
|
|
||||||
loadBooks();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$bookSearch.on("keypress", function (e) {
|
|
||||||
if (e.which === 13) {
|
|
||||||
clearTimeout(searchTimeout);
|
|
||||||
currentPage = 1;
|
|
||||||
loadBooks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const $guestLink = $("#guest-link");
|
|
||||||
const $userBtn = $("#user-btn");
|
|
||||||
const $userDropdown = $("#user-dropdown");
|
|
||||||
const $userArrow = $("#user-arrow");
|
|
||||||
const $userAvatar = $("#user-avatar");
|
|
||||||
const $dropdownName = $("#dropdown-name");
|
|
||||||
const $dropdownUsername = $("#dropdown-username");
|
|
||||||
const $dropdownEmail = $("#dropdown-email");
|
|
||||||
const $logoutBtn = $("#logout-btn");
|
|
||||||
|
|
||||||
let isDropdownOpen = false;
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
isDropdownOpen = true;
|
|
||||||
$userDropdown.removeClass("hidden");
|
|
||||||
$userArrow.addClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpen = false;
|
|
||||||
$userDropdown.addClass("hidden");
|
|
||||||
$userArrow.removeClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userBtn.on("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("click", function (e) {
|
|
||||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on("keydown", function (e) {
|
|
||||||
if (e.key === "Escape" && isDropdownOpen) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$logoutBtn.on("click", function () {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
function showGuest() {
|
|
||||||
$guestLink.removeClass("hidden");
|
|
||||||
$userBtn.addClass("hidden").removeClass("flex");
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUser(user) {
|
|
||||||
$guestLink.addClass("hidden");
|
|
||||||
$userBtn.removeClass("hidden").addClass("flex");
|
|
||||||
|
|
||||||
const displayName = user.full_name || user.username;
|
|
||||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
$userAvatar.text(firstLetter);
|
|
||||||
$dropdownName.text(displayName);
|
|
||||||
$dropdownUsername.text("@" + user.username);
|
|
||||||
$dropdownEmail.text(user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUserAvatar(email) {
|
|
||||||
if (!email) return;
|
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
|
||||||
const emailHash = sha256(cleanEmail);
|
|
||||||
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
|
||||||
const avatarImg = document.getElementById("user-avatar");
|
|
||||||
if (avatarImg) {
|
|
||||||
avatarImg.src = avatarUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
showGuest();
|
|
||||||
} else {
|
|
||||||
fetch("/api/auth/me", {
|
|
||||||
headers: { Authorization: "Bearer " + token },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) return response.json();
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
})
|
|
||||||
.then((user) => {
|
|
||||||
showUser(user);
|
|
||||||
updateUserAvatar(user.email);
|
|
||||||
|
|
||||||
document.getElementById("user-btn").classList.remove("hidden");
|
|
||||||
document.getElementById("guest-link").classList.add("hidden");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
showGuest();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ const bookX = (svgWidth - bookWidth) / 2;
|
|||||||
const bookY = (svgHeight - bookHeight) / 2;
|
const bookY = (svgHeight - bookHeight) / 2;
|
||||||
const desiredLineSpacing = 8;
|
const desiredLineSpacing = 8;
|
||||||
const baseLineWidth = 2;
|
const baseLineWidth = 2;
|
||||||
const maxLineWidth = 10;
|
const maxLineWidth = 8;
|
||||||
const maxLineHeight = bookHeight - 24;
|
const maxLineHeight = bookHeight - 24;
|
||||||
const innerPaddingX = 10;
|
const innerPaddingX = 15;
|
||||||
const appearStagger = 8;
|
const appearStagger = 8;
|
||||||
|
|
||||||
let lineSpacing;
|
let lineSpacing;
|
||||||
@@ -28,7 +28,7 @@ if (lineCount > 1) {
|
|||||||
const linesSpan = lineSpacing * (lineCount - 1);
|
const linesSpan = lineSpacing * (lineCount - 1);
|
||||||
|
|
||||||
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
|
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
|
||||||
const lineStartX = rightBase - linesSpan;
|
const lineStartX = rightBase - linesSpan + maxLineWidth;
|
||||||
|
|
||||||
const leftLimit = bookX + innerPaddingX;
|
const leftLimit = bookX + innerPaddingX;
|
||||||
|
|
||||||
@@ -250,18 +250,16 @@ function observeStatCards() {
|
|||||||
entries.forEach((entry, index) => {
|
entries.forEach((entry, index) => {
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
$(entry.target)
|
$(entry.target).addClass("animate-fade-in").css({
|
||||||
.addClass("animate-fade-in")
|
opacity: "1",
|
||||||
.css({
|
transform: "translateY(0)",
|
||||||
opacity: "1",
|
});
|
||||||
transform: "translateY(0)",
|
|
||||||
});
|
|
||||||
}, index * 100);
|
}, index * 100);
|
||||||
observer.unobserve(entry.target);
|
observer.unobserve(entry.target);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ threshold: 0.1 }
|
{ threshold: 0.1 },
|
||||||
);
|
);
|
||||||
|
|
||||||
$cards.each((index, card) => {
|
$cards.each((index, card) => {
|
||||||
@@ -277,108 +275,4 @@ function observeStatCards() {
|
|||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
loadStats();
|
loadStats();
|
||||||
observeStatCards();
|
observeStatCards();
|
||||||
|
|
||||||
const $guestLink = $("#guest-link");
|
|
||||||
const $userBtn = $("#user-btn");
|
|
||||||
const $userDropdown = $("#user-dropdown");
|
|
||||||
const $userArrow = $("#user-arrow");
|
|
||||||
const $userAvatar = $("#user-avatar");
|
|
||||||
const $dropdownName = $("#dropdown-name");
|
|
||||||
const $dropdownUsername = $("#dropdown-username");
|
|
||||||
const $dropdownEmail = $("#dropdown-email");
|
|
||||||
const $logoutBtn = $("#logout-btn");
|
|
||||||
|
|
||||||
let isDropdownOpen = false;
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
isDropdownOpen = true;
|
|
||||||
$userDropdown.removeClass("hidden");
|
|
||||||
$userArrow.addClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpen = false;
|
|
||||||
$userDropdown.addClass("hidden");
|
|
||||||
$userArrow.removeClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userBtn.on("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("click", function (e) {
|
|
||||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("keydown", function (e) {
|
|
||||||
if (e.key === "Escape" && isDropdownOpen) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$logoutBtn.on("click", function () {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
function showGuest() {
|
|
||||||
$guestLink.removeClass("hidden");
|
|
||||||
$userBtn.addClass("hidden").removeClass("flex");
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUser(user) {
|
|
||||||
$guestLink.addClass("hidden");
|
|
||||||
$userBtn.removeClass("hidden").addClass("flex");
|
|
||||||
|
|
||||||
const displayName = user.full_name || user.username;
|
|
||||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
$userAvatar.text(firstLetter);
|
|
||||||
$dropdownName.text(displayName);
|
|
||||||
$dropdownUsername.text("@" + user.username);
|
|
||||||
$dropdownEmail.text(user.email);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateUserAvatar(email) {
|
|
||||||
if (!email) return;
|
|
||||||
const cleanEmail = email.trim().toLowerCase();
|
|
||||||
const emailHash = sha256(cleanEmail);
|
|
||||||
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
|
||||||
const avatarImg = document.getElementById("user-avatar");
|
|
||||||
if (avatarImg) {
|
|
||||||
avatarImg.src = avatarUrl;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
showGuest();
|
|
||||||
} else {
|
|
||||||
fetch("/api/auth/me", {
|
|
||||||
headers: { Authorization: "Bearer " + token },
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) return response.json();
|
|
||||||
throw new Error("Unauthorized");
|
|
||||||
})
|
|
||||||
.then((user) => {
|
|
||||||
showUser(user);
|
|
||||||
updateUserAvatar(user.email);
|
|
||||||
|
|
||||||
document.getElementById("user-btn").classList.remove("hidden");
|
|
||||||
document.getElementById("guest-link").classList.add("hidden");
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
showGuest();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
+123
-501
@@ -1,509 +1,131 @@
|
|||||||
$(document).ready(() => {
|
$(document).ready(() => {
|
||||||
let currentUser = null;
|
const token = localStorage.getItem("access_token");
|
||||||
let allRoles = [];
|
if (!token) {
|
||||||
|
window.location.href = "/auth";
|
||||||
const token = localStorage.getItem("access_token");
|
return;
|
||||||
|
}
|
||||||
if (!token) {
|
|
||||||
window.location.href = "/login";
|
loadProfile();
|
||||||
return;
|
|
||||||
}
|
function loadProfile() {
|
||||||
|
Promise.all([
|
||||||
loadProfile();
|
Api.get("/api/auth/me"),
|
||||||
|
Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
|
||||||
function loadProfile() {
|
])
|
||||||
showLoadingState();
|
.then(async ([user, rolesData]) => {
|
||||||
|
document.title = `LiB - ${user.full_name || user.username}`;
|
||||||
Promise.all([
|
await renderProfileHeader(user);
|
||||||
fetch("/api/auth/me", {
|
renderInfo(user);
|
||||||
headers: { Authorization: "Bearer " + token },
|
renderRoles(user.roles || [], rolesData.roles || []);
|
||||||
}).then((response) => {
|
|
||||||
if (!response.ok) {
|
$("#account-section, #roles-section").removeClass("hidden");
|
||||||
if (response.status === 401) {
|
})
|
||||||
throw new Error("Unauthorized");
|
.catch((error) => {
|
||||||
}
|
console.error(error);
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
Utils.showToast("Ошибка загрузки профиля", "error");
|
||||||
}
|
});
|
||||||
return response.json();
|
}
|
||||||
}),
|
|
||||||
fetch("/api/auth/roles", {
|
async function renderProfileHeader(user) {
|
||||||
headers: { Authorization: "Bearer " + token },
|
const avatarUrl = await Utils.getGravatarUrl(user.email);
|
||||||
}).then((response) => {
|
const displayName = Utils.escapeHtml(user.full_name || user.username);
|
||||||
if (response.ok) return response.json();
|
|
||||||
return { roles: [] };
|
$("#profile-card").html(`
|
||||||
}),
|
<div class="flex flex-col sm:flex-row items-center sm:items-start">
|
||||||
])
|
<div class="relative mb-4 sm:mb-0 sm:mr-6">
|
||||||
.then(([user, rolesData]) => {
|
<img src="${avatarUrl}" class="w-24 h-24 rounded-full object-cover border-4 border-gray-200">
|
||||||
currentUser = user;
|
${user.is_verified ? '<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1 border-2 border-white"><svg class="w-3 h-3 text-white" 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"/></svg></div>' : ""}
|
||||||
allRoles = rolesData.roles || [];
|
</div>
|
||||||
renderProfile(user);
|
<div class="flex-1 text-center sm:text-left">
|
||||||
renderAccountInfo(user);
|
<h1 class="text-2xl font-bold text-gray-900 mb-1">${displayName}</h1>
|
||||||
renderRoles(user.roles, allRoles);
|
<p class="text-gray-500 mb-3">@${Utils.escapeHtml(user.username)}</p>
|
||||||
renderActions();
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm ${user.is_active ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}">
|
||||||
document.title = `LiB - ${user.full_name || user.username}`;
|
${user.is_active ? "Активен" : "Заблокирован"}
|
||||||
})
|
</span>
|
||||||
.catch((error) => {
|
|
||||||
console.error("Error loading profile:", error);
|
|
||||||
if (error.message === "Unauthorized") {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
window.location.href = "/login";
|
|
||||||
} else {
|
|
||||||
showErrorState(error.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderProfile(user) {
|
|
||||||
const $card = $("#profile-card");
|
|
||||||
const displayName = user.full_name || user.username;
|
|
||||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
const emailHash = sha256(user.email.trim().toLowerCase());
|
|
||||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
|
||||||
|
|
||||||
$card.html(`
|
|
||||||
<div class="flex flex-col sm:flex-row items-center sm:items-start">
|
|
||||||
<!-- Аватар -->
|
|
||||||
<div class="relative mb-4 sm:mb-0 sm:mr-6">
|
|
||||||
<img src="${avatarUrl}" alt="Аватар"
|
|
||||||
class="w-24 h-24 rounded-full object-cover border-4 border-gray-200"
|
|
||||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
|
||||||
<div class="w-24 h-24 bg-gray-500 text-white rounded-full items-center justify-center text-4xl font-bold hidden">
|
|
||||||
${firstLetter}
|
|
||||||
</div>
|
|
||||||
<!-- Статус верификации -->
|
|
||||||
${user.is_verified ? `
|
|
||||||
<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1" title="Подтверждённый аккаунт">
|
|
||||||
<svg class="w-4 h-4 text-white" 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"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Информация -->
|
|
||||||
<div class="flex-1 text-center sm:text-left">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-1">${escapeHtml(displayName)}</h1>
|
|
||||||
<p class="text-gray-500 mb-3">@${escapeHtml(user.username)}</p>
|
|
||||||
|
|
||||||
<!-- Статусы -->
|
|
||||||
<div class="flex flex-wrap justify-center sm:justify-start gap-2">
|
|
||||||
${user.is_active ? `
|
|
||||||
<span class="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1 rounded-full">
|
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
|
||||||
Активен
|
|
||||||
</span>
|
|
||||||
` : `
|
|
||||||
<span class="inline-flex items-center bg-red-100 text-red-800 text-sm px-3 py-1 rounded-full">
|
|
||||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
|
||||||
Заблокирован
|
|
||||||
</span>
|
|
||||||
`}
|
|
||||||
${user.is_verified ? `
|
|
||||||
<span class="inline-flex items-center bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded-full">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
Подтверждён
|
|
||||||
</span>
|
|
||||||
` : `
|
|
||||||
<span class="inline-flex items-center bg-yellow-100 text-yellow-800 text-sm px-3 py-1 rounded-full">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
|
||||||
</svg>
|
|
||||||
Не подтверждён
|
|
||||||
</span>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAccountInfo(user) {
|
|
||||||
const $container = $("#account-container");
|
|
||||||
|
|
||||||
$container.html(`
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- ID пользователя -->
|
|
||||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-500">ID пользователя</p>
|
|
||||||
<p class="text-gray-900">${user.id}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<!-- Имя пользователя -->
|
|
||||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-gray-400 mr-3" 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>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-500">Имя пользователя</p>
|
|
||||||
<p class="text-gray-900">@${escapeHtml(user.username)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Полное имя -->
|
|
||||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-500">Полное имя</p>
|
|
||||||
<p class="text-gray-900">${escapeHtml(user.full_name || "Не указано")}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email -->
|
|
||||||
<div class="flex items-center justify-between py-3">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-500">Email</p>
|
|
||||||
<p class="text-gray-900">${escapeHtml(user.email)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRoles(userRoles, allRoles) {
|
|
||||||
const $container = $("#roles-container");
|
|
||||||
|
|
||||||
if (!userRoles || userRoles.length === 0) {
|
|
||||||
$container.html(`
|
|
||||||
<p class="text-gray-500">У вас нет назначенных ролей</p>
|
|
||||||
`);
|
`);
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
function renderInfo(user) {
|
||||||
const roleDescriptions = {};
|
const fields = [
|
||||||
allRoles.forEach((role) => {
|
{ label: "ID пользователя", value: user.id },
|
||||||
roleDescriptions[role.name] = role.description;
|
{ label: "Email", value: user.email },
|
||||||
});
|
{ label: "Полное имя", value: user.full_name || "Не указано" },
|
||||||
|
];
|
||||||
const roleIcons = {
|
|
||||||
admin: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
const html = fields
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
.map(
|
||||||
</svg>`,
|
(f) => `
|
||||||
librarian: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex justify-between py-2 border-b last:border-0">
|
||||||
<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"/>
|
<span class="text-gray-500">${f.label}</span>
|
||||||
</svg>`,
|
<span class="font-medium text-gray-900">${Utils.escapeHtml(String(f.value))}</span>
|
||||||
member: `<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
|
||||||
</svg>`,
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleColors = {
|
|
||||||
admin: "bg-red-100 text-red-800 border-red-200",
|
|
||||||
librarian: "bg-blue-100 text-blue-800 border-blue-200",
|
|
||||||
member: "bg-green-100 text-green-800 border-green-200",
|
|
||||||
};
|
|
||||||
|
|
||||||
let rolesHtml = '<div class="space-y-3">';
|
|
||||||
|
|
||||||
userRoles.forEach((roleName) => {
|
|
||||||
const description = roleDescriptions[roleName] || "Описание недоступно";
|
|
||||||
const icon = roleIcons[roleName] || roleIcons.member;
|
|
||||||
const colorClass = roleColors[roleName] || roleColors.member;
|
|
||||||
|
|
||||||
rolesHtml += `
|
|
||||||
<div class="flex items-center p-4 rounded-lg border ${colorClass}">
|
|
||||||
<div class="flex-shrink-0 mr-4">
|
|
||||||
${icon}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
`,
|
||||||
<h4 class="font-medium capitalize">${escapeHtml(roleName)}</h4>
|
)
|
||||||
<p class="text-sm opacity-75">${escapeHtml(description)}</p>
|
.join("");
|
||||||
</div>
|
|
||||||
</div>
|
$("#account-info").html(html);
|
||||||
`;
|
}
|
||||||
});
|
|
||||||
|
function renderRoles(userRoles, allRoles) {
|
||||||
rolesHtml += '</div>';
|
const $container = $("#roles-container");
|
||||||
|
if (userRoles.length === 0) {
|
||||||
$container.html(rolesHtml);
|
$container.html('<p class="text-gray-500">Нет ролей</p>');
|
||||||
}
|
return;
|
||||||
|
|
||||||
function renderActions() {
|
|
||||||
const $container = $("#actions-container");
|
|
||||||
|
|
||||||
$container.html(`
|
|
||||||
<div class="space-y-3">
|
|
||||||
<!-- Смена пароля -->
|
|
||||||
<button id="change-password-btn" class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-gray-700">Сменить пароль</span>
|
|
||||||
</div>
|
|
||||||
<svg class="w-5 h-5 text-gray-400" 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"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Выход -->
|
|
||||||
<button id="logout-profile-btn" class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<svg class="w-5 h-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-red-700">Выйти из аккаунта</span>
|
|
||||||
</div>
|
|
||||||
<svg class="w-5 h-5 text-red-400" 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"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$("#change-password-btn").on("click", openPasswordModal);
|
|
||||||
$("#logout-profile-btn").on("click", logout);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openPasswordModal() {
|
|
||||||
$("#password-modal").removeClass("hidden").addClass("flex");
|
|
||||||
$("#current-password").focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePasswordModal() {
|
|
||||||
$("#password-modal").removeClass("flex").addClass("hidden");
|
|
||||||
$("#password-form")[0].reset();
|
|
||||||
$("#password-error").addClass("hidden").text("");
|
|
||||||
}
|
|
||||||
|
|
||||||
$("#close-password-modal, #cancel-password").on("click", closePasswordModal);
|
|
||||||
|
|
||||||
$("#password-modal").on("click", function (e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closePasswordModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("keydown", function (e) {
|
|
||||||
if (e.key === "Escape" && $("#password-modal").hasClass("flex")) {
|
|
||||||
closePasswordModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$("#password-form").on("submit", function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const currentPassword = $("#current-password").val();
|
|
||||||
const newPassword = $("#new-password").val();
|
|
||||||
const confirmPassword = $("#confirm-password").val();
|
|
||||||
const $error = $("#password-error");
|
|
||||||
|
|
||||||
if (newPassword !== confirmPassword) {
|
|
||||||
$error.text("Пароли не совпадают").removeClass("hidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.length < 6) {
|
|
||||||
$error.text("Пароль должен содержать минимум 6 символов").removeClass("hidden");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: смена пароля, 2FA
|
|
||||||
// fetch("/api/auth/change-password", {
|
|
||||||
// method: "POST",
|
|
||||||
// headers: {
|
|
||||||
// "Content-Type": "application/json",
|
|
||||||
// Authorization: "Bearer " + token,
|
|
||||||
// },
|
|
||||||
// body: JSON.stringify({
|
|
||||||
// current_password: currentPassword,
|
|
||||||
// new_password: newPassword,
|
|
||||||
// }),
|
|
||||||
// })
|
|
||||||
// .then((response) => {
|
|
||||||
// if (!response.ok) throw new Error("Ошибка смены пароля");
|
|
||||||
// return response.json();
|
|
||||||
// })
|
|
||||||
// .then(() => {
|
|
||||||
// closePasswordModal();
|
|
||||||
// showNotification("Пароль успешно изменён", "success");
|
|
||||||
// })
|
|
||||||
// .catch((error) => {
|
|
||||||
// $error.text(error.message).removeClass("hidden");
|
|
||||||
// });
|
|
||||||
|
|
||||||
$error.text("Функция смены пароля временно недоступна").removeClass("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
function logout() {
|
|
||||||
localStorage.removeItem("access_token");
|
|
||||||
localStorage.removeItem("refresh_token");
|
|
||||||
window.location.href = "/login";
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoadingState() {
|
|
||||||
const $profileCard = $("#profile-card");
|
|
||||||
const $accountContainer = $("#account-container");
|
|
||||||
const $rolesContainer = $("#roles-container");
|
|
||||||
const $actionsContainer = $("#actions-container");
|
|
||||||
|
|
||||||
$profileCard.html(`
|
|
||||||
<div class="flex flex-col sm:flex-row items-center sm:items-start animate-pulse">
|
|
||||||
<div class="w-24 h-24 bg-gray-200 rounded-full mb-4 sm:mb-0 sm:mr-6"></div>
|
|
||||||
<div class="flex-1 text-center sm:text-left">
|
|
||||||
<div class="h-7 bg-gray-200 rounded w-48 mx-auto sm:mx-0 mb-2"></div>
|
|
||||||
<div class="h-5 bg-gray-200 rounded w-32 mx-auto sm:mx-0 mb-3"></div>
|
|
||||||
<div class="flex justify-center sm:justify-start gap-2">
|
|
||||||
<div class="h-7 bg-gray-200 rounded-full w-20"></div>
|
|
||||||
<div class="h-7 bg-gray-200 rounded-full w-28"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$accountContainer.html(`
|
|
||||||
<div class="space-y-4 animate-pulse">
|
|
||||||
${Array(4)
|
|
||||||
.fill()
|
|
||||||
.map(
|
|
||||||
() => `
|
|
||||||
<div class="flex items-center py-3 border-b border-gray-100">
|
|
||||||
<div class="w-5 h-5 bg-gray-200 rounded mr-3"></div>
|
|
||||||
<div>
|
|
||||||
<div class="h-3 bg-gray-200 rounded w-16 mb-2"></div>
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-40"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
)
|
|
||||||
.join("")}
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$rolesContainer.html(`
|
|
||||||
<div class="space-y-3 animate-pulse">
|
|
||||||
<div class="h-16 bg-gray-200 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$actionsContainer.html(`
|
|
||||||
<div class="space-y-3 animate-pulse">
|
|
||||||
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
|
||||||
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function showErrorState(message) {
|
|
||||||
const $profileCard = $("#profile-card");
|
|
||||||
const $accountSection = $("#account-section");
|
|
||||||
const $rolesSection = $("#roles-section");
|
|
||||||
const $actionsSection = $("#actions-section");
|
|
||||||
|
|
||||||
$accountSection.hide();
|
|
||||||
$rolesSection.hide();
|
|
||||||
$actionsSection.hide();
|
|
||||||
|
|
||||||
$profileCard.html(`
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<svg class="mx-auto h-16 w-16 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 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"/>
|
|
||||||
</svg>
|
|
||||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
|
||||||
<p class="text-gray-500 mb-6">Не удалось загрузить профиль</p>
|
|
||||||
<div class="flex justify-center gap-4">
|
|
||||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
|
||||||
Попробовать снова
|
|
||||||
</button>
|
|
||||||
<a href="/" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
|
||||||
На главную
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
$("#retry-btn").on("click", function () {
|
|
||||||
$accountSection.show();
|
|
||||||
$rolesSection.show();
|
|
||||||
$actionsSection.show();
|
|
||||||
loadProfile();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
if (!text) return "";
|
|
||||||
const div = document.createElement("div");
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
const $guestLink = $("#guest-link");
|
|
||||||
const $userBtn = $("#user-btn");
|
|
||||||
const $userDropdown = $("#user-dropdown");
|
|
||||||
const $userArrow = $("#user-arrow");
|
|
||||||
const $userAvatar = $("#user-avatar");
|
|
||||||
const $dropdownName = $("#dropdown-name");
|
|
||||||
const $dropdownUsername = $("#dropdown-username");
|
|
||||||
const $dropdownEmail = $("#dropdown-email");
|
|
||||||
const $logoutBtn = $("#logout-btn");
|
|
||||||
|
|
||||||
let isDropdownOpen = false;
|
|
||||||
|
|
||||||
function openDropdown() {
|
|
||||||
isDropdownOpen = true;
|
|
||||||
$userDropdown.removeClass("hidden");
|
|
||||||
$userArrow.addClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDropdown() {
|
|
||||||
isDropdownOpen = false;
|
|
||||||
$userDropdown.addClass("hidden");
|
|
||||||
$userArrow.removeClass("rotate-180");
|
|
||||||
}
|
|
||||||
|
|
||||||
$userBtn.on("click", function (e) {
|
|
||||||
e.stopPropagation();
|
|
||||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
|
||||||
});
|
|
||||||
|
|
||||||
$(document).on("click", function (e) {
|
|
||||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$logoutBtn.on("click", logout);
|
|
||||||
|
|
||||||
function showGuest() {
|
|
||||||
$guestLink.removeClass("hidden");
|
|
||||||
$userBtn.addClass("hidden").removeClass("flex");
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
|
|
||||||
function showUser(user) {
|
|
||||||
$guestLink.addClass("hidden");
|
|
||||||
$userBtn.removeClass("hidden").addClass("flex");
|
|
||||||
|
|
||||||
const displayName = user.full_name || user.username;
|
|
||||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
$userAvatar.text(firstLetter);
|
|
||||||
$dropdownName.text(displayName);
|
|
||||||
$dropdownUsername.text("@" + user.username);
|
|
||||||
$dropdownEmail.text(user.email);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentUser) {
|
const roleMap = {};
|
||||||
showUser(currentUser);
|
allRoles.forEach((r) => (roleMap[r.name] = r.description));
|
||||||
|
|
||||||
|
const html = userRoles
|
||||||
|
.map(
|
||||||
|
(role) => `
|
||||||
|
<div class="p-3 bg-blue-50 border border-blue-100 rounded text-blue-800">
|
||||||
|
<div class="font-bold capitalize">${Utils.escapeHtml(role)}</div>
|
||||||
|
<div class="text-xs opacity-75">${Utils.escapeHtml(roleMap[role] || "")}</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
$container.html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#submit-password-btn").on("click", async function () {
|
||||||
|
const $btn = $(this);
|
||||||
|
const newPass = $("#new-password").val();
|
||||||
|
const confirm = $("#confirm-password").val();
|
||||||
|
|
||||||
|
if (newPass !== confirm) {
|
||||||
|
Utils.showToast("Пароли не совпадают", "error");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
if (newPass.length < 4) {
|
||||||
|
Utils.showToast("Пароль слишком короткий", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$btn.prop("disabled", true).text("Меняем...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Api.request("/api/auth/me", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: newPass,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
Utils.showToast("Пароль успешно изменен", "success");
|
||||||
|
window.dispatchEvent(new CustomEvent("close-modal"));
|
||||||
|
|
||||||
|
$("#change-password-form")[0].reset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
Utils.showToast(error.message || "Ошибка смены пароля", "error");
|
||||||
|
} finally {
|
||||||
|
$btn.prop("disabled", false).text("Сменить");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ h1 {
|
|||||||
letter-spacing: 10px;
|
letter-spacing: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h2,
|
||||||
nav ul li a {
|
nav ul li a {
|
||||||
font-family: "Dited", sans-serif;
|
font-family: "Dited", sans-serif;
|
||||||
letter-spacing: 2.5px;
|
letter-spacing: 2.5px;
|
||||||
@@ -245,8 +246,8 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.line-clamp-3 {
|
.line-clamp-3 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 3;
|
-webkit-line-clamp: 3;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const Utils = {
|
||||||
|
escapeHtml: (text) => {
|
||||||
|
if (!text) return "";
|
||||||
|
return text.replace(/[&<>"']/g, function (m) {
|
||||||
|
return {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
}[m];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
showToast: (message, type = "info") => {
|
||||||
|
const container = document.getElementById("toast-container");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const el = document.createElement("div");
|
||||||
|
const colors =
|
||||||
|
type === "error"
|
||||||
|
? "bg-red-500"
|
||||||
|
: type === "success"
|
||||||
|
? "bg-green-500"
|
||||||
|
: "bg-blue-500";
|
||||||
|
el.className = `${colors} text-white px-6 py-3 rounded shadow-lg transform transition-all duration-300 translate-y-10 opacity-0 mb-3`;
|
||||||
|
el.textContent = message;
|
||||||
|
|
||||||
|
container.appendChild(el);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.classList.remove("translate-y-10", "opacity-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.add("translate-y-10", "opacity-0");
|
||||||
|
setTimeout(() => el.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
getGravatarUrl: async (email) => {
|
||||||
|
if (!email) return "";
|
||||||
|
const msgBuffer = new TextEncoder().encode(email.trim().toLowerCase());
|
||||||
|
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const hashHex = hashArray
|
||||||
|
.map((b) => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
return `https://www.gravatar.com/avatar/${hashHex}?d=identicon&s=200`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Api = {
|
||||||
|
async request(endpoint, options = {}) {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options.headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = { ...options, headers };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, config);
|
||||||
|
if (response.status === 401) {
|
||||||
|
Auth.logout();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || `Error ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
get(endpoint) {
|
||||||
|
return this.request(endpoint, { method: "GET" });
|
||||||
|
},
|
||||||
|
|
||||||
|
post(endpoint, body) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
postForm(endpoint, formData) {
|
||||||
|
return this.request(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
body: formData.toString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Auth = {
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem("access_token");
|
||||||
|
localStorage.removeItem("refresh_token");
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
|
||||||
|
init: async () => {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await Api.get("/api/auth/me");
|
||||||
|
if (user) {
|
||||||
|
document.dispatchEvent(new CustomEvent("auth:login", { detail: user }));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Auth check failed", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -19,7 +19,6 @@ block content %}
|
|||||||
Регистрация
|
Регистрация
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="login-form" class="p-6">
|
<form id="login-form" class="p-6">
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label
|
<label
|
||||||
|
|||||||
@@ -1,16 +1,97 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %} {% block
|
{% extends "base.html" %} {% block content %}
|
||||||
content %}
|
<div class="container mx-auto p-4 max-w-4xl">
|
||||||
<div class="flex flex-1 mt-4 p-4">
|
<div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
<main class="flex-1 max-w-4xl mx-auto">
|
<a
|
||||||
<div id="author-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
href="/authors"
|
||||||
</div>
|
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
||||||
<div id="books-section" class="bg-white p-6 rounded-lg shadow-md">
|
>
|
||||||
<h2 class="text-xl font-semibold mb-4">Книги автора</h2>
|
<svg
|
||||||
<div id="books-container">
|
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="M15 19l-7-7 7-7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Вернуться к списку авторов
|
||||||
|
</a>
|
||||||
|
<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">
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div id="author-content" class="hidden flex items-start">
|
||||||
|
<div
|
||||||
|
id="author-avatar"
|
||||||
|
class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0"
|
||||||
|
></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h1
|
||||||
|
id="author-name"
|
||||||
|
class="text-3xl font-bold text-gray-900"
|
||||||
|
></h1>
|
||||||
|
<span id="author-id" class="text-sm text-gray-500"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center text-gray-600 mb-4">
|
||||||
|
<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 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>
|
||||||
|
<span id="author-books-count"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="books-section">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Книги автора</h2>
|
||||||
|
<div id="books-container" class="space-y-4"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<template id="book-item-template">
|
||||||
|
<div
|
||||||
|
class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow duration-200 cursor-pointer book-card bg-white"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3
|
||||||
|
class="book-title text-lg font-semibold text-gray-900 hover:text-gray-400 transition-colors mb-2"
|
||||||
|
></h3>
|
||||||
|
<p class="book-desc text-gray-600 text-sm line-clamp-3"></p>
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="text/javascript" src="/static/author.js"></script>
|
<script src="/static/author.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,20 +1,88 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %} {% block
|
{% extends "base.html" %} {% block content %}
|
||||||
content %}
|
<div class="container mx-auto p-4">
|
||||||
<div class="flex flex-1 mt-4 p-4">
|
<div
|
||||||
<aside
|
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
|
||||||
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
|
|
||||||
>
|
>
|
||||||
<h2 class="text-xl font-semibold mb-4">Поиск</h2>
|
<h2 class="text-2xl font-bold text-gray-800">Авторы</h2>
|
||||||
<div class="relative mb-4">
|
|
||||||
<input
|
<div class="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
|
||||||
type="text"
|
<div class="relative">
|
||||||
id="author-search-input"
|
<input
|
||||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
type="text"
|
||||||
placeholder="Поиск авторов..."
|
id="author-search-input"
|
||||||
maxlength="50"
|
placeholder="Поиск автора..."
|
||||||
/>
|
class="border rounded-lg pl-3 pr-10 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 w-full sm:w-64"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
class="absolute right-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="flex gap-2 bg-white rounded-lg p-1 border">
|
||||||
|
<label
|
||||||
|
class="cursor-pointer px-3 py-1 rounded hover:bg-gray-100 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort"
|
||||||
|
value="name_asc"
|
||||||
|
checked
|
||||||
|
class="hidden peer"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-sm text-gray-600 peer-checked:text-black peer-checked:font-bold"
|
||||||
|
>А-Я</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
<label
|
||||||
|
class="cursor-pointer px-3 py-1 rounded hover:bg-gray-100 flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sort"
|
||||||
|
value="name_desc"
|
||||||
|
class="hidden peer"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
class="text-sm text-gray-600 peer-checked:text-black peer-checked:font-bold"
|
||||||
|
>Я-А</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="results-counter" class="text-sm text-gray-500 mb-4"></div>
|
||||||
|
<div
|
||||||
|
id="authors-container"
|
||||||
|
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
|
></div>
|
||||||
|
<div id="pagination-container"></div>
|
||||||
|
</div>
|
||||||
|
<template id="author-card-template">
|
||||||
|
<div
|
||||||
|
class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer author-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div
|
||||||
|
class="author-avatar w-12 h-12 bg-gray-500 text-white rounded-full flex items-center justify-center text-xl font-bold mr-4"
|
||||||
|
></div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3
|
||||||
|
class="author-name text-lg font-semibold text-gray-900 hover:text-gray-400 transition-colors"
|
||||||
|
></h3>
|
||||||
|
<p class="author-id text-sm text-gray-500"></p>
|
||||||
|
</div>
|
||||||
<svg
|
<svg
|
||||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
class="w-5 h-5 text-gray-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -23,50 +91,33 @@ content %}
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
d="M9 5l7 7-7 7"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-xl font-semibold mb-4">Сортировка</h2>
|
</div>
|
||||||
<div class="mb-4">
|
</template>
|
||||||
<div class="space-y-2">
|
<template id="empty-state-template">
|
||||||
<label class="flex items-center cursor-pointer">
|
<div class="col-span-full bg-white p-8 rounded-lg shadow-md text-center">
|
||||||
<input
|
<svg
|
||||||
type="radio"
|
class="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||||
name="sort"
|
fill="none"
|
||||||
value="name_asc"
|
stroke="currentColor"
|
||||||
checked
|
viewBox="0 0 24 24"
|
||||||
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
|
||||||
/>
|
|
||||||
<span class="ml-2 text-gray-700">По имени (А-Я)</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="sort"
|
|
||||||
value="name_desc"
|
|
||||||
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
|
||||||
/>
|
|
||||||
<span class="ml-2 text-gray-700">По имени (Я-А)</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
id="reset-filters-btn"
|
|
||||||
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200"
|
|
||||||
>
|
>
|
||||||
Сбросить
|
<path
|
||||||
</button>
|
stroke-linecap="round"
|
||||||
<div
|
stroke-linejoin="round"
|
||||||
id="results-counter"
|
stroke-width="2"
|
||||||
class="mt-4 text-center text-sm text-gray-500"
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
></div>
|
/>
|
||||||
</aside>
|
</svg>
|
||||||
<main class="flex-1">
|
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||||
<div id="authors-container"></div>
|
Авторы не найдены
|
||||||
<div id="pagination-container"></div>
|
</h3>
|
||||||
</main>
|
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="text/javascript" src="/static/authors.js"></script>
|
<script src="/static/authors.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -4,14 +4,30 @@
|
|||||||
<title>{% block title %}LiB{% endblock %}</title>
|
<title>{% block title %}LiB{% endblock %}</title>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<script async="" src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
|
<script
|
||||||
|
defer
|
||||||
|
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
|
||||||
|
></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/cash/8.1.5/cash.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/cash/8.1.5/cash.min.js"></script>
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="/static/utils.js"></script>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="flex flex-col min-h-screen bg-gray-100">
|
<body
|
||||||
<header class="bg-gray-500 text-white p-4 shadow-md">
|
class="flex flex-col min-h-screen bg-gray-100"
|
||||||
|
x-data="{
|
||||||
|
user: null,
|
||||||
|
async init() {
|
||||||
|
document.addEventListener('auth:login', async (e) => {
|
||||||
|
this.user = e.detail;
|
||||||
|
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
|
||||||
|
});
|
||||||
|
await Auth.init();
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<header class="bg-gray-600 text-white p-4 shadow-md">
|
||||||
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
||||||
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||||
<img class="invert" src="/static/logo.svg" />
|
<img class="invert" src="/static/logo.svg" />
|
||||||
@@ -19,59 +35,168 @@
|
|||||||
</a>
|
</a>
|
||||||
<nav>
|
<nav>
|
||||||
<ul class="flex space-x-4">
|
<ul class="flex space-x-4">
|
||||||
<li><a href="/" class="hover:text-gray-200">Главная</a></li>
|
<li>
|
||||||
<li><a href="/books" class="hover:text-gray-200">Книги</a></li>
|
<a href="/" class="hover:text-gray-200">Главная</a>
|
||||||
<li><a href="/authors" class="hover:text-gray-200">Авторы</a></li>
|
</li>
|
||||||
<li><a href="/api" class="hover:text-gray-200">API</a></li>
|
<li>
|
||||||
|
<a href="/books" class="hover:text-gray-200"
|
||||||
|
>Книги</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/authors" class="hover:text-gray-200"
|
||||||
|
>Авторы</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/api" class="hover:text-gray-200">API</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="relative" id="user-menu-area">
|
<div class="relative" x-data="{ open: false }">
|
||||||
<a href="/auth" id="guest-link" class="block hover:opacity-80 transition"><img class="w-6 h-6 invert" src="/static/avatar.svg" /></a>
|
<template x-if="!user">
|
||||||
<button type="button" id="user-btn" class="hidden items-center gap-2 hover:opacity-80 transition focus:outline-none">
|
<a
|
||||||
<img
|
href="/auth"
|
||||||
id="user-avatar"
|
class="block hover:opacity-80 transition"
|
||||||
src="https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y"
|
>
|
||||||
class="w-8 h-8 rounded-full border-2 border-white object-cover bg-gray-600"
|
<svg
|
||||||
alt="User Avatar"
|
class="w-7 h-7"
|
||||||
/>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
<svg id="user-arrow" class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
viewBox="0 0 24 24"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
>
|
||||||
</svg>
|
<path
|
||||||
</button>
|
stroke-linecap="round"
|
||||||
<div id="user-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
stroke-linejoin="round"
|
||||||
<div class="px-4 py-3 border-b border-gray-200">
|
stroke-width="1.5"
|
||||||
<p id="dropdown-name" class="text-sm font-semibold text-gray-900 truncate">Пользователь</p>
|
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
<p id="dropdown-username" class="text-sm text-gray-500 truncate">@username</p>
|
></path>
|
||||||
<p id="dropdown-email" class="text-xs text-gray-400 truncate mt-1">email@example.com</p>
|
</svg>
|
||||||
</div>
|
|
||||||
<a href="/profile" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100">
|
|
||||||
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
|
|
||||||
<p class="text-gray-700 text-sm">Мой профиль</p>
|
|
||||||
</a>
|
</a>
|
||||||
<a href="/my-books" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100">
|
</template>
|
||||||
<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 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>
|
<template x-if="user">
|
||||||
<p class="text-gray-700 text-sm">Мои книги</p>
|
<div>
|
||||||
</a>
|
<button
|
||||||
<div class="border-t border-gray-200 mt-1 pt-1">
|
@click="open = !open"
|
||||||
<button type="button" id="logout-btn" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
@click.outside="open = false"
|
||||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
|
type="button"
|
||||||
<p class="text-gray-700 text-sm">Выйти</p>
|
class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="user.avatar"
|
||||||
|
class="w-8 h-8 rounded-full border border-white object-cover bg-gray-600"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 transition-transform duration-200"
|
||||||
|
:class="open ? 'rotate-180' : ''"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition
|
||||||
|
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
||||||
|
style="display: none"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200">
|
||||||
|
<p
|
||||||
|
class="text-sm font-semibold truncate"
|
||||||
|
x-text="user.full_name || user.username"
|
||||||
|
></p>
|
||||||
|
<p
|
||||||
|
class="text-sm text-gray-500 truncate"
|
||||||
|
x-text="'@' + user.username"
|
||||||
|
></p>
|
||||||
|
<p
|
||||||
|
class="text-xs text-gray-400 truncate mt-1"
|
||||||
|
x-text="user.email"
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/profile"
|
||||||
|
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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Мой профиль
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/my-books"
|
||||||
|
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 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>
|
||||||
|
Мои книги
|
||||||
|
</a>
|
||||||
|
<div class="border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
@click="Auth.logout()"
|
||||||
|
class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-4 h-4 mr-3"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<main class="flex-grow">{% block content %}{% endblock %}</main>
|
||||||
{% block content %}{% endblock %}
|
<div
|
||||||
|
id="toast-container"
|
||||||
|
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50"
|
||||||
|
></div>
|
||||||
|
|
||||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||||
<div class="container mx-auto text-center">
|
<div class="container mx-auto text-center">
|
||||||
<p>© 2025 My Awesome Library. All rights reserved.</p>
|
<p>© 2025 LiB Library. All rights reserved.</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,21 +1,119 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %} {% block
|
{% extends "base.html" %} {% block content %}
|
||||||
content %}
|
<div class="container mx-auto p-4 max-w-4xl">
|
||||||
<div class="flex flex-1 mt-4 p-4">
|
<div id="book-card" class="bg-white rounded-lg shadow-md p-6">
|
||||||
<main class="flex-1 max-w-4xl mx-auto">
|
<a
|
||||||
<div id="book-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
href="/books"
|
||||||
</div>
|
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors mb-4"
|
||||||
<div id="authors-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
>
|
||||||
<h2 class="text-xl font-semibold mb-4">Авторы</h2>
|
<svg
|
||||||
<div id="authors-container">
|
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="M15 19l-7-7 7-7"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
Вернуться к списку книг
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
id="book-loader"
|
||||||
|
class="flex flex-col md:flex-row items-start animate-pulse"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"
|
||||||
|
></div>
|
||||||
|
<div class="flex-1 w-full">
|
||||||
|
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||||
|
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||||
|
<div class="space-y-2 mb-6">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="genres-section" class="bg-white p-6 rounded-lg shadow-md">
|
<div
|
||||||
<h2 class="text-xl font-semibold mb-4">Жанры</h2>
|
id="book-content"
|
||||||
<div id="genres-container">
|
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"
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-16 h-16 text-white"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="1.5"
|
||||||
|
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 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>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<h1
|
||||||
|
id="book-title"
|
||||||
|
class="text-3xl font-bold text-gray-900"
|
||||||
|
></h1>
|
||||||
|
<span
|
||||||
|
id="book-id"
|
||||||
|
class="text-sm text-gray-500 ml-4 px-2 py-1 border border-gray-300 rounded-lg"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
id="book-authors-text"
|
||||||
|
class="text-lg text-gray-600 mb-4"
|
||||||
|
></p>
|
||||||
|
<div class="prose prose-gray max-w-none mb-6">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Жанры
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
id="genres-container"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Авторы
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
id="authors-container"
|
||||||
|
class="flex flex-wrap gap-3"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="text/javascript" src="/static/book.js"></script>
|
<script src="/static/book.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,79 +1,129 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Главная{% endblock %} {% block
|
{% extends "base.html" %} {% block content %}
|
||||||
content %}
|
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
|
||||||
<div class="flex flex-1 mt-4 p-4">
|
<aside class="w-full md:w-1/4">
|
||||||
<aside
|
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||||
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
|
<h2 class="text-xl font-bold mb-4">Поиск</h2>
|
||||||
>
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Поиск</h2>
|
|
||||||
<div class="relative mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="book-search-input"
|
|
||||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
|
||||||
placeholder="Поиск книг (мин. 3 символа)..."
|
|
||||||
minlength="3"
|
|
||||||
maxlength="50"
|
|
||||||
/>
|
|
||||||
<svg
|
|
||||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-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"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
|
|
||||||
<div class="mb-4">
|
|
||||||
<h3 class="font-medium mb-2">Авторы</h3>
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div
|
<input
|
||||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white min-h-[42px]"
|
type="text"
|
||||||
id="selected-authors-container"
|
id="book-search-input"
|
||||||
|
placeholder="Название книги..."
|
||||||
|
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"
|
||||||
>
|
>
|
||||||
<input
|
<path
|
||||||
type="text"
|
stroke-linecap="round"
|
||||||
id="author-search-input"
|
stroke-linejoin="round"
|
||||||
class="flex-grow outline-none bg-transparent min-w-[100px]"
|
stroke-width="2"
|
||||||
placeholder="Начните вводить..."
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
/>
|
></path>
|
||||||
</div>
|
</svg>
|
||||||
|
</div>
|
||||||
|
</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]"
|
||||||
|
></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
|
<div
|
||||||
id="author-dropdown"
|
id="author-dropdown"
|
||||||
class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 hidden max-h-60 overflow-y-auto shadow-lg"
|
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>
|
</div>
|
||||||
<div class="mb-4">
|
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||||
<h3 class="font-medium mb-2">Жанры</h3>
|
<h2 class="text-xl font-bold mb-4">Жанры</h2>
|
||||||
<ul id="genres-list" class="max-h-60 overflow-y-auto"></ul>
|
<ul
|
||||||
|
id="genres-list"
|
||||||
|
class="space-y-2 max-h-60 overflow-y-auto text-sm text-gray-700"
|
||||||
|
></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="apply-filters-btn"
|
id="apply-filters-btn"
|
||||||
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 mb-2"
|
class="w-full bg-gray-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-gray-700 transition mb-2"
|
||||||
>
|
>
|
||||||
Применить фильтры
|
Применить
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
id="reset-filters-btn"
|
id="reset-filters-btn"
|
||||||
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200"
|
class="w-full bg-white text-gray-600 border border-gray-300 font-bold py-2 px-4 rounded-lg hover:bg-gray-50 transition"
|
||||||
>
|
>
|
||||||
Сбросить фильтры
|
Сбросить
|
||||||
</button>
|
</button>
|
||||||
<div
|
|
||||||
id="results-counter"
|
|
||||||
class="mt-4 text-center text-sm text-gray-500"
|
|
||||||
></div>
|
|
||||||
</aside>
|
</aside>
|
||||||
<main class="flex-1">
|
<main class="w-full md:w-3/4">
|
||||||
<div id="books-container"></div>
|
<div id="books-container" class="grid grid-cols-1 gap-4"></div>
|
||||||
|
<div id="pagination-container"></div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<template id="book-card-template">
|
||||||
|
<div
|
||||||
|
class="bg-white p-4 rounded-lg shadow-md mb-4 hover:shadow-lg transition-shadow duration-200 cursor-pointer book-card"
|
||||||
|
>
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex justify-between items-center gap-2 mb-1">
|
||||||
|
<h3
|
||||||
|
class="book-title text-lg font-bold text-gray-900 hover:text-gray-400 transition-colors"
|
||||||
|
></h3>
|
||||||
|
<span
|
||||||
|
class="book-status inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 mb-2">
|
||||||
|
<span class="font-medium">Авторы:</span>
|
||||||
|
<span class="book-authors"></span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="book-desc text-gray-700 text-sm mb-2 line-clamp-3"
|
||||||
|
></p>
|
||||||
|
<div class="book-genres flex flex-wrap gap-1"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template id="genre-badge-template">
|
||||||
|
<span
|
||||||
|
class="inline-block bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full"
|
||||||
|
></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 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>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-2">Книги не найдены</h3>
|
||||||
|
<p class="text-gray-500">
|
||||||
|
Попробуйте изменить параметры поиска или фильтры
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="text/javascript" src="/static/books.js"></script>
|
<script src="/static/books.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,69 +1,159 @@
|
|||||||
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %} {% block
|
{% extends "base.html" %} {% block content %}
|
||||||
content %}
|
<div
|
||||||
|
class="container mx-auto p-4 max-w-2xl"
|
||||||
<div class="flex flex-1 mt-4 p-4">
|
x-data="{ showPasswordModal: false }"
|
||||||
<main class="flex-1 max-w-2xl mx-auto">
|
@close-modal.window="showPasswordModal = false"
|
||||||
<div id="profile-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
>
|
||||||
|
<div id="profile-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
|
<div class="animate-pulse flex items-center">
|
||||||
|
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
|
||||||
|
<div class="h-6 bg-gray-200 w-48 rounded"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="account-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
</div>
|
||||||
<h2 class="text-xl font-semibold mb-4">Информация об аккаунте</h2>
|
<div
|
||||||
<div id="account-container">
|
id="account-section"
|
||||||
</div>
|
class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
|
||||||
</div>
|
>
|
||||||
<div id="roles-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
<h2 class="text-xl font-bold mb-4 border-b pb-2">Информация</h2>
|
||||||
<h2 class="text-xl font-semibold mb-4">Роли и права</h2>
|
<div id="account-info" class="space-y-4"></div>
|
||||||
<div id="roles-container">
|
</div>
|
||||||
</div>
|
<div
|
||||||
</div>
|
id="roles-section"
|
||||||
<div id="actions-section" class="bg-white p-6 rounded-lg shadow-md">
|
class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
|
||||||
<h2 class="text-xl font-semibold mb-4">Действия</h2>
|
>
|
||||||
<div id="actions-container">
|
<h2 class="text-xl font-bold mb-4 border-b pb-2">Роли</h2>
|
||||||
</div>
|
<div id="roles-container" class="space-y-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||||
</div>
|
<div class="space-y-3">
|
||||||
<div id="password-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<button
|
||||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md mx-4">
|
@click="showPasswordModal = true"
|
||||||
<div class="flex justify-between items-center mb-4">
|
class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
<h3 class="text-xl font-semibold">Смена пароля</h3>
|
>
|
||||||
<button id="close-password-modal" class="text-gray-400 hover:text-gray-600">
|
<span class="text-gray-700 font-medium">Сменить пароль</span>
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
class="w-5 h-5 text-gray-400"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick="Auth.logout()"
|
||||||
|
class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span class="text-red-700 font-medium">Выйти из аккаунта</span>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 text-red-400"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form id="password-form">
|
</div>
|
||||||
<div class="mb-4">
|
<div
|
||||||
<label class="block text-gray-700 text-sm font-medium mb-2">Текущий пароль</label>
|
x-show="showPasswordModal"
|
||||||
<input type="password" id="current-password"
|
class="fixed inset-0 z-50 overflow-y-auto"
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
style="display: none"
|
||||||
required minlength="6">
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
x-show="showPasswordModal"
|
||||||
|
x-transition.opacity
|
||||||
|
class="fixed inset-0 transition-opacity"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 bg-gray-500 opacity-75"
|
||||||
|
@click="showPasswordModal = false"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-4">
|
<div
|
||||||
<label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label>
|
x-show="showPasswordModal"
|
||||||
<input type="password" id="new-password"
|
x-transition.scale
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
|
||||||
required minlength="6">
|
>
|
||||||
|
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<h3
|
||||||
|
class="text-lg leading-6 font-medium text-gray-900 mb-4"
|
||||||
|
>
|
||||||
|
Смена пароля
|
||||||
|
</h3>
|
||||||
|
<form id="change-password-form">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="block text-gray-700 text-sm font-bold mb-2"
|
||||||
|
>Текущий пароль</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="current-password"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="block text-gray-700 text-sm font-bold mb-2"
|
||||||
|
>Новый пароль</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="new-password"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label
|
||||||
|
class="block text-gray-700 text-sm font-bold mb-2"
|
||||||
|
>Подтвердите пароль</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm-password"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="submit-password-btn"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
|
>
|
||||||
|
Сменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="showPasswordModal = false"
|
||||||
|
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-6">
|
</div>
|
||||||
<label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите новый пароль</label>
|
|
||||||
<input type="password" id="confirm-password"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
|
||||||
required minlength="6">
|
|
||||||
</div>
|
|
||||||
<div id="password-error" class="mb-4 text-red-600 text-sm hidden"></div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button type="submit" class="flex-1 bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition-colors">
|
|
||||||
Сменить пароль
|
|
||||||
</button>
|
|
||||||
<button type="button" id="cancel-password" class="flex-1 bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script type="text/javascript" src="/static/profile.js"></script>
|
<script src="/static/profile.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""role payroll
|
||||||
|
|
||||||
|
Revision ID: a8e40ab24138
|
||||||
|
Revises: 02ed6e775351
|
||||||
|
Create Date: 2025-12-20 13:44:13.807704
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
import sqlmodel
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'a8e40ab24138'
|
||||||
|
down_revision: Union[str, None] = '02ed6e775351'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.alter_column('book', 'status',
|
||||||
|
existing_type=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
|
||||||
|
type_=sa.String(),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'active'::bookstatus"))
|
||||||
|
op.add_column('roles', sa.Column('payroll', sa.Integer(), nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('roles', 'payroll')
|
||||||
|
op.alter_column('book', 'status',
|
||||||
|
existing_type=sa.String(),
|
||||||
|
type_=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
|
||||||
|
existing_nullable=False,
|
||||||
|
existing_server_default=sa.text("'active'::bookstatus"))
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user