Добавление авторизации и улучшение качества кода

This commit is contained in:
2025-12-19 12:35:51 +03:00
parent 16a043843a
commit f6ac03a869
11 changed files with 836 additions and 164 deletions
+4
View File
@@ -1,9 +1,11 @@
"""Основной модуль""" """Основной модуль"""
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from .routers import api_router from .routers import api_router
from .settings import engine, get_app from .settings import engine, get_app
@@ -29,3 +31,5 @@ async def lifespan(app: FastAPI):
# Подключение маршрутов # Подключение маршрутов
app.include_router(api_router) app.include_router(api_router)
static_path = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=static_path), name="static")
+7 -13
View File
@@ -31,7 +31,13 @@ def get_info(app) -> Dict:
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def root(request: Request, app=Depends(get_app)): async def root(request: Request, app=Depends(get_app)):
"""Эндпоинт главной страницы""" """Эндпоинт главной страницы"""
return templates.TemplateResponse(request, "index.html", get_info(app)) return RedirectResponse("/books")
@router.get("/books", include_in_schema=False)
async def books(request: Request, app=Depends(get_app)):
"""Эндпоинт страницы выбора книг"""
return templates.TemplateResponse(request, "books.html", get_info(app))
@router.get("/auth", include_in_schema=False) @router.get("/auth", include_in_schema=False)
@@ -61,18 +67,6 @@ async def favicon():
) )
@router.get("/static/{path:path}", include_in_schema=False)
async def serve_static(path: str):
"""Статические файлы"""
static_dir = Path(__file__).parent.parent / "static"
file_path = static_dir / path
if not file_path.is_file() or not file_path.is_relative_to(static_dir):
return JSONResponse(status_code=404, content={"error": "File not found"})
return FileResponse(file_path)
@router.get( @router.get(
"/api/info", "/api/info",
summary="Информация о сервисе", summary="Информация о сервисе",
+287
View File
@@ -0,0 +1,287 @@
$(function () {
const $loginTab = $("#login-tab");
const $registerTab = $("#register-tab");
const $loginForm = $("#login-form");
const $registerForm = $("#register-form");
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
const $menuContainer = $("#user-menu-container");
function switchToLogin() {
$loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
$registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
$loginForm.removeClass("hidden"); $registerForm.addClass("hidden");
history.replaceState(null, "", "/auth#login");
}
function switchToRegister() {
$registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
$loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
$registerForm.removeClass("hidden"); $loginForm.addClass("hidden");
history.replaceState(null, "", "/auth#register");
}
$loginTab.on("click", switchToLogin);
$registerTab.on("click", switchToRegister);
$("body").on("click", ".toggle-password", function () {
const $btn = $(this);
const $input = $btn.siblings("input");
const $eyeOpen = $btn.find(".eye-open");
const $eyeClosed = $btn.find(".eye-closed");
if ($input.attr("type") === "password") {
$input.attr("type", "text");
$eyeOpen.addClass("hidden");
$eyeClosed.removeClass("hidden");
} else {
$input.attr("type", "password");
$eyeOpen.removeClass("hidden");
$eyeClosed.addClass("hidden");
}
});
$("#register-password").on("input", function () {
const password = $(this).val();
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const levels = [
{ width: "0%", color: "", text: "" },
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
{ width: "100%", color: "bg-green-500", text: "Отличный" },
];
const level = levels[strength];
const $bar = $("#password-strength-bar");
$bar.css("width", level.width);
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
$("#password-strength-text").text(level.text);
checkPasswordMatch();
});
function checkPasswordMatch() {
const password = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
const $error = $("#password-match-error");
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
} else {
$error.addClass("hidden");
return true;
}
}
$("#register-password-confirm").on("input", checkPasswordMatch);
$loginForm.on("submit", async function (event) {
event.preventDefault();
const $errorDiv = $("#login-error");
const $submitBtn = $("#login-submit");
const username = $("#login-username").val();
const password = $("#login-password").val();
$errorDiv.addClass("hidden");
$submitBtn.prop("disabled", true).text("Вход...");
try {
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
const response = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData.toString(),
});
const data = await response.json();
if (response.ok) {
localStorage.setItem("access_token", data.access_token);
if (data.refresh_token) {
localStorage.setItem("refresh_token", data.refresh_token);
}
window.location.href = "/";
} else {
$errorDiv.text(data.detail || "Неверное имя пользователя или пароль");
$errorDiv.removeClass("hidden");
$submitBtn.prop("disabled", false).text("Войти");
}
} catch (error) {
console.error("Login error:", error);
$errorDiv.text("Ошибка соединения с сервером");
$errorDiv.removeClass("hidden");
$submitBtn.prop("disabled", false).text("Войти");
}
});
$registerForm.on("submit", async function (event) {
event.preventDefault();
const $errorDiv = $("#register-error");
const $successDiv = $("#register-success");
const $submitBtn = $("#register-submit");
if (!checkPasswordMatch()) {
$errorDiv.text("Пароли не совпадают").removeClass("hidden");
return;
}
const userData = {
username: $("#register-username").val(),
email: $("#register-email").val(),
full_name: $("#register-fullname").val() || null,
password: $("#register-password").val(),
};
$errorDiv.addClass("hidden");
$successDiv.addClass("hidden");
$submitBtn.prop("disabled", true).text("Регистрация...");
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData),
});
const data = await response.json();
if (response.ok) {
$successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden");
setTimeout(() => {
$("#login-username").val(userData.username);
switchToLogin();
}, 2000);
} else {
let errorMessage = data.detail;
if (Array.isArray(data.detail)) {
errorMessage = data.detail.map((err) => err.msg).join(". ");
}
$errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden");
}
} catch (error) {
console.error("Register error:", error);
$errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
} finally {
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
}
});
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
});
$(document).on("click", function (e) {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
closeDropdown();
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && isDropdownOpen) {
closeDropdown();
}
});
$logoutBtn.on("click", function () {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/";
});
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; }
}
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();
});
}
});
@@ -1,7 +1,7 @@
$(document).ready(function () { $(document).ready(function () {
Promise.all([ Promise.all([
fetch("/authors").then((response) => response.json()), fetch("/api/authors").then((response) => response.json()),
fetch("/genres").then((response) => response.json()), fetch("/api/genres").then((response) => response.json()),
]) ])
.then(([authorsData, genresData]) => { .then(([authorsData, genresData]) => {
const $dropdown = $("#author-dropdown"); const $dropdown = $("#author-dropdown");
@@ -112,4 +112,106 @@ $(document).ready(function () {
renderSelectedAuthors(); renderSelectedAuthors();
} }
});
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();
});
}
});
+88
View File
@@ -75,3 +75,91 @@ nav ul li a {
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
transform: rotate(45deg); transform: rotate(45deg);
} }
.auth-tab {
font-family: "Dited", sans-serif;
letter-spacing: 1.5px;
}
input:focus {
transform: translateY(-1px);
}
button:disabled {
opacity: 0.7;
}
#login-form,
#register-form {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.flex.justify-center.gap-4 button:hover {
transform: translateY(-2px);
}
.shake {
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
#req-length, #req-upper, #req-lower, #req-digit {
transition: color 0.2s ease;
}
.req-icon {
font-size: 10px;
width: 12px;
display: inline-block;
}
#login-form:not(.hidden),
#register-form:not(.hidden) {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#login-tab, #register-tab {
font-family: "Dited", sans-serif;
letter-spacing: 1.5px;
cursor: pointer;
}
#user-dropdown {
animation: dropdownFade 0.1s ease-out;
}
@keyframes dropdownFade {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
#user-arrow.rotate-180 {
transform: rotate(180deg);
}
+193
View File
@@ -0,0 +1,193 @@
<!-- templates/auth.html -->
{% extends "base.html" %}
{% block title %}LiB - Авторизация{% endblock %}
{% block content %}
<div class="flex flex-1 items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="bg-white rounded-lg shadow-md overflow-hidden">
<div class="flex border-b border-gray-200">
<button type="button" id="login-tab" class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500">Вход</button>
<button type="button" id="register-tab" class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600">Регистрация</button>
</div>
<form id="login-form" class="p-6">
<div class="mb-4">
<label for="login-username" class="block text-sm font-medium text-gray-700 mb-2">Имя пользователя</label>
<input
type="text"
id="login-username"
name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите имя пользователя"
required
/>
</div>
<div class="mb-4">
<label for="login-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
<div class="relative">
<input
type="password"
id="login-password"
name="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Введите пароль"
required
/>
<button
type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
onclick="togglePassword(this)"
>
<svg class="eye-open 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 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
</svg>
</button>
</div>
</div>
<div class="flex items-center justify-between mb-6">
<label class="custom-checkbox flex items-center text-sm text-gray-600">
<input type="checkbox" id="remember-me" />
<span class="checkmark"></span>Запомнить меня
</label>
<a href="#" class="text-sm text-gray-500 hover:text-gray-700 transition">Забыли пароль?</a>
</div>
<div id="login-error" class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"></div>
<button
type="submit"
id="login-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
>Войти</button>
</form>
<form id="register-form" class="p-6 hidden" onsubmit="return handleRegister(event)">
<div class="mb-4">
<label for="register-username" class="block text-sm font-medium text-gray-700 mb-2">Имя пользователя</label>
<input
type="text"
id="register-username"
name="username"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Придумайте имя пользователя (мин. 3 символа)"
required
minlength="3"
maxlength="50"
/>
</div>
<div class="mb-4">
<label for="register-email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
<input
type="email"
id="register-email"
name="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="example@mail.com"
required
/>
</div>
<div class="mb-4">
<label for="register-fullname" class="block text-sm font-medium text-gray-700 mb-2">Полное имя <span class="text-gray-400">(необязательно)</span></label>
<input
type="text"
id="register-fullname"
name="full_name"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Иван Иванов"
maxlength="100"
/>
</div>
<div class="mb-4">
<label for="register-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
<div class="relative">
<input
type="password"
id="register-password"
name="password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Минимум 8 символов, A-Z, a-z, 0-9"
required
minlength="8"
maxlength="100"
/>
<button
type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
onclick="togglePassword(this)"
>
<svg class="eye-open 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 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
</svg>
</button>
</div>
<div class="mt-2">
<div class="h-1 w-full bg-gray-200 rounded-full overflow-hidden">
<div id="password-strength-bar" class="h-full w-0 transition-all duration-300"></div>
</div>
<p id="password-strength-text" class="text-xs mt-1 text-gray-500"></p>
</div>
</div>
<div class="mb-4">
<label for="register-password-confirm" class="block text-sm font-medium text-gray-700 mb-2">Подтвердите пароль</label>
<div class="relative">
<input
type="password"
id="register-password-confirm"
name="password_confirm"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
placeholder="Повторите пароль"
required
/>
<button
type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
onclick="togglePassword(this)"
>
<svg class="eye-open 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 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"></path>
</svg>
</button>
</div>
<p id="password-match-error" class="text-xs mt-1 text-red-500 hidden">Пароли не совпадают</p>
</div>
<div id="register-error" class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"></div>
<div id="register-success" class="hidden mb-4 p-3 bg-green-100 border border-green-300 text-green-700 rounded-lg text-sm"></div>
<button
type="submit"
id="register-submit"
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed"
>
Зарегистрироваться
</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="text/javascript" src="/static/auth.js"></script>
{% endblock %}
+77
View File
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="ru">
<head>
<title>{% block title %}LiB{% endblock %}</title>
<meta charset="UTF-8" />
<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 src="https://cdnjs.cloudflare.com/ajax/libs/cash/8.1.5/cash.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/static/styles.css" />
{% block extra_head %}{% endblock %}
</head>
<body class="flex flex-col min-h-screen bg-gray-100">
<header class="bg-gray-500 text-white p-4 shadow-md">
<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="/">
<img class="invert" src="/static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
<nav>
<ul class="flex space-x-4">
<li><a href="/" class="hover:text-gray-200">Главная</a></li>
<li><a href="/books" class="hover:text-gray-200">Книги</a></li>
<li><a href="/about" class="hover:text-gray-200">О нас</a></li>
<li><a href="/api" class="hover:text-gray-200">API</a></li>
</ul>
</nav>
<div class="relative" id="user-menu-area">
<a href="/auth" id="guest-link" class="block hover:opacity-80 transition"><img class="w-6 h-6 invert" src="/static/avatar.svg" /></a>
<button type="button" id="user-btn" class="hidden items-center gap-2 hover:opacity-80 transition focus:outline-none">
<img
id="user-avatar"
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"
alt="User Avatar"
/>
<svg id="user-arrow" class="w-4 h-4 transition-transform duration-200" 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>
<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">
<div class="px-4 py-3 border-b border-gray-200">
<p id="dropdown-name" class="text-sm font-semibold text-gray-900 truncate">Пользователь</p>
<p id="dropdown-username" class="text-sm text-gray-500 truncate">@username</p>
<p id="dropdown-email" class="text-xs text-gray-400 truncate mt-1">email@example.com</p>
</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 href="/my-books" 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="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>
<p class="text-gray-700 text-sm">Мои книги</p>
</a>
<div class="border-t border-gray-200 mt-1 pt-1">
<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">
<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>
<p class="text-gray-700 text-sm">Выйти</p>
</button>
</div>
</div>
</div>
</div>
</header>
{% block content %}{% endblock %}
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2025 My Awesome Library. All rights reserved.</p>
</div>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% block title %}LiB - Главная{% endblock %}
{% block content %}
<div class="flex flex-1 mt-4 p-4">
<aside 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>
<div class="mb-4">
<h3 class="font-medium mb-2">Авторы</h3>
<div class="relative">
<div class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white" id="selected-authors-container">
<input type="text" id="author-search-input" class="flex-grow outline-none bg-transparent" placeholder="Начните вводить..." />
</div>
<div 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"></div>
</div>
</div>
<div class="mb-4">
<h3 class="font-medium mb-2">Жанры</h3>
<ul id="genres-list"></ul>
</div>
<button class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200">
Применить фильтры
</button>
</aside>
<main class="flex-1">
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
<div>
<h3 class="text-lg font-bold mb-1">Product Title 1</h3>
<p class="text-gray-700 text-sm">
A short description of the product, highlighting its
key features and benefits.
</p>
</div>
<span class="text-lg font-semibold text-gray-600">$29.99</span>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
<div>
<h3 class="text-lg font-bold mb-1">Product Title 2</h3>
<p class="text-gray-700 text-sm">
Another great product with amazing features. You'll
love it!
</p>
</div>
<span class="text-lg font-semibold text-blue-600">$49.99</span>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start">
<div>
<h3 class="text-lg font-bold mb-1">Product Title 3</h3>
<p class="text-gray-700 text-sm">
This product is a must-have for every modern home.
High quality and durable.
</p>
</div>
<span class="text-lg font-semibold text-gray-600">$19.99</span>
</div>
</main>
</div>
{% endblock %}
{% block scripts %}
<script type="text/javascript" src="/static/books.js"></script>
{% endblock %}
-139
View File
@@ -1,139 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<title>LiB</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/cash-dom@1.4.0/dist/cash.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="static/styles.css" />
</head>
<body class="flex flex-col min-h-screen bg-gray-100">
<!-- Header -->
<header class="bg-gray-500 text-white p-4 shadow-md">
<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="/">
<img class="invert" src="static/logo.svg" />
<h1 class="text-2xl font-bold">LiB</h1>
</a>
<nav>
<ul class="flex space-x-4">
<li>
<a href="/" class="hover:text-gray-200">Home</a>
</li>
<li>
<a href="#" class="hover:text-gray-200">books</a>
</li>
<li>
<a href="#" class="hover:text-gray-200">About</a>
</li>
<li>
<a href="/api" class="hover:text-gray-200">API</a>
</li>
</ul>
</nav>
<img class="max-w-6 h-auto invert" src="static/avatar.svg" />
</div>
</header>
<!-- Main -->
<div class="flex flex-1 mt-4 p-4">
<aside
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>
<!-- Authors -->
<div class="mb-4">
<h3 class="font-medium mb-2">Авторы</h3>
<div class="relative">
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white"
id="selected-authors-container"
>
<input
type="text"
id="author-search-input"
class="flex-grow outline-none bg-transparent"
placeholder="Начните вводить..."
/>
</div>
<div
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"
></div>
</div>
</div>
<!-- Genres -->
<div class="mb-4">
<h3 class="font-medium mb-2">Жанры</h3>
<ul id="genres-list"></ul>
</div>
<!-- Apply -->
<button
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200"
>
Применить фильтры
</button>
</aside>
<!-- Main Area -->
<main class="flex-1">
<!-- Book Card 1 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 1</h3>
<p class="text-gray-700 text-sm">
A short description of the product, highlighting its
key features and benefits.
</p>
</div>
<span class="text-lg font-semibold text-gray-600"
>$29.99</span
>
</div>
<!-- Book Card 2 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 2</h3>
<p class="text-gray-700 text-sm">
Another great product with amazing features. You'll
love it!
</p>
</div>
<span class="text-lg font-semibold text-blue-600"
>$49.99</span
>
</div>
<!-- Book Card 3 -->
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 flex justify-between items-start"
>
<div>
<h3 class="text-lg font-bold mb-1">Product Title 3</h3>
<p class="text-gray-700 text-sm">
This product is a must-have for every modern home.
High quality and durable.
</p>
</div>
<span class="text-lg font-semibold text-gray-600"
>$19.99</span
>
</div>
</main>
</div>
<!-- Footer -->
<footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center">
<p>&copy; 2025 My Awesome Library. All rights reserved.</p>
</div>
</footer>
<script type="text/javascript" src="static/script.js"></script>
</body>
</html>
Generated
+8 -5
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. # This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
@@ -59,6 +59,7 @@ files = [
[package.dependencies] [package.dependencies]
idna = ">=2.8" idna = ">=2.8"
sniffio = ">=1.1" sniffio = ">=1.1"
typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""}
[package.extras] [package.extras]
doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"]
@@ -525,7 +526,7 @@ description = "Lightweight in-process concurrent programming"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main"]
markers = "python_version == \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")" markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"
files = [ files = [
{file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"}, {file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"},
{file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"}, {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"},
@@ -1471,6 +1472,7 @@ files = [
[package.dependencies] [package.dependencies]
pytest = ">=8.2,<10" pytest = ">=8.2,<10"
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
[package.extras] [package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
@@ -1855,11 +1857,12 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
markers = {dev = "python_version == \"3.12\""}
[[package]] [[package]]
name = "typing-inspection" name = "typing-inspection"
@@ -2243,5 +2246,5 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = "^3.13" python-versions = "^3.12"
content-hash = "2fdd550e819b1456733250a62196acb442127a296ff60e010c424ecbebec294e" content-hash = "a8d44f0decfa3ba437e998207c16ca7429ee42e930e8aa1d40f87231e71f219f"
+1 -4
View File
@@ -7,7 +7,7 @@ readme = "README.md"
packages = [{ include = "library_service" }] packages = [{ include = "library_service" }]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.13" python = "^3.12"
fastapi = { extras = ["all"], version = "^0.115.12" } fastapi = { extras = ["all"], version = "^0.115.12" }
psycopg2-binary = "^2.9.10" psycopg2-binary = "^2.9.10"
alembic = "^1.16.1" alembic = "^1.16.1"
@@ -28,9 +28,6 @@ isort = "^7.0.0"
pytest-asyncio = "^1.3.0" pytest-asyncio = "^1.3.0"
pylint = "^4.0.4" pylint = "^4.0.4"
[tool.poetry.requires-plugins]
poetry-plugin-export = ">=1.8"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"