mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 04:31:09 +00:00
Добавление catpcha при регистрации, фильтрация по количеству страниц
This commit is contained in:
+11
-2
@@ -1,4 +1,6 @@
|
||||
"""Основной модуль"""
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -7,12 +9,13 @@ from uuid import uuid4
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi import Request, Response
|
||||
from fastapi import FastAPI, Depends, Request, Response, status
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlmodel import Session
|
||||
|
||||
from library_service.auth import run_seeds
|
||||
from library_service.routers import api_router
|
||||
from library_service.services.captcha import limiter, cleanup_task, require_captcha
|
||||
from library_service.settings import (
|
||||
LOGGING_CONFIG,
|
||||
engine,
|
||||
@@ -20,6 +23,7 @@ from library_service.settings import (
|
||||
get_logger,
|
||||
)
|
||||
|
||||
|
||||
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
|
||||
|
||||
|
||||
@@ -47,6 +51,7 @@ async def lifespan(_):
|
||||
except Exception as e:
|
||||
logger.error(f"[-] Seeding failed: {e}")
|
||||
|
||||
asyncio.create_task(cleanup_task())
|
||||
logger.info("[+] Starting application...")
|
||||
yield # Обработка запросов
|
||||
logger.info("[+] Application shutdown")
|
||||
@@ -113,7 +118,10 @@ async def log_requests(request: Request, call_next):
|
||||
},
|
||||
exc_info=True,
|
||||
)
|
||||
return Response(status_code=500, content="Internal Server Error")
|
||||
return Response(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content="Internal Server Error",
|
||||
)
|
||||
|
||||
|
||||
# Подключение маршрутов
|
||||
@@ -127,6 +135,7 @@ app.mount(
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"library_service.main:app",
|
||||
host="0.0.0.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ from .books import router as books_router
|
||||
from .genres import router as genres_router
|
||||
from .loans import router as loans_router
|
||||
from .relationships import router as relationships_router
|
||||
from .cap import router as cap_router
|
||||
from .users import router as users_router
|
||||
from .misc import router as misc_router
|
||||
|
||||
@@ -22,5 +23,6 @@ api_router.include_router(authors_router, prefix="/api")
|
||||
api_router.include_router(books_router, prefix="/api")
|
||||
api_router.include_router(genres_router, prefix="/api")
|
||||
api_router.include_router(loans_router, prefix="/api")
|
||||
api_router.include_router(cap_router, prefix="/api")
|
||||
api_router.include_router(users_router, prefix="/api")
|
||||
api_router.include_router(relationships_router, prefix="/api")
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.services import require_captcha
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import (
|
||||
Token,
|
||||
@@ -63,7 +64,11 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
summary="Регистрация нового пользователя",
|
||||
description="Создает нового пользователя и возвращает резервные коды",
|
||||
)
|
||||
def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
def register(
|
||||
user_data: UserCreate,
|
||||
_=Depends(require_captcha),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Регистрирует нового пользователя в системе"""
|
||||
existing_user = session.exec(
|
||||
select(User).where(User.username == user_data.username)
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
"""Модуль работы с авторами"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, AuthorBookLink, Book
|
||||
from library_service.models.dto import (BookRead, AuthorWithBooks,
|
||||
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
|
||||
from library_service.models.dto import (
|
||||
BookRead,
|
||||
AuthorWithBooks,
|
||||
AuthorCreate,
|
||||
AuthorList,
|
||||
AuthorRead,
|
||||
AuthorUpdate,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
@@ -59,7 +66,9 @@ def get_author(
|
||||
"""Возвращает информацию об авторе и его книгах"""
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
|
||||
)
|
||||
|
||||
books = session.exec(
|
||||
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
|
||||
@@ -88,7 +97,9 @@ def update_author(
|
||||
"""Обновляет информацию об авторе"""
|
||||
db_author = session.get(Author, author_id)
|
||||
if not db_author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
|
||||
)
|
||||
|
||||
update_data = author.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
@@ -113,7 +124,9 @@ def delete_author(
|
||||
"""Удаляет автора из системы"""
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
|
||||
)
|
||||
|
||||
author_read = AuthorRead(**author.model_dump())
|
||||
session.delete(author)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
|
||||
from sqlmodel import Session, select, col, func
|
||||
|
||||
from library_service.auth import RequireStaff
|
||||
@@ -56,10 +56,16 @@ def close_active_loan(session: Session, book_id: int) -> None:
|
||||
def filter_books(
|
||||
session: Session = Depends(get_session),
|
||||
q: str | None = Query(None, max_length=50, description="Поиск"),
|
||||
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
||||
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
||||
min_page_count: int | None = Query(
|
||||
None, ge=0, description="Минимальное количество страниц"
|
||||
),
|
||||
max_page_count: int | None = Query(
|
||||
None, ge=0, description="Максимальное количество страниц"
|
||||
),
|
||||
author_ids: List[int] | None = Query(None, gt=0, description="Список ID авторов"),
|
||||
genre_ids: List[int] | None = Query(None, gt=0, description="Список ID жанров"),
|
||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
|
||||
size: int = Query(20, gt=0, le=100, description="Количество элементов на странице"),
|
||||
):
|
||||
"""Возвращает отфильтрованный список книг с пагинацией"""
|
||||
statement = select(Book).distinct()
|
||||
@@ -69,6 +75,12 @@ def filter_books(
|
||||
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
|
||||
)
|
||||
|
||||
if min_page_count:
|
||||
statement = statement.where(Book.page_count >= min_page_count)
|
||||
|
||||
if max_page_count:
|
||||
statement = statement.where(Book.page_count <= max_page_count)
|
||||
|
||||
if author_ids:
|
||||
statement = statement.join(AuthorBookLink).where(
|
||||
AuthorBookLink.author_id.in_( # ty: ignore[unresolved-attribute, unresolved-reference]
|
||||
@@ -149,7 +161,9 @@ def get_book(
|
||||
"""Возвращает информацию о книге с авторами и жанрами"""
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
authors = session.exec(
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
|
||||
@@ -185,12 +199,14 @@ def update_book(
|
||||
"""Обновляет информацию о книге"""
|
||||
db_book = session.get(Book, book_id)
|
||||
if not db_book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if book_update.status is not None:
|
||||
if book_update.status == BookStatus.BORROWED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Статус 'borrowed' устанавливается только через выдачу книги",
|
||||
)
|
||||
|
||||
@@ -226,7 +242,9 @@ def delete_book(
|
||||
"""Удаляет книгу из системы"""
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
book_read = BookRead(
|
||||
id=(book.id or 0),
|
||||
title=book.title,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from library_service.services.captcha import (
|
||||
limiter,
|
||||
get_ip,
|
||||
active_challenges,
|
||||
challenges_by_ip,
|
||||
MAX_CHALLENGES_PER_IP,
|
||||
MAX_TOTAL_CHALLENGES,
|
||||
CHALLENGE_TTL,
|
||||
REDEEM_TTL,
|
||||
prng,
|
||||
now_ms,
|
||||
redeem_tokens,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/cap", tags=["captcha"])
|
||||
|
||||
|
||||
@router.post("/challenge")
|
||||
@limiter.limit("15/minute")
|
||||
async def challenge(request: Request, ip: str = Depends(get_ip)):
|
||||
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
|
||||
)
|
||||
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
|
||||
)
|
||||
|
||||
token = secrets.token_hex(25)
|
||||
redeem = secrets.token_hex(25)
|
||||
expires = now_ms() + CHALLENGE_TTL
|
||||
|
||||
active_challenges[token] = {
|
||||
"c": 50,
|
||||
"s": 32,
|
||||
"d": 4,
|
||||
"expires": expires,
|
||||
"redeem_token": redeem,
|
||||
"ip": ip,
|
||||
}
|
||||
challenges_by_ip[ip] += 1
|
||||
|
||||
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
|
||||
|
||||
|
||||
@router.post("/redeem")
|
||||
@limiter.limit("30/minute")
|
||||
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
|
||||
token = payload.get("token")
|
||||
solutions = payload.get("solutions", [])
|
||||
|
||||
if token not in active_challenges:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
|
||||
)
|
||||
|
||||
ch = active_challenges.pop(token)
|
||||
challenges_by_ip[ch["ip"]] -= 1
|
||||
|
||||
if now_ms() > ch["expires"]:
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
|
||||
if len(solutions) < ch["c"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
|
||||
)
|
||||
|
||||
def verify(i: int) -> bool:
|
||||
salt = prng(f"{token}{i+1}", ch["s"])
|
||||
target = prng(f"{token}{i+1}d", ch["d"])
|
||||
h = hashlib.sha256((salt + str(solutions[i])).encode()).hexdigest()
|
||||
return h.startswith(target)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(asyncio.to_thread(verify, i) for i in range(ch["c"]))
|
||||
)
|
||||
if not all(results):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
|
||||
)
|
||||
|
||||
r_token = ch["redeem_token"]
|
||||
redeem_tokens[r_token] = now_ms() + REDEEM_TTL
|
||||
|
||||
resp = JSONResponse(
|
||||
{"success": True, "token": r_token, "expires": redeem_tokens[r_token]}
|
||||
)
|
||||
resp.set_cookie(
|
||||
key="capjs_token",
|
||||
value=r_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=REDEEM_TTL // 1000,
|
||||
)
|
||||
return resp
|
||||
@@ -1,10 +1,18 @@
|
||||
"""Модуль работы с жанрами"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.models.db import Book, Genre, GenreBookLink
|
||||
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
|
||||
from library_service.models.dto import (
|
||||
BookRead,
|
||||
GenreCreate,
|
||||
GenreList,
|
||||
GenreRead,
|
||||
GenreUpdate,
|
||||
GenreWithBooks,
|
||||
)
|
||||
from library_service.settings import get_session
|
||||
|
||||
|
||||
@@ -57,7 +65,9 @@ def get_genre(
|
||||
"""Возвращает информацию о жанре и книгах с ним"""
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
|
||||
)
|
||||
|
||||
books = session.exec(
|
||||
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
|
||||
@@ -86,7 +96,9 @@ def update_genre(
|
||||
"""Обновляет информацию о жанре"""
|
||||
db_genre = session.get(Genre, genre_id)
|
||||
if not db_genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
|
||||
)
|
||||
|
||||
update_data = genre.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
@@ -111,7 +123,9 @@ def delete_genre(
|
||||
"""Удаляет жанр из системы"""
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
|
||||
)
|
||||
|
||||
genre_read = GenreRead(**genre.model_dump())
|
||||
session.delete(genre)
|
||||
|
||||
@@ -40,17 +40,21 @@ def create_loan(
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Book is not available for loan (status: {book.status})",
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
db_loan = BookUserLink(
|
||||
book_id=loan.book_id,
|
||||
@@ -248,7 +252,9 @@ def get_loan(
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
|
||||
)
|
||||
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
@@ -275,7 +281,9 @@ def update_loan(
|
||||
"""Обновляет информацию о выдаче"""
|
||||
db_loan = session.get(BookUserLink, loan_id)
|
||||
if not db_loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
|
||||
)
|
||||
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
@@ -287,7 +295,9 @@ def update_loan(
|
||||
|
||||
book = session.get(Book, db_loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if loan_update.user_id is not None:
|
||||
if not is_staff:
|
||||
@@ -297,7 +307,9 @@ def update_loan(
|
||||
)
|
||||
new_user = session.get(User, loan_update.user_id)
|
||||
if not new_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
db_loan.user_id = loan_update.user_id
|
||||
|
||||
if loan_update.due_date is not None:
|
||||
@@ -305,7 +317,10 @@ def update_loan(
|
||||
|
||||
if loan_update.returned_at is not None:
|
||||
if db_loan.returned_at is not None:
|
||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Loan is already returned",
|
||||
)
|
||||
db_loan.returned_at = loan_update.returned_at
|
||||
book.status = BookStatus.ACTIVE
|
||||
|
||||
@@ -331,18 +346,24 @@ def confirm_loan(
|
||||
"""Подтверждает бронирование и меняет статус книги на BORROWED"""
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
|
||||
)
|
||||
|
||||
if loan.returned_at:
|
||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
|
||||
)
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Cannot confirm loan for book with status: {book.status}",
|
||||
)
|
||||
|
||||
@@ -370,10 +391,14 @@ def return_loan(
|
||||
"""Возвращает книгу и закрывает выдачу"""
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
|
||||
)
|
||||
|
||||
if loan.returned_at:
|
||||
raise HTTPException(status_code=400, detail="Loan is already returned")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
|
||||
)
|
||||
|
||||
loan.returned_at = datetime.now(timezone.utc)
|
||||
|
||||
@@ -403,7 +428,9 @@ def delete_loan(
|
||||
"""Удаляет выдачу или бронирование (только для RESERVED статуса)"""
|
||||
loan = session.get(BookUserLink, loan_id)
|
||||
if not loan:
|
||||
raise HTTPException(status_code=404, detail="Loan not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Loan not found"
|
||||
)
|
||||
|
||||
is_staff = is_user_staff(current_user)
|
||||
|
||||
@@ -417,7 +444,7 @@ def delete_loan(
|
||||
|
||||
if book and book.status != BookStatus.RESERVED:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Can only delete reservations. Use update endpoint to return borrowed books",
|
||||
)
|
||||
|
||||
@@ -471,16 +498,21 @@ def issue_book_directly(
|
||||
"""Выдает книгу напрямую без бронирования (только для администраторов)"""
|
||||
book = session.get(Book, loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Book is not available (status: {book.status})"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Book is not available (status: {book.status})",
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
|
||||
db_loan = BookUserLink(
|
||||
book_id=loan.book_id,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Модуль работы со связями"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireStaff
|
||||
@@ -17,7 +18,9 @@ def check_entity_exists(session, model, entity_id, entity_name):
|
||||
"""Проверяет существование сущности в базе данных"""
|
||||
entity = session.get(model, entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"{entity_name} not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"{entity_name} not found"
|
||||
)
|
||||
return entity
|
||||
|
||||
|
||||
@@ -30,7 +33,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
|
||||
).first()
|
||||
|
||||
if existing_link:
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
link = link_model(**{field1: id1, field2: id2})
|
||||
session.add(link)
|
||||
@@ -48,7 +51,9 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
|
||||
).first()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Relationship not found"
|
||||
)
|
||||
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
@@ -56,21 +61,22 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
|
||||
|
||||
|
||||
def get_related(
|
||||
session,
|
||||
main_model,
|
||||
main_id,
|
||||
main_name,
|
||||
related_model,
|
||||
link_model,
|
||||
link_main_field,
|
||||
link_related_field,
|
||||
read_model
|
||||
):
|
||||
session,
|
||||
main_model,
|
||||
main_id,
|
||||
main_name,
|
||||
related_model,
|
||||
link_model,
|
||||
link_main_field,
|
||||
link_related_field,
|
||||
read_model,
|
||||
):
|
||||
"""Возвращает список связанных сущностей"""
|
||||
check_entity_exists(session, main_model, main_id, main_name)
|
||||
|
||||
related = session.exec(
|
||||
select(related_model).join(link_model)
|
||||
select(related_model)
|
||||
.join(link_model)
|
||||
.where(getattr(link_model, link_main_field) == main_id)
|
||||
).all()
|
||||
|
||||
@@ -93,8 +99,15 @@ def add_author_to_book(
|
||||
check_entity_exists(session, Author, author_id, "Author")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
return add_relationship(session, AuthorBookLink,
|
||||
author_id, "author_id", book_id, "book_id", "Relationship already exists")
|
||||
return add_relationship(
|
||||
session,
|
||||
AuthorBookLink,
|
||||
author_id,
|
||||
"author_id",
|
||||
book_id,
|
||||
"book_id",
|
||||
"Relationship already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -110,8 +123,9 @@ def remove_author_from_book(
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Удаляет связь между автором и книгой"""
|
||||
return remove_relationship(session, AuthorBookLink,
|
||||
author_id, "author_id", book_id, "book_id")
|
||||
return remove_relationship(
|
||||
session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -122,9 +136,17 @@ def remove_author_from_book(
|
||||
)
|
||||
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
|
||||
"""Возвращает список книг автора"""
|
||||
return get_related(session,
|
||||
Author, author_id, "Author", Book,
|
||||
AuthorBookLink, "author_id", "book_id", BookRead)
|
||||
return get_related(
|
||||
session,
|
||||
Author,
|
||||
author_id,
|
||||
"Author",
|
||||
Book,
|
||||
AuthorBookLink,
|
||||
"author_id",
|
||||
"book_id",
|
||||
BookRead,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -135,9 +157,17 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
|
||||
)
|
||||
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
"""Возвращает список авторов книги"""
|
||||
return get_related(session,
|
||||
Book, book_id, "Book", Author,
|
||||
AuthorBookLink, "book_id", "author_id", AuthorRead)
|
||||
return get_related(
|
||||
session,
|
||||
Book,
|
||||
book_id,
|
||||
"Book",
|
||||
Author,
|
||||
AuthorBookLink,
|
||||
"book_id",
|
||||
"author_id",
|
||||
AuthorRead,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -156,8 +186,15 @@ def add_genre_to_book(
|
||||
check_entity_exists(session, Genre, genre_id, "Genre")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
return add_relationship(session, GenreBookLink,
|
||||
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
|
||||
return add_relationship(
|
||||
session,
|
||||
GenreBookLink,
|
||||
genre_id,
|
||||
"genre_id",
|
||||
book_id,
|
||||
"book_id",
|
||||
"Relationship already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -173,8 +210,9 @@ def remove_genre_from_book(
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Удаляет связь между жанром и книгой"""
|
||||
return remove_relationship(session, GenreBookLink,
|
||||
genre_id, "genre_id", book_id, "book_id")
|
||||
return remove_relationship(
|
||||
session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -185,9 +223,17 @@ def remove_genre_from_book(
|
||||
)
|
||||
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
"""Возвращает список книг в жанре"""
|
||||
return get_related(session,
|
||||
Genre, genre_id, "Genre", Book,
|
||||
GenreBookLink, "genre_id", "book_id", BookRead)
|
||||
return get_related(
|
||||
session,
|
||||
Genre,
|
||||
genre_id,
|
||||
"Genre",
|
||||
Book,
|
||||
GenreBookLink,
|
||||
"genre_id",
|
||||
"book_id",
|
||||
BookRead,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -198,6 +244,14 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
)
|
||||
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
"""Возвращает список жанров книги"""
|
||||
return get_related(session,
|
||||
Book, book_id, "Book", Genre,
|
||||
GenreBookLink, "book_id", "genre_id", GenreRead)
|
||||
return get_related(
|
||||
session,
|
||||
Book,
|
||||
book_id,
|
||||
"Book",
|
||||
Genre,
|
||||
GenreBookLink,
|
||||
"book_id",
|
||||
"genre_id",
|
||||
GenreRead,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
from .captcha import (
|
||||
limiter,
|
||||
cleanup_task,
|
||||
get_ip,
|
||||
require_captcha,
|
||||
active_challenges,
|
||||
redeem_tokens,
|
||||
challenges_by_ip,
|
||||
MAX_CHALLENGES_PER_IP,
|
||||
MAX_TOTAL_CHALLENGES,
|
||||
CHALLENGE_TTL,
|
||||
REDEEM_TTL,
|
||||
prng,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"limiter",
|
||||
"cleanup_task",
|
||||
"get_ip",
|
||||
"require_captcha",
|
||||
"active_challenges",
|
||||
"redeem_tokens",
|
||||
"challenges_by_ip",
|
||||
"MAX_CHALLENGES_PER_IP",
|
||||
"MAX_TOTAL_CHALLENGES",
|
||||
"CHALLENGE_TTL",
|
||||
"REDEEM_TTL",
|
||||
"prng",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
import os
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import Request, HTTPException, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
CLEANUP_INTERVAL = int(os.getenv("CAP_CLEANUP_INTERVAL", "10"))
|
||||
REDEEM_TTL = int(os.getenv("CAP_REDEEM_TTL_SECONDS", "180")) * 1000
|
||||
CHALLENGE_TTL = int(os.getenv("CAP_CHALLENGE_TTL_SECONDS", "120")) * 1000
|
||||
MAX_CHALLENGES_PER_IP = int(os.getenv("CAP_MAX_CHALLENGES_PER_IP", "12"))
|
||||
MAX_TOTAL_CHALLENGES = int(os.getenv("CAP_MAX_TOTAL_CHALLENGES", "1000"))
|
||||
|
||||
active_challenges: dict[str, dict] = {}
|
||||
redeem_tokens: dict[str, int] = {}
|
||||
challenges_by_ip: defaultdict[str, int] = defaultdict(int)
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def fnv1a_utf16(seed: str) -> int:
|
||||
h = 2166136261
|
||||
data = seed.encode("utf-16le")
|
||||
i = 0
|
||||
while i < len(data):
|
||||
unit = data[i] + (data[i + 1] << 8)
|
||||
h ^= unit
|
||||
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF
|
||||
i += 2
|
||||
return h
|
||||
|
||||
|
||||
def prng(seed: str, length: int) -> str:
|
||||
state = fnv1a_utf16(seed)
|
||||
out = ""
|
||||
while len(out) < length:
|
||||
state ^= (state << 13) & 0xFFFFFFFF
|
||||
state ^= state >> 17
|
||||
state ^= (state << 5) & 0xFFFFFFFF
|
||||
out += f"{state & 0xFFFFFFFF:08x}"
|
||||
return out[:length]
|
||||
|
||||
|
||||
async def cleanup_task():
|
||||
while True:
|
||||
now = now_ms()
|
||||
for token, data in list(active_challenges.items()):
|
||||
if data["expires"] < now:
|
||||
challenges_by_ip[data["ip"]] -= 1
|
||||
del active_challenges[token]
|
||||
for token, exp in list(redeem_tokens.items()):
|
||||
if exp < now:
|
||||
del redeem_tokens[token]
|
||||
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||
|
||||
|
||||
def get_ip(request: Request) -> str:
|
||||
return get_remote_address(request)
|
||||
|
||||
|
||||
async def require_captcha(request: Request):
|
||||
token = request.cookies.get("capjs_token")
|
||||
if not token or token not in redeem_tokens or redeem_tokens[token] < now_ms():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail={"error": "captcha_required"}
|
||||
)
|
||||
del redeem_tokens[token]
|
||||
@@ -61,6 +61,7 @@ OPENAPI_TAGS = [
|
||||
{"name": "loans", "description": "Действия с выдачами."},
|
||||
{"name": "relations", "description": "Действия со связями."},
|
||||
{"name": "users", "description": "Действия с пользователями."},
|
||||
{"name": "captcha", "description": "Создание и проверка cap.js каптчи."},
|
||||
{"name": "misc", "description": "Прочие."},
|
||||
]
|
||||
|
||||
|
||||
+274
-273
@@ -1,6 +1,70 @@
|
||||
$(() => {
|
||||
const PARTIAL_TOKEN_KEY = "partial_token";
|
||||
const PARTIAL_USERNAME_KEY = "partial_username";
|
||||
const SELECTORS = {
|
||||
loginForm: "#login-form",
|
||||
registerForm: "#register-form",
|
||||
resetForm: "#reset-password-form",
|
||||
loginTab: "#login-tab",
|
||||
registerTab: "#register-tab",
|
||||
forgotBtn: "#forgot-password-btn",
|
||||
backToLoginBtn: "#back-to-login-btn",
|
||||
backToCredentialsBtn: "#back-to-credentials-btn",
|
||||
submitLogin: "#login-submit",
|
||||
submitRegister: "#register-submit",
|
||||
submitReset: "#reset-submit",
|
||||
usernameLogin: "#login-username",
|
||||
passwordLogin: "#login-password",
|
||||
totpInput: "#login-totp",
|
||||
rememberMe: "#remember-me",
|
||||
credentialsSection: "#credentials-section",
|
||||
totpSection: "#totp-section",
|
||||
registerUsername: "#register-username",
|
||||
registerEmail: "#register-email",
|
||||
registerFullname: "#register-fullname",
|
||||
registerPassword: "#register-password",
|
||||
registerConfirm: "#register-password-confirm",
|
||||
passwordStrengthBar: "#password-strength-bar",
|
||||
passwordStrengthText: "#password-strength-text",
|
||||
passwordMatchError: "#password-match-error",
|
||||
resetUsername: "#reset-username",
|
||||
resetCode: "#reset-recovery-code",
|
||||
resetNewPassword: "#reset-new-password",
|
||||
resetConfirmPassword: "#reset-confirm-password",
|
||||
resetMatchError: "#reset-password-match-error",
|
||||
recoveryModal: "#recovery-codes-modal",
|
||||
recoveryList: "#recovery-codes-list",
|
||||
codesSavedCheckbox: "#codes-saved-checkbox",
|
||||
closeRecoveryBtn: "#close-recovery-modal-btn",
|
||||
copyCodesBtn: "#copy-codes-btn",
|
||||
downloadCodesBtn: "#download-codes-btn",
|
||||
gotoLoginAfterReset: "#goto-login-after-reset",
|
||||
capWidget: "#cap",
|
||||
lockProgressCircle: "#lock-progress-circle",
|
||||
};
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
partialToken: "partial_token",
|
||||
partialUsername: "partial_username",
|
||||
};
|
||||
|
||||
const TEXTS = {
|
||||
login: "Войти",
|
||||
confirm: "Подтвердить",
|
||||
checking: "Проверка...",
|
||||
registering: "Регистрация...",
|
||||
resetting: "Сброс...",
|
||||
enterTotp: "Введите код из приложения аутентификатора",
|
||||
sessionExpired: "Время сессии истекло. Пожалуйста, войдите заново.",
|
||||
invalidCode: "Неверный код",
|
||||
passwordsNotMatch: "Пароли не совпадают",
|
||||
captchaRequired: "Пожалуйста, пройдите проверку Captcha",
|
||||
registrationSuccess: "Регистрация успешна! Войдите в систему.",
|
||||
codesCopied: "Коды скопированы в буфер обмена",
|
||||
codesDownloaded: "Файл с кодами скачан",
|
||||
passwordResetSuccess: "Пароль успешно изменён!",
|
||||
invalidRecoveryCode: "Неверный формат резервного кода",
|
||||
passwordTooShort: "Пароль должен содержать минимум 8 символов",
|
||||
};
|
||||
|
||||
const TOTP_PERIOD = 30;
|
||||
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38;
|
||||
|
||||
@@ -14,96 +78,71 @@ $(() => {
|
||||
let registeredRecoveryCodes = [];
|
||||
let totpAnimationFrame = null;
|
||||
|
||||
function getTotpProgress() {
|
||||
const getTotpProgress = () => {
|
||||
const now = Date.now() / 1000;
|
||||
const elapsed = now % TOTP_PERIOD;
|
||||
return elapsed / TOTP_PERIOD;
|
||||
}
|
||||
};
|
||||
|
||||
function updateTotpTimer() {
|
||||
const circle = document.getElementById("lock-progress-circle");
|
||||
const updateTotpTimer = () => {
|
||||
const circle = $(SELECTORS.lockProgressCircle).get(0);
|
||||
if (!circle) return;
|
||||
|
||||
const progress = getTotpProgress();
|
||||
const offset = CIRCLE_CIRCUMFERENCE * (1 - progress);
|
||||
circle.style.strokeDashoffset = offset;
|
||||
|
||||
totpAnimationFrame = requestAnimationFrame(updateTotpTimer);
|
||||
}
|
||||
};
|
||||
|
||||
function startTotpTimer() {
|
||||
const startTotpTimer = () => {
|
||||
stopTotpTimer();
|
||||
updateTotpTimer();
|
||||
}
|
||||
};
|
||||
|
||||
function stopTotpTimer() {
|
||||
const stopTotpTimer = () => {
|
||||
if (totpAnimationFrame) {
|
||||
cancelAnimationFrame(totpAnimationFrame);
|
||||
totpAnimationFrame = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function resetCircle() {
|
||||
const circle = document.getElementById("lock-progress-circle");
|
||||
if (circle) {
|
||||
circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
|
||||
}
|
||||
}
|
||||
const resetCircle = () => {
|
||||
const circle = $(SELECTORS.lockProgressCircle).get(0);
|
||||
if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
|
||||
};
|
||||
|
||||
function initLoginState() {
|
||||
const savedToken = sessionStorage.getItem(PARTIAL_TOKEN_KEY);
|
||||
const savedUsername = sessionStorage.getItem(PARTIAL_USERNAME_KEY);
|
||||
const savePartialToken = (token, username) => {
|
||||
sessionStorage.setItem(STORAGE_KEYS.partialToken, token);
|
||||
sessionStorage.setItem(STORAGE_KEYS.partialUsername, username);
|
||||
};
|
||||
|
||||
if (savedToken && savedUsername) {
|
||||
loginState.partialToken = savedToken;
|
||||
loginState.username = savedUsername;
|
||||
loginState.step = "2fa";
|
||||
const clearPartialToken = () => {
|
||||
sessionStorage.removeItem(STORAGE_KEYS.partialToken);
|
||||
sessionStorage.removeItem(STORAGE_KEYS.partialUsername);
|
||||
};
|
||||
|
||||
$("#login-username").val(savedUsername);
|
||||
$("#credentials-section").addClass("hidden");
|
||||
$("#totp-section").removeClass("hidden");
|
||||
$("#login-submit").text("Подтвердить");
|
||||
|
||||
startTotpTimer();
|
||||
|
||||
setTimeout(() => {
|
||||
const totpInput = document.getElementById("login-totp");
|
||||
if (totpInput) totpInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function savePartialToken(token, username) {
|
||||
sessionStorage.setItem(PARTIAL_TOKEN_KEY, token);
|
||||
sessionStorage.setItem(PARTIAL_USERNAME_KEY, username);
|
||||
}
|
||||
|
||||
function clearPartialToken() {
|
||||
sessionStorage.removeItem(PARTIAL_TOKEN_KEY);
|
||||
sessionStorage.removeItem(PARTIAL_USERNAME_KEY);
|
||||
}
|
||||
|
||||
function showForm(formId) {
|
||||
$("#login-form, #register-form, #reset-password-form").addClass("hidden");
|
||||
const showForm = (formId) => {
|
||||
$(
|
||||
`${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`,
|
||||
).addClass("hidden");
|
||||
$(formId).removeClass("hidden");
|
||||
|
||||
$("#login-tab, #register-tab")
|
||||
$(`${SELECTORS.loginTab}, ${SELECTORS.registerTab}`)
|
||||
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
|
||||
.addClass("text-gray-400 hover:text-gray-600");
|
||||
|
||||
if (formId === "#login-form") {
|
||||
$("#login-tab")
|
||||
if (formId === SELECTORS.loginForm) {
|
||||
$(SELECTORS.loginTab)
|
||||
.removeClass("text-gray-400 hover:text-gray-600")
|
||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||
resetLoginState();
|
||||
} else if (formId === "#register-form") {
|
||||
$("#register-tab")
|
||||
} else if (formId === SELECTORS.registerForm) {
|
||||
$(SELECTORS.registerTab)
|
||||
.removeClass("text-gray-400 hover:text-gray-600")
|
||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function resetLoginState() {
|
||||
const resetLoginState = () => {
|
||||
clearPartialToken();
|
||||
stopTotpTimer();
|
||||
loginState = {
|
||||
@@ -112,30 +151,68 @@ $(() => {
|
||||
username: "",
|
||||
rememberMe: false,
|
||||
};
|
||||
$("#totp-section").addClass("hidden");
|
||||
$("#login-totp").val("");
|
||||
$("#credentials-section").removeClass("hidden");
|
||||
$("#login-submit").text("Войти");
|
||||
$(SELECTORS.totpSection).addClass("hidden");
|
||||
$(SELECTORS.totpInput).val("");
|
||||
$(SELECTORS.credentialsSection).removeClass("hidden");
|
||||
$(SELECTORS.submitLogin).text(TEXTS.login);
|
||||
resetCircle();
|
||||
}
|
||||
};
|
||||
|
||||
$("#login-tab").on("click", () => showForm("#login-form"));
|
||||
$("#register-tab").on("click", () => showForm("#register-form"));
|
||||
$("#forgot-password-btn").on("click", () => showForm("#reset-password-form"));
|
||||
$("#back-to-login-btn").on("click", () => showForm("#login-form"));
|
||||
const checkPasswordMatch = (passwordId, confirmId, errorId) => {
|
||||
const password = $(passwordId).val();
|
||||
const confirm = $(confirmId).val();
|
||||
const $error = $(errorId);
|
||||
if (confirm && password !== confirm) {
|
||||
$error.removeClass("hidden");
|
||||
return false;
|
||||
}
|
||||
$error.addClass("hidden");
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveTokensAndRedirect = (data, rememberMe) => {
|
||||
const storage = rememberMe ? localStorage : sessionStorage;
|
||||
const otherStorage = rememberMe ? sessionStorage : localStorage;
|
||||
storage.setItem("access_token", data.access_token);
|
||||
if (data.refresh_token)
|
||||
storage.setItem("refresh_token", data.refresh_token);
|
||||
otherStorage.removeItem("access_token");
|
||||
otherStorage.removeItem("refresh_token");
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const initLoginState = () => {
|
||||
const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken);
|
||||
const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername);
|
||||
if (savedToken && savedUsername) {
|
||||
loginState.partialToken = savedToken;
|
||||
loginState.username = savedUsername;
|
||||
loginState.step = "2fa";
|
||||
$(SELECTORS.usernameLogin).val(savedUsername);
|
||||
$(SELECTORS.credentialsSection).addClass("hidden");
|
||||
$(SELECTORS.totpSection).removeClass("hidden");
|
||||
$(SELECTORS.submitLogin).text(TEXTS.confirm);
|
||||
startTotpTimer();
|
||||
setTimeout(() => $(SELECTORS.totpInput).get(0)?.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
$(SELECTORS.loginTab).on("click", () => showForm(SELECTORS.loginForm));
|
||||
$(SELECTORS.registerTab).on("click", () => showForm(SELECTORS.registerForm));
|
||||
$(SELECTORS.forgotBtn).on("click", () => showForm(SELECTORS.resetForm));
|
||||
$(SELECTORS.backToLoginBtn).on("click", () => showForm(SELECTORS.loginForm));
|
||||
$(SELECTORS.backToCredentialsBtn).on("click", resetLoginState);
|
||||
|
||||
$("body").on("click", ".toggle-password", function () {
|
||||
const $btn = $(this);
|
||||
const $input = $btn.siblings("input");
|
||||
const $input = $(this).siblings("input");
|
||||
const isPassword = $input.attr("type") === "password";
|
||||
$input.attr("type", isPassword ? "text" : "password");
|
||||
$btn.find("svg").toggleClass("hidden");
|
||||
$(this).find("svg").toggleClass("hidden");
|
||||
});
|
||||
|
||||
$("#register-password").on("input", function () {
|
||||
$(SELECTORS.registerPassword).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++;
|
||||
@@ -150,91 +227,64 @@ $(() => {
|
||||
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
|
||||
{ width: "100%", color: "bg-green-500", text: "Отличный" },
|
||||
];
|
||||
|
||||
const level = levels[strength];
|
||||
$("#password-strength-bar")
|
||||
$(SELECTORS.passwordStrengthBar)
|
||||
.css("width", level.width)
|
||||
.attr("class", "h-full transition-all duration-300 " + level.color);
|
||||
$("#password-strength-text").text(level.text);
|
||||
|
||||
checkPasswordMatch();
|
||||
.attr("class", `h-full transition-all duration-300 ${level.color}`);
|
||||
$(SELECTORS.passwordStrengthText).text(level.text);
|
||||
checkPasswordMatch(
|
||||
SELECTORS.registerPassword,
|
||||
SELECTORS.registerConfirm,
|
||||
SELECTORS.passwordMatchError,
|
||||
);
|
||||
});
|
||||
|
||||
function checkPasswordMatch() {
|
||||
const password = $("#register-password").val();
|
||||
const confirm = $("#register-password-confirm").val();
|
||||
if (confirm && password !== confirm) {
|
||||
$("#password-match-error").removeClass("hidden");
|
||||
return false;
|
||||
}
|
||||
$("#password-match-error").addClass("hidden");
|
||||
return true;
|
||||
}
|
||||
$(SELECTORS.registerConfirm).on("input", () =>
|
||||
checkPasswordMatch(
|
||||
SELECTORS.registerPassword,
|
||||
SELECTORS.registerConfirm,
|
||||
SELECTORS.passwordMatchError,
|
||||
),
|
||||
);
|
||||
|
||||
$("#register-password-confirm").on("input", checkPasswordMatch);
|
||||
|
||||
function formatRecoveryCode(input) {
|
||||
let value = input.value.toUpperCase().replace(/[^0-9A-F]/g, "");
|
||||
$(SELECTORS.resetCode).on("input", function () {
|
||||
let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, "");
|
||||
let formatted = "";
|
||||
for (let i = 0; i < value.length && i < 16; i++) {
|
||||
if (i > 0 && i % 4 === 0) formatted += "-";
|
||||
formatted += value[i];
|
||||
}
|
||||
input.value = formatted;
|
||||
}
|
||||
|
||||
$("#reset-recovery-code").on("input", function () {
|
||||
formatRecoveryCode(this);
|
||||
this.value = formatted;
|
||||
});
|
||||
|
||||
$("#login-totp").on("input", function () {
|
||||
$(SELECTORS.totpInput).on("input", function () {
|
||||
this.value = this.value.replace(/\D/g, "").slice(0, 6);
|
||||
if (this.value.length === 6) {
|
||||
$("#login-form").trigger("submit");
|
||||
}
|
||||
if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit");
|
||||
});
|
||||
|
||||
$("#back-to-credentials-btn").on("click", function () {
|
||||
resetLoginState();
|
||||
});
|
||||
|
||||
$("#login-form").on("submit", async function (event) {
|
||||
$(SELECTORS.loginForm).on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
const $submitBtn = $("#login-submit");
|
||||
|
||||
const $submitBtn = $(SELECTORS.submitLogin);
|
||||
if (loginState.step === "credentials") {
|
||||
const username = $("#login-username").val();
|
||||
const password = $("#login-password").val();
|
||||
const rememberMe = $("#remember-me").prop("checked");
|
||||
|
||||
const username = $(SELECTORS.usernameLogin).val();
|
||||
const password = $(SELECTORS.passwordLogin).val();
|
||||
const rememberMe = $(SELECTORS.rememberMe).prop("checked");
|
||||
loginState.username = username;
|
||||
loginState.rememberMe = rememberMe;
|
||||
|
||||
$submitBtn.prop("disabled", true).text("Вход...");
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
|
||||
const formData = new URLSearchParams({ username, password });
|
||||
const data = await Api.postForm("/api/auth/token", formData);
|
||||
|
||||
if (data.requires_2fa && data.partial_token) {
|
||||
loginState.partialToken = data.partial_token;
|
||||
loginState.step = "2fa";
|
||||
|
||||
savePartialToken(data.partial_token, username);
|
||||
|
||||
$("#credentials-section").addClass("hidden");
|
||||
$("#totp-section").removeClass("hidden");
|
||||
|
||||
$(SELECTORS.credentialsSection).addClass("hidden");
|
||||
$(SELECTORS.totpSection).removeClass("hidden");
|
||||
startTotpTimer();
|
||||
|
||||
const totpInput = document.getElementById("login-totp");
|
||||
if (totpInput) totpInput.focus();
|
||||
|
||||
$submitBtn.text("Подтвердить");
|
||||
Utils.showToast("Введите код из приложения аутентификатора", "info");
|
||||
$(SELECTORS.totpInput).get(0)?.focus();
|
||||
$submitBtn.text(TEXTS.confirm);
|
||||
Utils.showToast(TEXTS.enterTotp, "info");
|
||||
} else if (data.access_token) {
|
||||
clearPartialToken();
|
||||
saveTokensAndRedirect(data, rememberMe);
|
||||
@@ -243,20 +293,15 @@ $(() => {
|
||||
Utils.showToast(error.message || "Ошибка входа", "error");
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false);
|
||||
if (loginState.step === "credentials") {
|
||||
$submitBtn.text("Войти");
|
||||
}
|
||||
if (loginState.step === "credentials") $submitBtn.text(TEXTS.login);
|
||||
}
|
||||
} else if (loginState.step === "2fa") {
|
||||
const totpCode = $("#login-totp").val();
|
||||
|
||||
const totpCode = $(SELECTORS.totpInput).val();
|
||||
if (!totpCode || totpCode.length !== 6) {
|
||||
Utils.showToast("Введите 6-значный код", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
$submitBtn.prop("disabled", true).text("Проверка...");
|
||||
|
||||
$submitBtn.prop("disabled", true).text(TEXTS.checking);
|
||||
try {
|
||||
const response = await fetch("/api/auth/2fa/verify", {
|
||||
method: "POST",
|
||||
@@ -266,113 +311,93 @@ $(() => {
|
||||
},
|
||||
body: JSON.stringify({ code: totpCode }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.status === 401) {
|
||||
resetLoginState();
|
||||
throw new Error(
|
||||
"Время сессии истекло. Пожалуйста, войдите заново.",
|
||||
);
|
||||
throw new Error(TEXTS.sessionExpired);
|
||||
}
|
||||
|
||||
throw new Error(errorData.detail || "Неверный код");
|
||||
throw new Error(errorData.detail || TEXTS.invalidCode);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
clearPartialToken();
|
||||
stopTotpTimer();
|
||||
saveTokensAndRedirect(data, loginState.rememberMe);
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || "Неверный код", "error");
|
||||
$("#login-totp").val("");
|
||||
const totpInput = document.getElementById("login-totp");
|
||||
if (totpInput) totpInput.focus();
|
||||
Utils.showToast(error.message || TEXTS.invalidCode, "error");
|
||||
$(SELECTORS.totpInput).val("");
|
||||
$(SELECTORS.totpInput).get(0)?.focus();
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false).text("Подтвердить");
|
||||
$submitBtn.prop("disabled", false).text(TEXTS.confirm);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function saveTokensAndRedirect(data, rememberMe) {
|
||||
const storage = rememberMe ? localStorage : sessionStorage;
|
||||
const otherStorage = rememberMe ? sessionStorage : localStorage;
|
||||
|
||||
storage.setItem("access_token", data.access_token);
|
||||
if (data.refresh_token) {
|
||||
storage.setItem("refresh_token", data.refresh_token);
|
||||
}
|
||||
|
||||
otherStorage.removeItem("access_token");
|
||||
otherStorage.removeItem("refresh_token");
|
||||
|
||||
window.location.href = "/";
|
||||
}
|
||||
|
||||
$("#register-form").on("submit", async function (event) {
|
||||
$(SELECTORS.registerForm).on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
const $submitBtn = $("#register-submit");
|
||||
const pass = $("#register-password").val();
|
||||
const confirm = $("#register-password-confirm").val();
|
||||
|
||||
const $submitBtn = $(SELECTORS.submitRegister);
|
||||
const pass = $(SELECTORS.registerPassword).val();
|
||||
const confirm = $(SELECTORS.registerConfirm).val();
|
||||
if (pass !== confirm) {
|
||||
Utils.showToast("Пароли не совпадают", "error");
|
||||
Utils.showToast(TEXTS.passwordsNotMatch, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = {
|
||||
username: $("#register-username").val(),
|
||||
email: $("#register-email").val(),
|
||||
full_name: $("#register-fullname").val() || null,
|
||||
username: $(SELECTORS.registerUsername).val(),
|
||||
email: $(SELECTORS.registerEmail).val(),
|
||||
full_name: $(SELECTORS.registerFullname).val() || null,
|
||||
password: pass,
|
||||
};
|
||||
|
||||
$submitBtn.prop("disabled", true).text("Регистрация...");
|
||||
|
||||
$submitBtn.prop("disabled", true).text(TEXTS.registering);
|
||||
try {
|
||||
const response = await Api.post("/api/auth/register", userData);
|
||||
|
||||
if (response.recovery_codes && response.recovery_codes.codes) {
|
||||
registeredRecoveryCodes = response.recovery_codes.codes;
|
||||
showRecoveryCodesModal(registeredRecoveryCodes, userData.username);
|
||||
} else {
|
||||
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
|
||||
Utils.showToast(TEXTS.registrationSuccess, "success");
|
||||
setTimeout(() => {
|
||||
showForm("#login-form");
|
||||
$("#login-username").val(userData.username);
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.usernameLogin).val(userData.username);
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.detail && error.detail.error === "captcha_required") {
|
||||
Utils.showToast(TEXTS.captchaRequired, "error");
|
||||
const $capElement = $(SELECTORS.capWidget);
|
||||
const $parent = $capElement.parent();
|
||||
$capElement.remove();
|
||||
$parent.append(
|
||||
`<cap-widget id="cap" data-cap-api-endpoint="/api/cap/" style="--cap-widget-width: 100%;"></cap-widget>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
let msg = error.message;
|
||||
if (error.detail && Array.isArray(error.detail)) {
|
||||
msg = error.detail.map((e) => e.msg).join(". ");
|
||||
}
|
||||
Utils.showToast(msg || "Ошибка регистрации", "error");
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
||||
$submitBtn
|
||||
.prop("disabled", false)
|
||||
.text(TEXTS.registering.replace("...", ""));
|
||||
}
|
||||
});
|
||||
|
||||
function showRecoveryCodesModal(codes, username) {
|
||||
const $list = $("#recovery-codes-list");
|
||||
const showRecoveryCodesModal = (codes, username) => {
|
||||
const $list = $(SELECTORS.recoveryList);
|
||||
$list.empty();
|
||||
|
||||
codes.forEach((code, index) => {
|
||||
$list.append(`
|
||||
<div class="py-1 px-2 bg-white rounded border select-all font-mono">
|
||||
${index + 1}. ${Utils.escapeHtml(code)}
|
||||
</div>
|
||||
`);
|
||||
$list.append(
|
||||
`<div class="py-1 px-2 bg-white rounded border select-all font-mono">${index + 1}. ${Utils.escapeHtml(code)}</div>`,
|
||||
);
|
||||
});
|
||||
$(SELECTORS.codesSavedCheckbox).prop("checked", false);
|
||||
$(SELECTORS.closeRecoveryBtn).prop("disabled", true);
|
||||
$(SELECTORS.recoveryModal).data("username", username).removeClass("hidden");
|
||||
};
|
||||
|
||||
$("#codes-saved-checkbox").prop("checked", false);
|
||||
$("#close-recovery-modal-btn").prop("disabled", true);
|
||||
$("#recovery-codes-modal").data("username", username);
|
||||
$("#recovery-codes-modal").removeClass("hidden");
|
||||
}
|
||||
|
||||
function renderRecoveryCodesStatus(usedCodes) {
|
||||
const renderRecoveryCodesStatus = (usedCodes) => {
|
||||
return usedCodes
|
||||
.map((used, index) => {
|
||||
const codeDisplay = "████-████-████-████";
|
||||
@@ -380,31 +405,25 @@ $(() => {
|
||||
? "text-gray-300 line-through"
|
||||
: "text-green-600";
|
||||
const statusIcon = used ? "✗" : "✓";
|
||||
return `
|
||||
<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}">
|
||||
<span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span>
|
||||
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
|
||||
</div>
|
||||
`;
|
||||
return `<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}"><span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span><span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
};
|
||||
|
||||
$("#codes-saved-checkbox").on("change", function () {
|
||||
$("#close-recovery-modal-btn").prop("disabled", !this.checked);
|
||||
$(SELECTORS.codesSavedCheckbox).on("change", function () {
|
||||
$(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked);
|
||||
});
|
||||
|
||||
$("#copy-codes-btn").on("click", function () {
|
||||
$(SELECTORS.copyCodesBtn).on("click", function () {
|
||||
const codesText = registeredRecoveryCodes.join("\n");
|
||||
navigator.clipboard.writeText(codesText).then(() => {
|
||||
Utils.showToast("Коды скопированы в буфер обмена", "success");
|
||||
});
|
||||
navigator.clipboard
|
||||
.writeText(codesText)
|
||||
.then(() => Utils.showToast(TEXTS.codesCopied, "success"));
|
||||
});
|
||||
|
||||
$("#download-codes-btn").on("click", function () {
|
||||
const username = $("#recovery-codes-modal").data("username") || "user";
|
||||
$(SELECTORS.downloadCodesBtn).on("click", function () {
|
||||
const username = $(SELECTORS.recoveryModal).data("username") || "user";
|
||||
const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`;
|
||||
|
||||
const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -412,69 +431,54 @@ $(() => {
|
||||
a.download = `recovery-codes-${username}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
Utils.showToast("Файл с кодами скачан", "success");
|
||||
Utils.showToast(TEXTS.codesDownloaded, "success");
|
||||
});
|
||||
|
||||
$("#close-recovery-modal-btn").on("click", function () {
|
||||
const username = $("#recovery-codes-modal").data("username");
|
||||
$("#recovery-codes-modal").addClass("hidden");
|
||||
|
||||
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
|
||||
showForm("#login-form");
|
||||
$("#login-username").val(username);
|
||||
$(SELECTORS.closeRecoveryBtn).on("click", function () {
|
||||
const username = $(SELECTORS.recoveryModal).data("username");
|
||||
$(SELECTORS.recoveryModal).addClass("hidden");
|
||||
Utils.showToast(TEXTS.registrationSuccess, "success");
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.usernameLogin).val(username);
|
||||
});
|
||||
|
||||
function checkResetPasswordMatch() {
|
||||
const password = $("#reset-new-password").val();
|
||||
const confirm = $("#reset-confirm-password").val();
|
||||
if (confirm && password !== confirm) {
|
||||
$("#reset-password-match-error").removeClass("hidden");
|
||||
return false;
|
||||
}
|
||||
$("#reset-password-match-error").addClass("hidden");
|
||||
return true;
|
||||
}
|
||||
$(SELECTORS.resetConfirmPassword).on("input", () =>
|
||||
checkPasswordMatch(
|
||||
SELECTORS.resetNewPassword,
|
||||
SELECTORS.resetConfirmPassword,
|
||||
SELECTORS.resetMatchError,
|
||||
),
|
||||
);
|
||||
|
||||
$("#reset-confirm-password").on("input", checkResetPasswordMatch);
|
||||
|
||||
$("#reset-password-form").on("submit", async function (event) {
|
||||
$(SELECTORS.resetForm).on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
const $submitBtn = $("#reset-submit");
|
||||
|
||||
const newPassword = $("#reset-new-password").val();
|
||||
const confirmPassword = $("#reset-confirm-password").val();
|
||||
|
||||
const $submitBtn = $(SELECTORS.submitReset);
|
||||
const newPassword = $(SELECTORS.resetNewPassword).val();
|
||||
const confirmPassword = $(SELECTORS.resetConfirmPassword).val();
|
||||
if (newPassword !== confirmPassword) {
|
||||
Utils.showToast("Пароли не совпадают", "error");
|
||||
Utils.showToast(TEXTS.passwordsNotMatch, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 8) {
|
||||
Utils.showToast("Пароль должен содержать минимум 8 символов", "error");
|
||||
Utils.showToast(TEXTS.passwordTooShort, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
username: $("#reset-username").val(),
|
||||
recovery_code: $("#reset-recovery-code").val().toUpperCase(),
|
||||
username: $(SELECTORS.resetUsername).val(),
|
||||
recovery_code: $(SELECTORS.resetCode).val().toUpperCase(),
|
||||
new_password: newPassword,
|
||||
};
|
||||
|
||||
if (
|
||||
!/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(
|
||||
data.recovery_code,
|
||||
)
|
||||
) {
|
||||
Utils.showToast("Неверный формат резервного кода", "error");
|
||||
Utils.showToast(TEXTS.invalidRecoveryCode, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
$submitBtn.prop("disabled", true).text("Сброс...");
|
||||
|
||||
$submitBtn.prop("disabled", true).text(TEXTS.resetting);
|
||||
try {
|
||||
const response = await Api.post("/api/auth/password/reset", data);
|
||||
|
||||
showPasswordResetResult(response, data.username);
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || "Ошибка сброса пароля", "error");
|
||||
@@ -482,9 +486,8 @@ $(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function showPasswordResetResult(response, username) {
|
||||
const $form = $("#reset-password-form");
|
||||
|
||||
const showPasswordResetResult = (response, username) => {
|
||||
const $form = $(SELECTORS.resetForm);
|
||||
$form.html(`
|
||||
<div class="text-center mb-4">
|
||||
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
|
||||
@@ -492,22 +495,19 @@ $(() => {
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Пароль успешно изменён!</h3>
|
||||
<h3 class="text-lg font-semibold text-gray-800">${TEXTS.passwordResetSuccess}</h3>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-600 mb-2 text-center">
|
||||
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
|
||||
</p>
|
||||
|
||||
${
|
||||
response.should_regenerate
|
||||
? `
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
|
||||
<p class="text-sm text-yellow-800 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
Рекомендуем сгенерировать новые коды в профиле
|
||||
</p>
|
||||
@@ -515,12 +515,10 @@ $(() => {
|
||||
`
|
||||
: ""
|
||||
}
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
|
||||
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
|
||||
${renderRecoveryCodesStatus(response.used_codes)}
|
||||
</div>
|
||||
|
||||
${
|
||||
response.generated_at
|
||||
? `
|
||||
@@ -531,23 +529,26 @@ $(() => {
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
|
||||
<button type="button" id="goto-login-after-reset"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
|
||||
<button type="button" id="goto-login-after-reset" 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.off("submit");
|
||||
|
||||
$("#goto-login-after-reset").on("click", function () {
|
||||
location.reload();
|
||||
setTimeout(() => {
|
||||
showForm("#login-form");
|
||||
$("#login-username").val(username);
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.usernameLogin).val(username);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
initLoginState();
|
||||
|
||||
const widget = $(SELECTORS.capWidget).get(0);
|
||||
if (widget && widget.shadowRoot) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `.credits { right: 20px !important; }`;
|
||||
$(widget.shadowRoot).append(style);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,25 @@
|
||||
$(document).ready(() => {
|
||||
$(() => {
|
||||
const SELECTORS = {
|
||||
booksContainer: "#books-container",
|
||||
paginationContainer: "#pagination-container",
|
||||
bookSearchInput: "#book-search-input",
|
||||
authorSearchInput: "#author-search-input",
|
||||
authorDropdown: "#author-dropdown",
|
||||
selectedAuthorsContainer: "#selected-authors-container",
|
||||
genresList: "#genres-list",
|
||||
applyFiltersBtn: "#apply-filters-btn",
|
||||
resetFiltersBtn: "#reset-filters-btn",
|
||||
adminActions: "#admin-actions",
|
||||
pagesMin: "#pages-min",
|
||||
pagesMax: "#pages-max",
|
||||
};
|
||||
|
||||
const TEMPLATES = {
|
||||
bookCard: document.getElementById("book-card-template"),
|
||||
genreBadge: document.getElementById("genre-badge-template"),
|
||||
emptyState: document.getElementById("empty-state-template"),
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: {
|
||||
label: "Доступна",
|
||||
@@ -27,6 +48,40 @@ $(document).ready(() => {
|
||||
},
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
const STATE = {
|
||||
selectedAuthors: new Map(),
|
||||
selectedGenres: new Map(),
|
||||
currentPage: 1,
|
||||
totalBooks: 0,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const INITIAL_FILTERS = {
|
||||
search: urlParams.get("q") || "",
|
||||
authorIds: new Set(urlParams.getAll("author_id")),
|
||||
genreIds: new Set(urlParams.getAll("genre_id")),
|
||||
};
|
||||
|
||||
if (INITIAL_FILTERS.search) {
|
||||
$(SELECTORS.bookSearchInput).val(INITIAL_FILTERS.search);
|
||||
}
|
||||
|
||||
const LOADING_SKELETON_HTML = `<div class="space-y-4">${Array.from(
|
||||
{ length: 3 },
|
||||
() => `
|
||||
<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-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>
|
||||
`,
|
||||
).join("")}</div>`;
|
||||
|
||||
const USER_CAN_MANAGE =
|
||||
typeof window.canManage === "function" && window.canManage();
|
||||
|
||||
function getStatusConfig(status) {
|
||||
return (
|
||||
STATUS_CONFIG[status] || {
|
||||
@@ -37,224 +92,191 @@ $(document).ready(() => {
|
||||
);
|
||||
}
|
||||
|
||||
let selectedAuthors = new Map();
|
||||
let selectedGenres = new Map();
|
||||
let currentPage = 1;
|
||||
let pageSize = 12;
|
||||
let totalBooks = 0;
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const genreIdsFromUrl = urlParams.getAll("genre_id");
|
||||
const authorIdsFromUrl = urlParams.getAll("author_id");
|
||||
const searchFromUrl = urlParams.get("q");
|
||||
|
||||
if (searchFromUrl) $("#book-search-input").val(searchFromUrl);
|
||||
|
||||
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
|
||||
.then(([authorsData, genresData]) => {
|
||||
initAuthors(authorsData.authors);
|
||||
initGenres(genresData.genres);
|
||||
initializeAuthorDropdownListeners();
|
||||
renderChips();
|
||||
loadBooks();
|
||||
})
|
||||
.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);
|
||||
const $dropdown = $(SELECTORS.authorDropdown);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (authorIdsFromUrl.includes(String(author.id))) {
|
||||
selectedAuthors.set(author.id, author.name);
|
||||
authors.forEach((author) => {
|
||||
const item = document.createElement("div");
|
||||
item.className =
|
||||
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors";
|
||||
item.dataset.id = author.id;
|
||||
item.dataset.name = author.name;
|
||||
item.textContent = author.name;
|
||||
fragment.appendChild(item);
|
||||
|
||||
if (INITIAL_FILTERS.authorIds.has(String(author.id))) {
|
||||
STATE.selectedAuthors.set(author.id, author.name);
|
||||
}
|
||||
});
|
||||
|
||||
$dropdown.empty().append(fragment);
|
||||
}
|
||||
|
||||
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);
|
||||
const $list = $(SELECTORS.genresList);
|
||||
const canManage = USER_CAN_MANAGE;
|
||||
let html = "";
|
||||
|
||||
const editButton = window.canManage()
|
||||
genres.forEach((genre) => {
|
||||
const isChecked = INITIAL_FILTERS.genreIds.has(String(genre.id));
|
||||
if (isChecked) {
|
||||
STATE.selectedGenres.set(genre.id, genre.name);
|
||||
}
|
||||
const safeName = Utils.escapeHtml(genre.name);
|
||||
const editButton = canManage
|
||||
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||
</svg>
|
||||
</a>`
|
||||
: "";
|
||||
|
||||
$list.append(`
|
||||
html += `
|
||||
<li class="mb-1">
|
||||
<div class="flex items-center">
|
||||
<label class="custom-checkbox flex items-center flex-1">
|
||||
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
|
||||
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
|
||||
<input type="checkbox" data-id="${genre.id}" data-name="${safeName}" ${
|
||||
isChecked ? "checked" : ""
|
||||
} />
|
||||
<span class="checkmark"></span> ${safeName}
|
||||
</label>
|
||||
${editButton}
|
||||
</div>
|
||||
</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);
|
||||
});
|
||||
$list.html(html);
|
||||
|
||||
$list.on("change", "input", function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
const name = $(this).data("name");
|
||||
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
|
||||
if (this.checked) {
|
||||
STATE.selectedGenres.set(id, name);
|
||||
} else {
|
||||
STATE.selectedGenres.delete(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTotalPages() {
|
||||
return Math.max(1, Math.ceil(STATE.totalBooks / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function loadBooks() {
|
||||
const searchQuery = $("#book-search-input").val().trim();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
params.append("q", searchQuery);
|
||||
selectedAuthors.forEach((_, id) => params.append("author_ids", id));
|
||||
selectedGenres.forEach((_, id) => params.append("genre_ids", id));
|
||||
const searchQuery = $(SELECTORS.bookSearchInput).val().trim();
|
||||
const $minPages = $(SELECTORS.pagesMin);
|
||||
const $maxPages = $(SELECTORS.pagesMax);
|
||||
const minPages = $minPages.length ? $minPages.val() : "";
|
||||
const maxPages = $maxPages.length ? $maxPages.val() : "";
|
||||
|
||||
const apiParams = new URLSearchParams();
|
||||
const browserParams = new URLSearchParams();
|
||||
browserParams.append("q", searchQuery);
|
||||
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
|
||||
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
|
||||
|
||||
if (searchQuery) {
|
||||
apiParams.append("q", searchQuery);
|
||||
browserParams.append("q", searchQuery);
|
||||
}
|
||||
|
||||
if (minPages && minPages > 0) {
|
||||
apiParams.append("min_page_count", minPages);
|
||||
browserParams.append("min_page_count", minPages);
|
||||
}
|
||||
|
||||
if (maxPages && maxPages < 2000) {
|
||||
apiParams.append("max_page_count", maxPages);
|
||||
browserParams.append("max_page_count", maxPages);
|
||||
}
|
||||
|
||||
STATE.selectedAuthors.forEach((_, id) => {
|
||||
apiParams.append("author_ids", id);
|
||||
browserParams.append("author_id", id);
|
||||
});
|
||||
|
||||
STATE.selectedGenres.forEach((_, id) => {
|
||||
apiParams.append("genre_ids", id);
|
||||
browserParams.append("genre_id", id);
|
||||
});
|
||||
|
||||
apiParams.append("page", STATE.currentPage);
|
||||
apiParams.append("size", PAGE_SIZE);
|
||||
|
||||
const newUrl =
|
||||
window.location.pathname +
|
||||
(browserParams.toString() ? `?${browserParams.toString()}` : "");
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
|
||||
params.append("page", currentPage);
|
||||
params.append("size", pageSize);
|
||||
|
||||
showLoadingState();
|
||||
|
||||
Api.get(`/api/books/filter?${params.toString()}`)
|
||||
Api.get(`/api/books/filter?${apiParams.toString()}`)
|
||||
.then((data) => {
|
||||
totalBooks = data.total;
|
||||
renderBooks(data.books);
|
||||
STATE.totalBooks = data.total || 0;
|
||||
renderBooks(data.books || []);
|
||||
renderPagination();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Не удалось загрузить книги", "error");
|
||||
$("#books-container").html(
|
||||
document.getElementById("empty-state-template").innerHTML,
|
||||
$(SELECTORS.booksContainer).html(
|
||||
TEMPLATES.emptyState.content.cloneNode(true),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBooks(books) {
|
||||
const $container = $("#books-container");
|
||||
const tpl = document.getElementById("book-card-template");
|
||||
const emptyTpl = document.getElementById("empty-state-template");
|
||||
const badgeTpl = document.getElementById("genre-badge-template");
|
||||
|
||||
const $container = $(SELECTORS.booksContainer);
|
||||
$container.empty();
|
||||
|
||||
if (books.length === 0) {
|
||||
$container.append(emptyTpl.content.cloneNode(true));
|
||||
if (!books.length) {
|
||||
$container.append(TEMPLATES.emptyState.content.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
const card = clone.querySelector(".book-card");
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
books.forEach((book) => {
|
||||
const clone = TEMPLATES.bookCard.content.cloneNode(true);
|
||||
const card = clone.querySelector(".book-card");
|
||||
card.dataset.id = book.id;
|
||||
clone.querySelector(".book-title").textContent = book.title;
|
||||
clone.querySelector(".book-authors").textContent =
|
||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||
|
||||
const titleEl = clone.querySelector(".book-title");
|
||||
const authorsEl = clone.querySelector(".book-authors");
|
||||
const pageCountWrapper = clone.querySelector(".book-page-count");
|
||||
const pageCountValue =
|
||||
pageCountWrapper.querySelector(".page-count-value");
|
||||
const descEl = clone.querySelector(".book-desc");
|
||||
const statusEl = clone.querySelector(".book-status");
|
||||
const genresContainer = clone.querySelector(".book-genres");
|
||||
|
||||
titleEl.textContent = book.title;
|
||||
authorsEl.textContent =
|
||||
(book.authors && book.authors.map((a) => a.name).join(", ")) ||
|
||||
"Автор неизвестен";
|
||||
|
||||
if (book.page_count && book.page_count > 0) {
|
||||
const pageEl = clone.querySelector(".book-page-count");
|
||||
pageEl.querySelector(".page-count-value").textContent = book.page_count;
|
||||
pageEl.classList.remove("hidden");
|
||||
pageCountValue.textContent = book.page_count;
|
||||
pageCountWrapper.classList.remove("hidden");
|
||||
}
|
||||
clone.querySelector(".book-desc").textContent = book.description || "";
|
||||
|
||||
descEl.textContent = book.description || "";
|
||||
|
||||
const statusConfig = getStatusConfig(book.status);
|
||||
const statusEl = clone.querySelector(".book-status");
|
||||
statusEl.textContent = statusConfig.label;
|
||||
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
|
||||
|
||||
const genresContainer = clone.querySelector(".book-genres");
|
||||
book.genres.forEach((g) => {
|
||||
const badge = badgeTpl.content.cloneNode(true);
|
||||
const span = badge.querySelector("span");
|
||||
span.textContent = g.name;
|
||||
genresContainer.appendChild(badge);
|
||||
});
|
||||
|
||||
$container.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
$("#pagination-container").empty();
|
||||
const totalPages = Math.ceil(totalBooks / 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" : ""}>←</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" : ""}>→</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const $pageNumbers = $pagination.find("#page-numbers");
|
||||
const pages = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page === "...") {
|
||||
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
|
||||
} else {
|
||||
const isActive = page === currentPage;
|
||||
$pageNumbers.append(`
|
||||
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
|
||||
`);
|
||||
if (Array.isArray(book.genres)) {
|
||||
book.genres.forEach((g) => {
|
||||
const badge = TEMPLATES.genreBadge.content.cloneNode(true);
|
||||
const span = badge.querySelector("span");
|
||||
span.textContent = g.name;
|
||||
genresContainer.appendChild(badge);
|
||||
});
|
||||
}
|
||||
|
||||
fragment.appendChild(clone);
|
||||
});
|
||||
|
||||
$("#pagination-container").append($pagination);
|
||||
|
||||
$("#prev-page").on("click", function () {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
$("#next-page").on("click", function () {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
$(".page-btn").on("click", function () {
|
||||
const page = parseInt($(this).data("page"));
|
||||
if (page !== currentPage) {
|
||||
currentPage = page;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
$container.append(fragment);
|
||||
}
|
||||
|
||||
function generatePageNumbers(current, total) {
|
||||
@@ -274,49 +296,81 @@ $(document).ready(() => {
|
||||
return pages;
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const totalPages = getTotalPages();
|
||||
const $container = $(SELECTORS.paginationContainer);
|
||||
$container.empty();
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = generatePageNumbers(STATE.currentPage, totalPages);
|
||||
let pagesHtml = "";
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page === "...") {
|
||||
pagesHtml += `<span class="px-3 py-2 text-gray-500">...</span>`;
|
||||
} else {
|
||||
const isActive = page === STATE.currentPage;
|
||||
pagesHtml += `<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>`;
|
||||
}
|
||||
});
|
||||
|
||||
const html = `
|
||||
<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" ${
|
||||
STATE.currentPage === 1 ? "disabled" : ""
|
||||
}>←</button>
|
||||
<div id="page-numbers" class="flex gap-1">${pagesHtml}</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" ${
|
||||
STATE.currentPage === totalPages ? "disabled" : ""
|
||||
}>→</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$container.html(html);
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
$("#books-container").html(`
|
||||
<div class="space-y-4">
|
||||
${Array(3)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||
<div class="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-full mb-2"></div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
$(SELECTORS.booksContainer).html(LOADING_SKELETON_HTML);
|
||||
}
|
||||
|
||||
function renderChips() {
|
||||
const $container = $("#selected-authors-container");
|
||||
const $dropdown = $("#author-dropdown");
|
||||
|
||||
function renderSelectedAuthors() {
|
||||
const $container = $(SELECTORS.selectedAuthorsContainer);
|
||||
const $dropdown = $(SELECTORS.authorDropdown);
|
||||
$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);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
STATE.selectedAuthors.forEach((name, id) => {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className =
|
||||
"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";
|
||||
wrapper.innerHTML = `
|
||||
${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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
fragment.appendChild(wrapper);
|
||||
});
|
||||
|
||||
$container.append(fragment);
|
||||
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
if (selectedAuthors.has(id)) {
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
if (STATE.selectedAuthors.has(id)) {
|
||||
$(this)
|
||||
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.removeClass("hover:bg-gray-100");
|
||||
@@ -329,11 +383,11 @@ $(document).ready(() => {
|
||||
}
|
||||
|
||||
function initializeAuthorDropdownListeners() {
|
||||
const $input = $("#author-search-input");
|
||||
const $dropdown = $("#author-dropdown");
|
||||
const $container = $("#selected-authors-container");
|
||||
const $input = $(SELECTORS.authorSearchInput);
|
||||
const $dropdown = $(SELECTORS.authorDropdown);
|
||||
const $container = $(SELECTORS.selectedAuthorsContainer);
|
||||
|
||||
$input.on("focus", function () {
|
||||
$input.on("focus", () => {
|
||||
$dropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
@@ -349,7 +403,7 @@ $(document).ready(() => {
|
||||
$(document).on("click", function (e) {
|
||||
if (
|
||||
!$(e.target).closest(
|
||||
"#author-search-input, #author-dropdown, #selected-authors-container",
|
||||
`${SELECTORS.authorSearchInput}, ${SELECTORS.authorDropdown}, ${SELECTORS.selectedAuthorsContainer}`,
|
||||
).length
|
||||
) {
|
||||
$dropdown.addClass("hidden");
|
||||
@@ -358,61 +412,108 @@ $(document).ready(() => {
|
||||
|
||||
$dropdown.on("click", ".author-item", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (selectedAuthors.has(id)) {
|
||||
selectedAuthors.delete(id);
|
||||
if (STATE.selectedAuthors.has(id)) {
|
||||
STATE.selectedAuthors.delete(id);
|
||||
} else {
|
||||
selectedAuthors.set(id, name);
|
||||
STATE.selectedAuthors.set(id, name);
|
||||
}
|
||||
|
||||
$input.val("");
|
||||
$dropdown.find(".author-item").show();
|
||||
renderChips();
|
||||
renderSelectedAuthors();
|
||||
$input[0].focus();
|
||||
});
|
||||
|
||||
$container.on("click", ".remove-author", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
selectedAuthors.delete(id);
|
||||
renderChips();
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
STATE.selectedAuthors.delete(id);
|
||||
renderSelectedAuthors();
|
||||
});
|
||||
}
|
||||
|
||||
$("#books-container").on("click", ".book-card", function () {
|
||||
window.location.href = `/book/${$(this).data("id")}`;
|
||||
$(SELECTORS.booksContainer).on("click", ".book-card", function () {
|
||||
const id = $(this).data("id");
|
||||
if (id) {
|
||||
window.location.href = `/book/${id}`;
|
||||
}
|
||||
});
|
||||
|
||||
$("#apply-filters-btn").on("click", function () {
|
||||
currentPage = 1;
|
||||
$(SELECTORS.applyFiltersBtn).on("click", function () {
|
||||
STATE.currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
$("#reset-filters-btn").on("click", function () {
|
||||
$("#book-search-input").val("");
|
||||
selectedAuthors.clear();
|
||||
selectedGenres.clear();
|
||||
$("#genres-list input").prop("checked", false);
|
||||
renderChips();
|
||||
currentPage = 1;
|
||||
$(SELECTORS.resetFiltersBtn).on("click", function () {
|
||||
$(SELECTORS.bookSearchInput).val("");
|
||||
STATE.selectedAuthors.clear();
|
||||
STATE.selectedGenres.clear();
|
||||
$(`${SELECTORS.genresList} input`).prop("checked", false);
|
||||
|
||||
const $min = $(SELECTORS.pagesMin);
|
||||
const $max = $(SELECTORS.pagesMax);
|
||||
if ($min.length && $max.length) {
|
||||
const minDefault = $min.attr("min");
|
||||
const maxDefault = $max.attr("max");
|
||||
if (minDefault !== undefined) $min.val(minDefault).trigger("input");
|
||||
if (maxDefault !== undefined) $max.val(maxDefault).trigger("input");
|
||||
}
|
||||
|
||||
renderSelectedAuthors();
|
||||
STATE.currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
$("#book-search-input").on("keypress", function (e) {
|
||||
$(SELECTORS.bookSearchInput).on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
currentPage = 1;
|
||||
STATE.currentPage = 1;
|
||||
loadBooks();
|
||||
}
|
||||
});
|
||||
|
||||
function showAdminControls() {
|
||||
if (window.canManage()) {
|
||||
$("#admin-actions").removeClass("hidden");
|
||||
$(SELECTORS.paginationContainer).on("click", "#prev-page", function () {
|
||||
if (STATE.currentPage > 1) {
|
||||
STATE.currentPage -= 1;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$(SELECTORS.paginationContainer).on("click", "#next-page", function () {
|
||||
const totalPages = getTotalPages();
|
||||
if (STATE.currentPage < totalPages) {
|
||||
STATE.currentPage += 1;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$(SELECTORS.paginationContainer).on("click", ".page-btn", function () {
|
||||
const page = parseInt($(this).data("page"), 10);
|
||||
if (page && page !== STATE.currentPage) {
|
||||
STATE.currentPage = page;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
if (USER_CAN_MANAGE) {
|
||||
$(SELECTORS.adminActions).removeClass("hidden");
|
||||
}
|
||||
|
||||
showAdminControls();
|
||||
setTimeout(showAdminControls, 100);
|
||||
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
|
||||
.then(([authorsData, genresData]) => {
|
||||
initAuthors(authorsData.authors || []);
|
||||
initGenres(genresData.genres || []);
|
||||
initializeAuthorDropdownListeners();
|
||||
renderSelectedAuthors();
|
||||
loadBooks();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка загрузки данных", "error");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +184,27 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<cap-widget id="cap"
|
||||
data-cap-api-endpoint="/api/cap/"
|
||||
style="
|
||||
--cap-widget-width: 100%;
|
||||
--cap-background: #fdfdfd;
|
||||
--cap-border-color: #d1d5db;
|
||||
--cap-border-radius: 8px;
|
||||
--cap-widget-height: auto;
|
||||
--cap-color: #212121;
|
||||
--cap-checkbox-size: 32px;
|
||||
--cap-checkbox-border: 1.5px dashed #d1d5db;
|
||||
--cap-checkbox-border-radius: 6px;
|
||||
--cap-checkbox-background: #fafafa;
|
||||
--cap-checkbox-margin: 2px;
|
||||
--cap-spinner-color: #4b5563;
|
||||
--cap-spinner-background-color: #eee;
|
||||
--cap-spinner-thickness: 5px;"
|
||||
></cap-widget>
|
||||
</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">
|
||||
Зарегистрироваться
|
||||
@@ -320,5 +341,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
|
||||
<script src="/static/page/auth.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,38 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Книги{% endblock %}
|
||||
{% block content %}
|
||||
<style>
|
||||
.range-double {
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.range-double::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 100%;
|
||||
background: #4b5563;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px #4b5563;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.range-double::-moz-range-thumb {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 100%;
|
||||
background: #4b5563;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px #4b5563;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.range-double::-moz-range-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
|
||||
<aside class="w-full md:w-1/4">
|
||||
<div
|
||||
@@ -88,6 +121,49 @@
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
x-data="pagesSlider(0, 2000, 10)"
|
||||
class="bg-white p-4 rounded-lg shadow-md mb-6"
|
||||
>
|
||||
<h2 class="text-xl font-bold mb-4">Страниц</h2>
|
||||
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-2">
|
||||
<span>От: <span id="pages-min-value" x-text="minValue"></span></span>
|
||||
<span>До: <span id="pages-max-value" x-text="maxValue"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-4 mb-6">
|
||||
<div class="absolute top-1/2 -translate-y-1/2 w-full h-1 bg-gray-200 rounded-full"></div>
|
||||
|
||||
<div
|
||||
id="pages-range-progress"
|
||||
class="absolute top-1/2 -translate-y-1/2 h-1 bg-gray-600 rounded-full"
|
||||
:style="{ left: leftPercent + '%', right: rightPercent + '%' }"
|
||||
></div>
|
||||
|
||||
<input
|
||||
id="pages-min"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
x-model.number="minValue"
|
||||
@input="onMinInput()"
|
||||
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
|
||||
/>
|
||||
|
||||
<input
|
||||
id="pages-max"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
x-model.number="maxValue"
|
||||
@input="onMaxInput()"
|
||||
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Авторы</h2>
|
||||
<div
|
||||
@@ -192,4 +268,34 @@
|
||||
</template>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/page/books.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('pagesSlider', (min, max, gap) => ({
|
||||
min,
|
||||
max,
|
||||
gap,
|
||||
minValue: min,
|
||||
maxValue: max,
|
||||
|
||||
// проценты для заливки
|
||||
get leftPercent() {
|
||||
return (this.minValue - this.min) * 100 / (this.max - this.min);
|
||||
},
|
||||
get rightPercent() {
|
||||
return 100 - (this.maxValue - this.min) * 100 / (this.max - this.min);
|
||||
},
|
||||
|
||||
onMinInput() {
|
||||
if (this.maxValue - this.minValue < this.gap) {
|
||||
this.minValue = this.maxValue - this.gap;
|
||||
}
|
||||
},
|
||||
onMaxInput() {
|
||||
if (this.maxValue - this.minValue < this.gap) {
|
||||
this.maxValue = this.minValue + this.gap;
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user