Compare commits

..

12 Commits

64 changed files with 11484 additions and 5245 deletions
+22 -8
View File
@@ -1,9 +1,23 @@
# DEFAULT_ADMIN_USERNAME = "admin" # Postgres
# DEFAULT_ADMIN_EMAIL = "admin@example.com" POSTGRES_HOST="db"
# DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch" POSTGRES_PORT="5432"
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="postgres"
POSTGRES_DB="lib"
POSTGRES_HOST = "localhost" # DEFAULT_ADMIN_USERNAME="admin"
POSTGRES_PORT = "5432" # DEFAULT_ADMIN_EMAIL="admin@example.com"
POSTGRES_USER = "postgres" # DEFAULT_ADMIN_PASSWORD="password-is-generated-randomly-on-first-launch"
POSTGRES_PASSWORD = "postgres"
POSTGRES_DB = "lib" # JWT
ALGORITHM="HS256"
REFRESH_TOKEN_EXPIRE_DAYS="7"
ACCESS_TOKEN_EXPIRE_MINUTES="15"
# SECRET_KEY="your-secret-key-change-in-production"
# Hash
ARGON2_TYPE="id"
ARGON2_TIME_COST="3"
ARGON2_MEMORY_COST="65536"
ARGON2_PARALLELISM="4"
ARGON2_SALT_LENGTH="16"
Vendored
+3
View File
@@ -1,3 +1,6 @@
.env
*.log
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
+27 -16
View File
@@ -1,22 +1,33 @@
FROM python:3.13 as requirements-stage FROM python:3.12-slim
WORKDIR /tmp
RUN pip install poetry
RUN poetry self add poetry-plugin-export
COPY ./pyproject.toml ./poetry.lock* /tmp/
RUN poetry export -f requirements.txt --output requirements.txt --with dev --without-hashes
FROM python:3.13
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN apt-get update \ ENV UV_PROJECT_ENVIRONMENT="/opt/venv"
&& apt-get -y install gcc postgresql \ ENV PATH="/opt/venv/bin:$PATH"
&& apt-get clean # netcat
RUN pip install --upgrade pip
WORKDIR /code WORKDIR /code
COPY --from=requirements-stage /tmp/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir --upgrade -r ./requirements.txt RUN apt-get update \
COPY . . && apt-get -y install gcc libpq-dev \
ENV PYTHONPATH=. && apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN pip install uv
COPY ./README.md ./pyproject.toml ./uv.lock* /code/
RUN uv sync --group dev --no-install-project
COPY ./library_service /code/library_service
COPY ./alembic.ini /code/
COPY ./data.py /code/
RUN useradd app && \
chown -R app:app /code && \
chown -R app:app /opt/venv
USER app
ENV PYTHONPATH=/code
EXPOSE 8000
CMD ["uvicorn", "library_service.main:app", "--host", "0.0.0.0", "--port", "8000", "--forwarded-allow-ips=*"]
+193 -78
View File
@@ -1,17 +1,19 @@
![logo](./logo.png) ![logo](./logo.png)
# LibraryAPI # LiB
Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания. Веб-приложение библиотеки на FastAPI с современным REST API и веб-интерфейсом. Использует Pydantic для валидации данных, SQLModel для работы с базой данных, Alembic для миграций, PostgreSQL как СУБД и Docker Compose для развертывания.
### **Ключевые элементы:** ### **Ключевые технологии:**
1. FastAPI: Предоставляет высокопроизводительность и простоту для разработки RESTful API, поддерживает асинхронные операции и автоматическую генерацию документации.
2. Pydantic: Используется для валидации данных и сериализации, позволяет легко определить схемы данных.
3. SQLModel: Объединяет SQLAlchemy и Pydantic, включая операции с базой данных с помощью классов Python.
4. Alembic: Инструмент для управления миграциями базы данных, упрощающий отслеживание и применение изменений в схеме базы данных.
5. PostgreSQL: Надежная реляционная база данных для хранения данных.
6. Docker Compose: Упрощает развертывание приложения и его зависимостей в контейнерах.
1. **FastAPI**: Высокопроизводительный веб-фреймворк для создания RESTful API с автоматической генерацией документации
2. **Pydantic**: Валидация данных и сериализация с использованием аннотаций типов Python
3. **SQLModel**: Объединение SQLAlchemy и Pydantic для работы с БД через классы Python
4. **Alembic**: Инструмент для управления миграциями базы данных
5. **PostgreSQL**: Надежная реляционная база данных
6. **Docker Compose**: Упрощенное развертывание приложения и зависимостей в контейнерах
7. **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
8. **Alpine.js**: Легковесный JavaScript-фреймворк для реактивности
9. **Chart.js**: Библиотека для визуализации данных
### **Инструкция по установке** ### **Инструкция по установке**
@@ -37,12 +39,12 @@
5. Запустите приложение: 5. Запустите приложение:
```bash ```bash
docker compose up api docker compose up api -d
``` ```
Для создания новых миграций: Для создания новых миграций:
```bash ```bash
docker compose run --rm -T api alembic revision --autogenerate -m "Migration name" alembic revision --autogenerate -m "Migration name"
``` ```
Для запуска тестов: Для запуска тестов:
@@ -50,97 +52,210 @@
docker compose up test docker compose up test
``` ```
Для добавления данных для примера используйте:
```bash
python data.py
```
### **Роли пользователей**
- **Админ**: Полный доступ ко всем функциям системы
- **librarian**: Управление книгами, авторами, жанрами и выдачами
- **member**: Просмотр каталога и управление своими выдачами
### **Эндпоинты API** ### **Эндпоинты API**
**Авторы** #### **Аутентификация** (`/api/auth`)
| Метод | Эндпоинты | Описание |
|--------|-----------------------|---------------------------------|
| POST | `/authors` | Создать нового автора |
| GET | `/authors` | Получить список всех авторов |
| GET | `/authors/{id}` | Получить автора по ID с книгами |
| PUT | `/authors/{id}` | Обновить автора по ID |
| DELETE | `/authors/{id}` | Удалить автора по ID |
**Книги** | Метод | Эндпоинт | Доступ | Описание |
| Метод | Эндпоинты | Описание | |--------|-----------------------------------------------|----------------|------------------------------------------|
|--------|-----------------------|---------------------------------| | POST | `/api/auth/register` | Публичный | Регистрация нового пользователя |
| POST | `/books` | Создать новую книгу | | POST | `/api/auth/token` | Публичный | Получение JWT токенов (access + refresh) |
| GET | `/books` | Получить список всех книг | | POST | `/api/auth/refresh` | Публичный | Обновление пары токенов |
| GET | `/book/{id}` | Получить книгу по ID с авторами | | GET | `/api/auth/me` | Авторизованный | Информация о текущем пользователе |
| PUT | `/books/{id}` | Обновить книгу по ID | | PUT | `/api/auth/me` | Авторизованный | Обновление профиля текущего пользователя |
| DELETE | `/books/{id}` | Удалить книгу по ID | | GET | `/api/auth/users` | Сотрудник | Список всех пользователей |
| POST | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
| DELETE | `/api/auth/users/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
| GET | `/api/auth/roles` | Авторизованный | Список ролей в системе |
**Жанры** #### **Авторы** (`/api/authors`)
| Метод | Эндпоинты | Описание |
|--------|-----------------------|---------------------------------|
| POST | `/genres` | Создать новый жанр |
| GET | `/genres` | Получить список всех жанров |
| GET | `/genres/{id}` | Получить жанр по ID |
| PUT | `/genres/{id}` | Обновить жанр по ID |
| DELETE | `/genres/{id}` | Удалить жанр по ID |
**Связи** | Метод | Эндпоинт | Доступ | Описание |
| Метод | Эндпоинты | Описание | |--------|---------------------|-----------|---------------------------------|
|--------|------------------------------|-----------------------------------| | POST | `/api/authors` | Сотрудник | Создать нового автора |
| GET | `/authors/{id}/books` | Получить список книг для автора | | GET | `/api/authors` | Публичный | Получить список всех авторов |
| GET | `/books/{id}/authors` | Получить список авторов для книги | | GET | `/api/authors/{id}` | Публичный | Получить автора по ID с книгами |
| POST | `/relationships/author-book` | Связать автор-книга | | PUT | `/api/authors/{id}` | Сотрудник | Обновить автора по ID |
| DELETE | `/relationships/author-book` | Разделить автор-книга | | DELETE | `/api/authors/{id}` | Сотрудник | Удалить автора по ID |
| GET | `/genres/{id}/books` | Получить список книг для жанра |
| GET | `/books/{id}/genres` | Получить список жанров для книги |
| POST | `/relationships/genre-book` | Связать автор-книга |
| DELETE | `/relationships/genre-book` | Разделить автор-книга |
**Другие** #### **Книги** (`/api/books`)
| Метод | Эндпоинты | Описание |
|--------|-------------|-------------------------------|
| GET | `/api/info` | Получить информацию о сервисе |
| Метод | Эндпоинт | Доступ | Описание |
|--------|---------------------|-----------|-----------------------------------------------------------|
| GET | `/api/books/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам с пагинацией |
| POST | `/api/books` | Сотрудник | Создать новую книгу |
| GET | `/api/books` | Публичный | Получить список всех книг |
| GET | `/api/books/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
| PUT | `/api/books/{id}` | Сотрудник | Обновить книгу по ID |
| DELETE | `/api/books/{id}` | Сотрудник | Удалить книгу по ID |
#### **Жанры** (`/api/genres`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|--------------------|-----------|-------------------------------|
| POST | `/api/genres` | Сотрудник | Создать новый жанр |
| GET | `/api/genres` | Публичный | Получить список всех жанров |
| GET | `/api/genres/{id}` | Публичный | Получить жанр по ID с книгами |
| PUT | `/api/genres/{id}` | Сотрудник | Обновить жанр по ID |
| DELETE | `/api/genres/{id}` | Сотрудник | Удалить жанр по ID |
#### **Выдачи** (`/api/loans`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|------------------------------------|----------------|--------------------------------------------------------------|
| POST | `/api/loans` | Авторизованный | Создать выдачу/бронь (читатели для себя, Сотрудник для всех) |
| GET | `/api/loans` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
| GET | `/api/loans/analytics` | Админ | Аналитика выдач и возвратов |
| GET | `/api/loans/{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
| PUT | `/api/loans/{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
| POST | `/api/loans/{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
| POST | `/api/loans/{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
| DELETE | `/api/loans/{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
| GET | `/api/loans/book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
| POST | `/api/loans/issue` | Админ | Выдать книгу напрямую без бронирования |
#### **Связи** (`/api`)
| Метод | Эндпоинт | Доступ | Описание |
|--------|----------------------------------|-----------|-------------------------------|
| POST | `/api/relationships/author-book` | Сотрудник | Связать автора и книгу |
| DELETE | `/api/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
| GET | `/api/authors/{id}/books` | Публичный | Получить список книг автора |
| GET | `/api/books/{id}/authors` | Публичный | Получить список авторов книги |
| POST | `/api/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
| DELETE | `/api/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
| GET | `/api/genres/{id}/books` | Публичный | Получить список книг жанра |
| GET | `/api/books/{id}/genres` | Публичный | Получить список жанров книги |
#### **Прочее** (`/api`)
| Метод | Эндпоинт | Доступ | Описание |
|-------|--------------|-----------|----------------------|
| GET | `/api/info` | Публичный | Информация о сервисе |
| GET | `/api/stats` | Публичный | Статистика системы |
### **Веб-страницы**
| Путь | Доступ | Описание |
|---------------------|----------------|-----------------------------------------|
| `/` | Публичный | Главная страница |
| `/auth` | Публичный | Страница авторизации |
| `/profile` | Авторизованный | Профиль пользователя |
| `/books` | Публичный | Каталог книг с фильтрацией |
| `/book/{id}` | Публичный | Страница просмотра книги |
| `/book/create` | Сотрудник | Создание новой книги |
| `/book/{id}/edit` | Сотрудник | Редактирование книги |
| `/authors` | Публичный | Список авторов |
| `/author/{id}` | Публичный | Страница автора |
| `/author/create` | Сотрудник | Создание автора |
| `/author/{id}/edit` | Сотрудник | Редактирование автора |
| `/genre/create` | Сотрудник | Создание жанра |
| `/genre/{id}/edit` | Сотрудник | Редактирование жанра |
| `/my-books` | Авторизованный | Мои выдачи |
| `/users` | Сотрудник | Управление пользователями |
| `/analytics` | Админ | Аналитика выдач и возвратов |
| `/api` | Публичный | Страница с ссылками на документацию API |
### **Схема базы данных**
```mermaid ```mermaid
erDiagram erDiagram
AUTHOR { USER {
int id PK "ID автора" int id PK
string name "Имя автора" string username UK
string email UK
string full_name
string hashed_password
boolean is_active
boolean is_verified
}
ROLE {
int id PK
string name UK
string description
int payroll
}
USER_ROLE_LINK {
int user_id FK
int role_id FK
} }
BOOK { BOOK {
int id PK "ID книги" int id PK
string title "Название книги" string title
string description "Описание книги" string description
string status
}
AUTHOR {
int id PK
string name
string bio
} }
GENRE { GENRE {
int id PK "ID жанра" int id PK
string name "Название жанра" string name
string description
} }
AUTHOR_BOOK { AUTHOR_BOOK_LINK {
int author_id FK "ID автора" int author_id FK
int book_id FK "ID книги" int book_id FK
} }
GENRE_BOOK { GENRE_BOOK_LINK {
int genre_id FK "ID жанра" int genre_id FK
int book_id FK "ID книги" int book_id FK
} }
AUTHOR ||--o{ AUTHOR_BOOK : "писал" BOOK_USER_LINK {
BOOK ||--o{ AUTHOR_BOOK : "написан" int id PK
int book_id FK
int user_id FK
datetime borrowed_at
datetime due_date
datetime returned_at
}
BOOK ||--o{ GENRE_BOOK : "принадлежит" USER ||--o{ USER_ROLE_LINK : "имеет"
GENRE ||--o{ GENRE_BOOK : "содержит" ROLE ||--o{ USER_ROLE_LINK : "назначена"
USER ||--o{ BOOK_USER_LINK : "берет"
BOOK ||--o{ BOOK_USER_LINK : "выдана"
AUTHOR ||--o{ AUTHOR_BOOK_LINK : "пишет"
BOOK ||--o{ AUTHOR_BOOK_LINK : "написана"
GENRE ||--o{ GENRE_BOOK_LINK : "содержит"
BOOK ||--o{ GENRE_BOOK_LINK : "принадлежит"
``` ```
### **Статусы книг**
- **ACTIVE**: Книга доступна для выдачи
- **RESERVED**: Книга забронирована (ожидает подтверждения)
- **BORROWED**: Книга выдана пользователю
### **Используемые технологии** ### **Используемые технологии**
- **FastAPI**: Современный web фреймворк для построения API с использованием Python, известный своей скоростью и простотой использования. - **FastAPI**: Современный веб-фреймворк для построения API на Python
- **Pydantic**: Библиотека для валидации данных и управления настройками, использующая аннотации типов Python. - **Pydantic**: Библиотека для валидации данных и управления настройками
- **SQLModel**: Библиотека для взаимодействия с базами данных с использованием классов Python, объединяющая функции SQLAlchemy и Pydantic. - **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic
- **Alembic**: Легковесный инструмент для миграции базы данных на основе SQLAlchemy. - **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных. - **PostgreSQL**: Реляционная система управления базами данных
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах. - **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker. - **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker
- **Tailwind**: CSS-фреймворк, позволяющий стилизовать веб-интерфейсы, применяя готовые низкоуровневые классы. - **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
- **Cash**: Микро JavaScript-библиотека, созданная как очень быстрая и компактная альтернатива jQuery. - **Alpine.js**: Легковесный JavaScript-фреймворк для реактивности
- **Chart.js**: Библиотека для визуализации данных
+1 -1
View File
@@ -3,7 +3,7 @@ from typing import Optional
# Конфигурация # Конфигурация
USERNAME = "admin" USERNAME = "admin"
PASSWORD = "7WaVlcj8EWzEbbdab9kqRw" PASSWORD = "your-password-here"
BASE_URL = "http://localhost:8000" BASE_URL = "http://localhost:8000"
+48 -11
View File
@@ -1,28 +1,65 @@
services: services:
db: db:
container_name: db
image: postgres:17 image: postgres:17
ports: container_name: db
- 5432:5432 restart: unless-stopped
# volumes: logging:
# - ./data/db:/var/lib/postgresql/data options:
max-size: "10m"
max-file: "3"
volumes:
- ./data/db:/var/lib/postgresql/data
networks:
- proxy
env_file: env_file:
- ./.env - ./.env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 5
api: api:
container_name: api
build: . build: .
command: bash -c "alembic upgrade head && uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --reload" container_name: api
restart: unless-stopped
command: bash -c "uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips=*"
logging:
options:
max-size: "10m"
max-file: "3"
networks:
- proxy
ports:
- 8000:8000
env_file:
- ./.env
volumes: volumes:
- .:/code - .:/code
ports: depends_on:
- "8000:8000" db:
# depends_on: condition: service_healthy
# - db
tests: tests:
container_name: tests container_name: tests
build: . build: .
command: bash -c "pytest tests" command: bash -c "pytest tests"
restart: no
logging:
options:
max-size: "10m"
max-file: "3"
networks:
- proxy
env_file:
- ./.env
volumes: volumes:
- .:/code - .:/code
depends_on:
db:
condition: service_healthy
networks:
proxy: # Рекомендуется использовать через реверс-прокси
name: proxy
external: true
+148 -54
View File
@@ -1,92 +1,117 @@
"""Модуль авторизации и аутентификации""" """Модуль авторизации и аутентификации"""
import os import os
import base64
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Annotated from typing import Annotated
from uuid import uuid4
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt from jose import jwt, JWTError, ExpiredSignatureError
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlmodel import Session, select from sqlmodel import Session, select
import pyotp
import qrcode
from library_service.models.db import Role, User from library_service.models.db import Role, User
from library_service.models.dto import TokenData from library_service.models.dto import TokenData
from library_service.settings import get_session, get_logger from library_service.settings import get_session, get_logger
# Конфигурация из переменных окружения # Конфигурация JWT из переменных окружения
SECRET_KEY = os.getenv("SECRET_KEY")
ALGORITHM = os.getenv("ALGORITHM", "HS256") ALGORITHM = os.getenv("ALGORITHM", "HS256")
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production") ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "30"))
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")) REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# Конфигурация хэширования паролей из переменных окружения
ARGON2_TYPE = os.getenv("ARGON2_TYPE", "id")
ARGON2_TIME_COST = int(os.getenv("ARGON2_TIME_COST", "3"))
ARGON2_MEMORY_COST = int(os.getenv("ARGON2_MEMORY_COST", "65536"))
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "4"))
ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16"))
# Получение логгера # Получение логгера
logger = get_logger("uvicorn") logger = get_logger()
# OAuth2 схема # OAuth2 схема
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token") oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
# Проверка секретного ключа
if not SECRET_KEY:
raise RuntimeError("SECRET_KEY environment variable is required")
# Хэширование паролей # Хэширование паролей
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") pwd_context = CryptContext(
schemes=["argon2"],
deprecated="auto",
argon2__type=ARGON2_TYPE,
argon2__time_cost=ARGON2_TIME_COST,
argon2__memory_cost=ARGON2_MEMORY_COST,
argon2__parallelism=ARGON2_PARALLELISM,
argon2__salt_len=ARGON2_SALT_LENGTH,
)
def verify_password(plain_password: str, hashed_password: str) -> bool: def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверка пароль по его хешу.""" """Проверяет пароль по его хешу"""
return pwd_context.verify(plain_password, hashed_password) return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
"""Хэширование пароля.""" """Хэширует пароль"""
return pwd_context.hash(password) return pwd_context.hash(password)
def _create_token(data: dict, expires_delta: timedelta, token_type: str) -> str:
"""Базовая функция создания токена"""
now = datetime.now(timezone.utc)
to_encode = {**data, "iat": now, "exp": now + expires_delta, "type": token_type}
if token_type == "refresh":
to_encode.update({"jti": str(uuid4())})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Создание JWT access токена.""" """Создает JWT access токен"""
to_encode = data.copy() delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
if expires_delta: return _create_token(data, delta, "access")
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict) -> str: def create_refresh_token(data: dict) -> str:
"""Создание JWT refresh токена.""" """Создает JWT refresh токен"""
to_encode = data.copy() return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def decode_token(token: str) -> TokenData: def decode_token(token: str, expected_type: str = "access") -> TokenData:
"""Декодирование и проверка JWT токенов.""" """Декодирует и проверяет JWT токен"""
token_error = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
headers={"WWW-Authenticate": "Bearer"},
)
try: try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub") username: str | None = payload.get("sub")
user_id: int = payload.get("user_id") user_id: int | None = payload.get("user_id")
if username is None: token_type: str | None = payload.get("type")
raise HTTPException( if token_type != expected_type:
status_code=status.HTTP_401_UNAUTHORIZED, token_error.detail = f"Invalid token type. Expected {expected_type}"
detail="Could not validate credentials", raise token_error
headers={"WWW-Authenticate": "Bearer"}, if username is None or user_id is None:
) token_error.detail = "Could not validate credentials"
raise token_error
return TokenData(username=username, user_id=user_id) return TokenData(username=username, user_id=user_id)
except ExpiredSignatureError:
token_error.detail = "Token expired"
raise token_error
except JWTError: except JWTError:
raise HTTPException( token_error.detail = "Could not validate credentials"
status_code=status.HTTP_401_UNAUTHORIZED, raise token_error
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def authenticate_user(session: Session, username: str, password: str) -> User | None: def authenticate_user(session: Session, username: str, password: str) -> User | None:
"""Аутентификация пользователя по имени пользователя и паролю.""" """Аутентифицирует пользователя по имени и паролю"""
statement = select(User).where(User.username == username) statement = select(User).where(User.username == username)
user = session.exec(statement).first() user = session.exec(statement).first()
if not user or not verify_password(password, user.hashed_password): if not user or not verify_password(password, user.hashed_password):
@@ -98,7 +123,7 @@ def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)], token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session), session: Session = Depends(get_session),
) -> User: ) -> User:
"""Получить текущего авторизованного пользователя.""" """Возвращает текущего авторизованного пользователя"""
token_data = decode_token(token) token_data = decode_token(token)
user = session.get(User, token_data.user_id) user = session.get(User, token_data.user_id)
@@ -114,7 +139,7 @@ def get_current_user(
def get_current_active_user( def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
) -> User: ) -> User:
"""Получить текущего активного пользователя.""" """Проверяет активность пользователя и возвращает его"""
if not current_user.is_active: if not current_user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user" status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
@@ -123,7 +148,7 @@ def get_current_active_user(
def require_role(role_name: str): def require_role(role_name: str):
"""Dependency, требующая выполнения определенной роли.""" """Создает dependency для проверки наличия определенной роли"""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User: def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
user_roles = [role.name for role in current_user.roles] user_roles = [role.name for role in current_user.roles]
@@ -137,18 +162,47 @@ def require_role(role_name: str):
return role_checker return role_checker
def require_any_role(allowed_roles: list[str]):
"""Создает dependency для проверки наличия хотя бы одной из ролей"""
def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
user_roles = {role.name for role in current_user.roles}
if not (user_roles & set(allowed_roles)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of roles: {allowed_roles}",
)
return current_user
return role_checker
# Создание dependencies # Создание dependencies
RequireAuth = Annotated[User, Depends(get_current_active_user)] RequireAuth = Annotated[User, Depends(get_current_active_user)]
RequireAdmin = Annotated[User, Depends(require_role("admin"))] RequireAdmin = Annotated[User, Depends(require_role("admin"))]
RequireModerator = Annotated[User, Depends(require_role("moderator"))] RequireMember = Annotated[User, Depends(require_role("member"))]
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))]
RequireStaff = Annotated[User, Depends(require_any_role(["admin", "librarian"]))]
def is_user_staff(user: User) -> bool:
"""Проверяет, является ли пользователь сотрудником (admin или librarian)"""
roles = {role.name for role in user.roles}
return bool(roles & {"admin", "librarian"})
def is_user_admin(user: User) -> bool:
"""Проверяет, является ли пользователь администратором"""
roles = {role.name for role in user.roles}
return "admin" in roles
def seed_roles(session: Session) -> dict[str, Role]: def seed_roles(session: Session) -> dict[str, Role]:
"""Создаёт роли по умолчанию, если их нет.""" """Создает роли по умолчанию, если их нет"""
default_roles = [ default_roles = [
{"name": "admin", "description": "Администратор системы"}, {"name": "admin", "description": "Администратор системы", "payroll": 80000},
{"name": "librarian", "description": "Библиотекарь"}, {"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
{"name": "member", "description": "Посетитель библиотеки"}, {"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
] ]
roles = {} roles = {}
@@ -171,13 +225,15 @@ def seed_roles(session: Session) -> dict[str, Role]:
def seed_admin(session: Session, admin_role: Role) -> User | None: def seed_admin(session: Session, admin_role: Role) -> User | None:
"""Создаёт администратора по умолчанию, если нет ни одного.""" """Создает администратора по умолчанию, если нет ни одного"""
existing_admins = session.exec( existing_admins = session.exec(
select(User).join(User.roles).where(Role.name == "admin") select(User).join(User.roles).where(Role.name == "admin")
).all() ).all()
if existing_admins: if existing_admins:
logger.info(f"[=] Admin already exists: {existing_admins[0].username}, skipping creation") logger.info(
f"[=] Admin already exists: {existing_admins[0].username}, skipping creation"
)
return None return None
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin") admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
@@ -187,6 +243,7 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
generated = False generated = False
if not admin_password: if not admin_password:
import secrets import secrets
admin_password = secrets.token_urlsafe(16) admin_password = secrets.token_urlsafe(16)
generated = True generated = True
@@ -207,15 +264,52 @@ def seed_admin(session: Session, admin_role: Role) -> User | None:
logger.info(f"[+] Created admin user: {admin_username}") logger.info(f"[+] Created admin user: {admin_username}")
if generated: if generated:
logger.warning("=" * 50) logger.warning("=" * 52)
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}") logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
logger.warning("[!] Save this password! It won't be shown again!") logger.warning("[!] Save this password! It won't be shown again!")
logger.warning("=" * 50) logger.warning("=" * 52)
return admin_user return admin_user
def run_seeds(session: Session) -> None: def run_seeds(session: Session) -> None:
"""Запускаем создание ролей и администратора.""" """Запускает создание ролей и администратора"""
roles = seed_roles(session) roles = seed_roles(session)
seed_admin(session, roles["admin"]) seed_admin(session, roles["admin"])
def qr_to_bitmap_b64(data: str) -> dict:
"""
Конвертирует данные в QR-код и возвращает как base64 bitmap.
0 = чёрный, 1 = белый
"""
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=1,
border=0,
)
qr.add_data(data)
qr.make(fit=True)
matrix = qr.get_matrix()
size = len(matrix)
bits = []
for row in matrix:
for cell in row:
bits.append(0 if cell else 1)
padding = (8 - len(bits) % 8) % 8
bits.extend([0] * padding)
bytes_array = bytearray()
for i in range(0, len(bits), 8):
byte = 0
for j in range(8):
byte = (byte << 1) | bits[i + j]
bytes_array.append(byte)
b64 = base64.b64encode(bytes_array).decode("ascii")
return {"size": size, "padding": padding, "bitmap_b64": b64}
+93 -8
View File
@@ -1,22 +1,32 @@
"""Основной модуль""" """Основной модуль"""
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path from pathlib import Path
from time import perf_counter
from uuid import uuid4
from alembic import command from alembic import command
from alembic.config import Config from alembic.config import Config
from fastapi import FastAPI from fastapi import Request, Response
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlmodel import Session from sqlmodel import Session
from .auth import run_seeds from library_service.auth import run_seeds
from .routers import api_router from library_service.routers import api_router
from .settings import engine, get_app, get_logger from library_service.settings import (
LOGGING_CONFIG,
engine,
get_app,
get_logger,
)
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(_):
"""Жизненный цикл сервиса""" """Жизненный цикл сервиса"""
logger = get_logger("uvicorn") logger = get_logger()
logger.info("[+] Initializing database...") logger.info("[+] Initializing database...")
try: try:
@@ -45,7 +55,82 @@ async def lifespan(app: FastAPI):
app = get_app(lifespan) app = get_app(lifespan)
# Улучшеное логгирование
@app.middleware("http")
async def log_requests(request: Request, call_next):
"""Middleware для логирования HTTP-запросов"""
path = request.url.path
if path.startswith("/static") or path in SKIP_LOGGING_PATHS:
return await call_next(request)
logger = get_logger()
request_id = uuid4().hex[:8]
timestamp = datetime.now().isoformat()
method = request.method
url = str(request.url)
user_agent = request.headers.get("user-agent", "Unknown")
client_ip = request.client.host if request.client else None
start_time = perf_counter()
try:
logger.debug(
f"[{request_id}] Starting: {method} {url}",
extra={"request_id": request_id, "user_agent": user_agent},
)
response: Response = await call_next(request)
process_time = perf_counter() - start_time
logger.info(
f"[{request_id}] {method} {url} - {response.status_code} - {process_time:.4f}s",
extra={
"request_id": request_id,
"timestamp": timestamp,
"method": method,
"url": url,
"status": response.status_code,
"process_time": process_time,
"client_ip": client_ip,
"user_agent": user_agent,
},
)
return response
except Exception as e:
process_time = perf_counter() - start_time
logger.error(
f"[{request_id}] {method} {url} - Error: {e} - {process_time:.4f}s",
extra={
"request_id": request_id,
"timestamp": timestamp,
"method": method,
"url": url,
"error": str(e),
"process_time": process_time,
"client_ip": client_ip,
"user_agent": user_agent,
},
exc_info=True,
)
return Response(status_code=500, content="Internal Server Error")
# Подключение маршрутов # Подключение маршрутов
app.include_router(api_router) app.include_router(api_router)
static_path = Path(__file__).parent / "static" app.mount(
app.mount("/static", StaticFiles(directory=static_path), name="static") "/static",
StaticFiles(directory=Path(__file__).parent / "static"),
name="static",
)
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"library_service.main:app",
host="0.0.0.0",
port=8000,
log_config=LOGGING_CONFIG,
access_log=False,
)
+2
View File
@@ -7,6 +7,7 @@ from .user import User
from .links import ( from .links import (
AuthorBookLink, AuthorBookLink,
GenreBookLink, GenreBookLink,
BookUserLink,
UserRoleLink UserRoleLink
) )
@@ -18,5 +19,6 @@ __all__ = [
"User", "User",
"AuthorBookLink", "AuthorBookLink",
"GenreBookLink", "GenreBookLink",
"BookUserLink",
"UserRoleLink", "UserRoleLink",
] ]
+5 -1
View File
@@ -1,6 +1,7 @@
"""Модуль DB-моделей книг""" """Модуль DB-моделей книг"""
from typing import TYPE_CHECKING, List from typing import TYPE_CHECKING, List
from sqlalchemy import Column, String
from sqlmodel import Field, Relationship from sqlmodel import Field, Relationship
from library_service.models.dto.book import BookBase from library_service.models.dto.book import BookBase
@@ -15,7 +16,10 @@ if TYPE_CHECKING:
class Book(BookBase, table=True): class Book(BookBase, table=True):
"""Модель книги в базе данных""" """Модель книги в базе данных"""
id: int | None = Field(default=None, primary_key=True, index=True) id: int | None = Field(default=None, primary_key=True, index=True)
status: BookStatus = Field(default=BookStatus.ACTIVE) status: BookStatus = Field(
default=BookStatus.ACTIVE,
sa_column=Column(String, nullable=False, default="active")
)
authors: List["Author"] = Relationship( authors: List["Author"] = Relationship(
back_populates="books", link_model=AuthorBookLink back_populates="books", link_model=AuthorBookLink
) )
+3 -1
View File
@@ -6,7 +6,7 @@ from .author import AuthorRead
from .genre import GenreRead from .genre import GenreRead
from .book import BookRead from .book import BookRead
from .loan import LoanRead from .loan import LoanRead
from ..enums import BookStatus
class AuthorWithBooks(SQLModel): class AuthorWithBooks(SQLModel):
"""Модель автора с книгами""" """Модель автора с книгами"""
@@ -35,6 +35,7 @@ class BookWithGenres(SQLModel):
id: int id: int
title: str title: str
description: str description: str
status: BookStatus | None = None
genres: List[GenreRead] = Field(default_factory=list) genres: List[GenreRead] = Field(default_factory=list)
@@ -43,6 +44,7 @@ class BookWithAuthorsAndGenres(SQLModel):
id: int id: int
title: str title: str
description: str description: str
status: BookStatus | None = None
authors: List[AuthorRead] = Field(default_factory=list) authors: List[AuthorRead] = Field(default_factory=list)
genres: List[GenreRead] = Field(default_factory=list) genres: List[GenreRead] = Field(default_factory=list)
+2
View File
@@ -19,6 +19,8 @@ class LoanCreate(LoanBase):
class LoanUpdate(SQLModel): class LoanUpdate(SQLModel):
"""Модель для обновления записи о выдаче""" """Модель для обновления записи о выдаче"""
user_id: int | None = None
due_date: datetime | None = None
returned_at: datetime | None = None returned_at: datetime | None = None
+1
View File
@@ -8,6 +8,7 @@ class RoleBase(SQLModel):
"""Базовая модель роли""" """Базовая модель роли"""
name: str name: str
description: str | None = None description: str | None = None
payroll: int = 0
class RoleCreate(RoleBase): class RoleCreate(RoleBase):
+4
View File
@@ -5,15 +5,19 @@ from .auth import router as auth_router
from .authors import router as authors_router from .authors import router as authors_router
from .books import router as books_router from .books import router as books_router
from .genres import router as genres_router from .genres import router as genres_router
from .loans import router as loans_router
from .relationships import router as relationships_router from .relationships import router as relationships_router
from .misc import router as misc_router from .misc import router as misc_router
api_router = APIRouter() api_router = APIRouter()
# Подключение всех маршрутов # Подключение всех маршрутов
api_router.include_router(misc_router) api_router.include_router(misc_router)
api_router.include_router(auth_router, prefix="/api") api_router.include_router(auth_router, prefix="/api")
api_router.include_router(authors_router, prefix="/api") api_router.include_router(authors_router, prefix="/api")
api_router.include_router(books_router, prefix="/api") api_router.include_router(books_router, prefix="/api")
api_router.include_router(genres_router, prefix="/api") api_router.include_router(genres_router, prefix="/api")
api_router.include_router(loans_router, prefix="/api")
api_router.include_router(relationships_router, prefix="/api") api_router.include_router(relationships_router, prefix="/api")
+116 -22
View File
@@ -1,18 +1,40 @@
"""Модуль работы с авторизацией и аутентификацией пользователей""" """Модуль работы с авторизацией и аутентификацией пользователей"""
from datetime import timedelta from datetime import timedelta
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Body, Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlmodel import Session, select from sqlmodel import Session, select
import pyotp
from library_service.models.db import Role, User from library_service.models.db import Role, User
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList from library_service.models.dto import (
Token,
UserCreate,
UserRead,
UserUpdate,
UserList,
RoleRead,
RoleList,
)
from library_service.settings import get_session from library_service.settings import get_session
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin, from library_service.auth import (
RequireAuth, authenticate_user, get_password_hash, ACCESS_TOKEN_EXPIRE_MINUTES,
create_access_token, create_refresh_token) RequireAuth,
RequireAdmin,
RequireStaff,
authenticate_user,
get_password_hash,
decode_token,
create_access_token,
create_refresh_token,
qr_to_bitmap_b64,
)
from pathlib import Path
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
router = APIRouter(prefix="/auth", tags=["authentication"]) router = APIRouter(prefix="/auth", tags=["authentication"])
@@ -24,8 +46,7 @@ router = APIRouter(prefix="/auth", tags=["authentication"])
description="Создает нового пользователя в системе", description="Создает нового пользователя в системе",
) )
def register(user_data: UserCreate, session: Session = Depends(get_session)): def register(user_data: UserCreate, session: Session = Depends(get_session)):
"""Эндпоинт регистрации пользователя""" """Регистрирует нового пользователя в системе"""
# Проверка если username существует
existing_user = session.exec( existing_user = session.exec(
select(User).where(User.username == user_data.username) select(User).where(User.username == user_data.username)
).first() ).first()
@@ -35,7 +56,6 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
detail="Username already registered", detail="Username already registered",
) )
# Проверка если email существует
existing_email = session.exec( existing_email = session.exec(
select(User).where(User.email == user_data.email) select(User).where(User.email == user_data.email)
).first() ).first()
@@ -46,10 +66,10 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
db_user = User( db_user = User(
**user_data.model_dump(exclude={"password"}), **user_data.model_dump(exclude={"password"}),
hashed_password=get_password_hash(user_data.password) hashed_password=get_password_hash(user_data.password),
) )
default_role = session.exec(select(Role).where(Role.name == "user")).first() default_role = session.exec(select(Role).where(Role.name == "member")).first()
if default_role: if default_role:
db_user.roles.append(default_role) db_user.roles.append(default_role)
@@ -70,7 +90,7 @@ def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()], form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт аутентификации и получения JWT токена""" """Аутентифицирует пользователя и возвращает JWT токены"""
user = authenticate_user(session, form_data.username, form_data.password) user = authenticate_user(session, form_data.username, form_data.password)
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -93,14 +113,63 @@ def login(
) )
@router.post(
"/refresh",
response_model=Token,
summary="Обновление токена",
description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен",
)
def refresh_token(
refresh_token: str = Body(..., embed=True),
session: Session = Depends(get_session),
):
"""Обновляет пару токенов (access и refresh)"""
try:
token_data = decode_token(refresh_token, expected_type="refresh")
except HTTPException:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
headers={"WWW-Authenticate": "Bearer"},
)
user = session.get(User, token_data.user_id)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User is inactive",
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
new_access_token = create_access_token(
data={"sub": user.username, "user_id": user.id},
expires_delta=access_token_expires,
)
new_refresh_token = create_refresh_token(
data={"sub": user.username, "user_id": user.id}
)
return Token(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer",
)
@router.get( @router.get(
"/me", "/me",
response_model=UserRead, response_model=UserRead,
summary="Текущий пользователь", summary="Текущий пользователь",
description="Получить информацию о текущем авторизованном пользователе", description="Получить информацию о текущем авторизованном пользователе",
) )
def read_users_me(current_user: RequireAuth): def get_my_profile(current_user: RequireAuth):
"""Эндпоинт получения информации о себе""" """Возвращает информацию о текущем пользователе"""
return UserRead( return UserRead(
**current_user.model_dump(), roles=[role.name for role in current_user.roles] **current_user.model_dump(), roles=[role.name for role in current_user.roles]
) )
@@ -117,7 +186,7 @@ def update_user_me(
current_user: RequireAuth, current_user: RequireAuth,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления пользователя""" """Обновляет профиль текущего пользователя"""
if user_update.email: if user_update.email:
current_user.email = user_update.email current_user.email = user_update.email
if user_update.full_name: if user_update.full_name:
@@ -141,15 +210,18 @@ def update_user_me(
description="Получить список всех пользователей (только для админов)", description="Получить список всех пользователей (только для админов)",
) )
def read_users( def read_users(
admin: RequireAdmin, current_user: RequireStaff,
session: Session = Depends(get_session),
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
session: Session = Depends(get_session),
): ):
"""Эндпоинт получения списка всех пользователей""" """Возвращает список всех пользователей"""
users = session.exec(select(User).offset(skip).limit(limit)).all() users = session.exec(select(User).offset(skip).limit(limit)).all()
return UserList( return UserList(
users=[UserRead(**user.model_dump()) for user in users], users=[
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
for user in users
],
total=len(users), total=len(users),
) )
@@ -166,7 +238,7 @@ def add_role_to_user(
admin: RequireAdmin, admin: RequireAdmin,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт добавления роли пользователю""" """Добавляет роль пользователю"""
user = session.get(User, user_id) user = session.get(User, user_id)
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -207,7 +279,7 @@ def remove_role_from_user(
admin: RequireAdmin, admin: RequireAdmin,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления роли у пользователя""" """Удаляет роль у пользователя"""
user = session.get(User, user_id) user = session.get(User, user_id)
if not user: if not user:
raise HTTPException( raise HTTPException(
@@ -243,11 +315,33 @@ def remove_role_from_user(
description="Возвращает список ролей", description="Возвращает список ролей",
) )
def get_roles( def get_roles(
auth: RequireAuth,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт получения списа ролей""" """Возвращает список ролей в системе"""
user_roles = [role.name for role in auth.roles]
exclude = {"payroll"} if "admin" in user_roles else set()
roles = session.exec(select(Role)).all() roles = session.exec(select(Role)).all()
return RoleList( return RoleList(
roles=[RoleRead(**role.model_dump()) for role in roles], roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
total=len(roles), total=len(roles),
) )
@router.get(
"/2fa",
summary="Создание QR-кода TOTP 2FA",
description="Получить информацию о текущем авторизованном пользователе",
)
def get_totp_qr_bitmap(auth: RequireAuth):
"""Возвращает qr-код bitmap"""
issuer = "issuer"
username = auth.username
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
provisioning_uri = totp.provisioning_uri(name=username, issuer_name=issuer)
bitmap_data = qr_to_bitmap_b64(provisioning_uri)
return {"secret": secret, "username": username, "issuer": issuer, **bitmap_data}
+10 -9
View File
@@ -2,12 +2,13 @@
from fastapi import APIRouter, Depends, HTTPException, Path from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireAuth from library_service.auth import RequireStaff
from library_service.settings import get_session from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book from library_service.models.db import Author, AuthorBookLink, Book
from library_service.models.dto import (BookRead, AuthorWithBooks, from library_service.models.dto import (BookRead, AuthorWithBooks,
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate) AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
router = APIRouter(prefix="/authors", tags=["authors"]) router = APIRouter(prefix="/authors", tags=["authors"])
@@ -18,11 +19,11 @@ router = APIRouter(prefix="/authors", tags=["authors"])
description="Добавляет автора в систему", description="Добавляет автора в систему",
) )
def create_author( def create_author(
current_user: RequireAuth, current_user: RequireStaff,
author: AuthorCreate, author: AuthorCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт создания автора""" """Создает нового автора в системе"""
db_author = Author(**author.model_dump()) db_author = Author(**author.model_dump())
session.add(db_author) session.add(db_author)
session.commit() session.commit()
@@ -37,7 +38,7 @@ def create_author(
description="Возвращает список всех авторов в системе", description="Возвращает список всех авторов в системе",
) )
def read_authors(session: Session = Depends(get_session)): def read_authors(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка авторов""" """Возвращает список всех авторов"""
authors = session.exec(select(Author)).all() authors = session.exec(select(Author)).all()
return AuthorList( return AuthorList(
authors=[AuthorRead(**author.model_dump()) for author in authors], authors=[AuthorRead(**author.model_dump()) for author in authors],
@@ -55,7 +56,7 @@ def get_author(
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретного автора""" """Возвращает информацию об авторе и его книгах"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
@@ -79,12 +80,12 @@ def get_author(
description="Обновляет информацию об авторе в системе", description="Обновляет информацию об авторе в системе",
) )
def update_author( def update_author(
current_user: RequireAuth, current_user: RequireStaff,
author: AuthorUpdate, author: AuthorUpdate,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления автора""" """Обновляет информацию об авторе"""
db_author = session.get(Author, author_id) db_author = session.get(Author, author_id)
if not db_author: if not db_author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
@@ -105,11 +106,11 @@ def update_author(
description="Удаляет автора из системы", description="Удаляет автора из системы",
) )
def delete_author( def delete_author(
current_user: RequireAuth, current_user: RequireStaff,
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0), author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления автора""" """Удаляет автора из системы"""
author = session.get(Author, author_id) author = session.get(Author, author_id)
if not author: if not author:
raise HTTPException(status_code=404, detail="Author not found") raise HTTPException(status_code=404, detail="Author not found")
+54 -19
View File
@@ -1,12 +1,14 @@
"""Модуль работы с книгами""" """Модуль работы с книгами"""
from datetime import datetime
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, Path, Query from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlmodel import Session, select, col, func from sqlmodel import Session, select, col, func
from library_service.auth import RequireAuth from library_service.auth import RequireStaff
from library_service.settings import get_session from library_service.settings import get_session
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre from library_service.models.enums import BookStatus
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink, Genre, BookUserLink
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
from library_service.models.dto.combined import ( from library_service.models.dto.combined import (
BookWithAuthorsAndGenres, BookWithAuthorsAndGenres,
@@ -17,6 +19,19 @@ from library_service.models.dto.combined import (
router = APIRouter(prefix="/books", tags=["books"]) router = APIRouter(prefix="/books", tags=["books"])
def close_active_loan(session: Session, book_id: int) -> None:
"""Закрывает активную выдачу книги при изменении статуса"""
active_loan = session.exec(
select(BookUserLink)
.where(BookUserLink.book_id == book_id)
.where(BookUserLink.returned_at == None) # noqa: E711
).first()
if active_loan:
active_loan.returned_at = datetime.utcnow()
session.add(active_loan)
@router.get( @router.get(
"/filter", "/filter",
response_model=BookFilteredList, response_model=BookFilteredList,
@@ -25,13 +40,13 @@ router = APIRouter(prefix="/books", tags=["books"])
) )
def filter_books( def filter_books(
session: Session = Depends(get_session), session: Session = Depends(get_session),
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"), q: str | None = Query(None, max_length=50, description="Поиск"),
author_ids: List[int] | None = Query(None, description="Список ID авторов"), author_ids: List[int] | None = Query(None, description="Список ID авторов"),
genre_ids: List[int] | None = Query(None, description="Список ID жанров"), genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
page: int = Query(1, gt=0, description="Номер страницы"), page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"), size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
): ):
"""Эндпоинт получения отфильтрованного списка книг""" """Возвращает отфильтрованный список книг с пагинацией"""
statement = select(Book).distinct() statement = select(Book).distinct()
if q: if q:
@@ -67,14 +82,16 @@ def filter_books(
@router.post( @router.post(
"/", "/",
response_model=Book, response_model=BookRead,
summary="Создать книгу", summary="Создать книгу",
description="Добавляет книгу в систему", description="Добавляет книгу в систему",
) )
def create_book( def create_book(
current_user: RequireAuth, book: BookCreate, session: Session = Depends(get_session) book: BookCreate,
current_user: RequireStaff,
session: Session = Depends(get_session)
): ):
"""Эндпоинт создания книги""" """Создает новую книгу в системе"""
db_book = Book(**book.model_dump()) db_book = Book(**book.model_dump())
session.add(db_book) session.add(db_book)
session.commit() session.commit()
@@ -89,7 +106,7 @@ def create_book(
description="Возвращает список всех книг в системе", description="Возвращает список всех книг в системе",
) )
def read_books(session: Session = Depends(get_session)): def read_books(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка книг""" """Возвращает список всех книг"""
books = session.exec(select(Book)).all() books = session.exec(select(Book)).all()
return BookList( return BookList(
books=[BookRead(**book.model_dump()) for book in books], total=len(books) books=[BookRead(**book.model_dump()) for book in books], total=len(books)
@@ -106,7 +123,7 @@ def get_book(
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретной книги""" """Возвращает информацию о книге с авторами и жанрами"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
@@ -137,21 +154,39 @@ def get_book(
description="Обновляет информацию о книге в системе", description="Обновляет информацию о книге в системе",
) )
def update_book( def update_book(
current_user: RequireAuth, current_user: RequireStaff,
book: BookUpdate, book_update: BookUpdate,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления книги""" """Обновляет информацию о книге"""
db_book = session.get(Book, book_id) db_book = session.get(Book, book_id)
if not db_book: if not db_book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
db_book.title = book.title or db_book.title if book_update.status is not None:
db_book.description = book.description or db_book.description if book_update.status == BookStatus.BORROWED:
raise HTTPException(
status_code=400,
detail="Статус 'borrowed' устанавливается только через выдачу книги"
)
if db_book.status == BookStatus.BORROWED:
close_active_loan(session, book_id)
db_book.status = book_update.status
if book_update.title is not None or book_update.description is not None:
if book_update.title is not None:
db_book.title = book_update.title
if book_update.description is not None:
db_book.description = book_update.description
session.add(db_book)
session.commit() session.commit()
session.refresh(db_book) session.refresh(db_book)
return db_book
return BookRead(**db_book.model_dump())
@router.delete( @router.delete(
@@ -161,16 +196,16 @@ def update_book(
description="Удаляет книгу их системы", description="Удаляет книгу их системы",
) )
def delete_book( def delete_book(
current_user: RequireAuth, current_user: RequireStaff,
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0), book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления книги""" """Удаляет книгу из системы"""
book = session.get(Book, book_id) book = session.get(Book, book_id)
if not book: if not book:
raise HTTPException(status_code=404, detail="Book not found") raise HTTPException(status_code=404, detail="Book not found")
book_read = BookRead( book_read = BookRead(
id=(book.id or 0), title=book.title, description=book.description id=(book.id or 0), title=book.title, description=book.description, status=book.status
) )
session.delete(book) session.delete(book)
session.commit() session.commit()
+12 -11
View File
@@ -2,11 +2,12 @@
from fastapi import APIRouter, Depends, HTTPException, Path from fastapi import APIRouter, Depends, HTTPException, Path
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireAuth from library_service.auth import RequireStaff
from library_service.models.db import Book, Genre, GenreBookLink 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 from library_service.settings import get_session
router = APIRouter(prefix="/genres", tags=["genres"]) router = APIRouter(prefix="/genres", tags=["genres"])
@@ -17,11 +18,11 @@ router = APIRouter(prefix="/genres", tags=["genres"])
description="Добавляет жанр книг в систему", description="Добавляет жанр книг в систему",
) )
def create_genre( def create_genre(
current_user: RequireAuth, current_user: RequireStaff,
genre: GenreCreate, genre: GenreCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт создания жанра""" """Создает новый жанр в системе"""
db_genre = Genre(**genre.model_dump()) db_genre = Genre(**genre.model_dump())
session.add(db_genre) session.add(db_genre)
session.commit() session.commit()
@@ -36,7 +37,7 @@ def create_genre(
description="Возвращает список всех жанров в системе", description="Возвращает список всех жанров в системе",
) )
def read_genres(session: Session = Depends(get_session)): def read_genres(session: Session = Depends(get_session)):
"""Эндпоинт чтения списка жанров""" """Возвращает список всех жанров"""
genres = session.exec(select(Genre)).all() genres = session.exec(select(Genre)).all()
return GenreList( return GenreList(
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres) genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
@@ -53,7 +54,7 @@ def get_genre(
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт чтения конкретного жанра""" """Возвращает информацию о жанре и книгах с ним"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
@@ -73,16 +74,16 @@ def get_genre(
@router.put( @router.put(
"/{genre_id}", "/{genre_id}",
response_model=GenreRead, response_model=GenreRead,
summary="Обновляет информацию о жанре", summary="Обновить информацию о жанре",
description="Обновляет информацию о жанре в системе", description="Обновляет информацию о жанре в системе",
) )
def update_genre( def update_genre(
current_user: RequireAuth, current_user: RequireStaff,
genre: GenreUpdate, genre: GenreUpdate,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт обновления жанра""" """Обновляет информацию о жанре"""
db_genre = session.get(Genre, genre_id) db_genre = session.get(Genre, genre_id)
if not db_genre: if not db_genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
@@ -100,14 +101,14 @@ def update_genre(
"/{genre_id}", "/{genre_id}",
response_model=GenreRead, response_model=GenreRead,
summary="Удалить жанр", summary="Удалить жанр",
description="Удаляет автора из системы", description="Удаляет жанр из системы",
) )
def delete_genre( def delete_genre(
current_user: RequireAuth, current_user: RequireStaff,
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0), genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления жанра""" """Удаляет жанр из системы"""
genre = session.get(Genre, genre_id) genre = session.get(Genre, genre_id)
if not genre: if not genre:
raise HTTPException(status_code=404, detail="Genre not found") raise HTTPException(status_code=404, detail="Genre not found")
+506
View File
@@ -0,0 +1,506 @@
"""Модуль работы с выдачей и бронированием книг"""
from datetime import datetime, timedelta
from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status
from fastapi.responses import JSONResponse
from sqlmodel import Session, select, col, func
from sqlalchemy import cast, Date
from library_service.auth import RequireAuth, RequireStaff, RequireAdmin, is_user_staff
from library_service.settings import get_session
from library_service.models.db import Book, User, BookUserLink
from library_service.models.dto import LoanCreate, LoanRead, LoanList, LoanUpdate
from library_service.models.enums import BookStatus
router = APIRouter(prefix="/loans", tags=["loans"])
@router.post(
"/",
response_model=LoanRead,
summary="Создать выдачу/бронь",
description="Создает запись о выдаче или бронировании книги",
)
def create_loan(
current_user: RequireAuth,
loan: LoanCreate,
session: Session = Depends(get_session),
):
"""Создает выдачу или бронирование книги"""
is_staff = is_user_staff(current_user)
if not is_staff and loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only create loans for yourself"
)
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400,
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")
db_loan = BookUserLink(
book_id=loan.book_id,
user_id=loan.user_id,
due_date=loan.due_date,
borrowed_at=datetime.utcnow()
)
book.status = BookStatus.RESERVED
session.add(db_loan)
session.add(book)
session.commit()
session.refresh(db_loan)
return LoanRead(**db_loan.model_dump())
@router.get(
"/",
response_model=LoanList,
summary="Получить список выдач",
description="Возвращает список выдач. Читатели видят только свои. Сотрудники видят все.",
)
def read_loans(
current_user: RequireAuth,
session: Session = Depends(get_session),
user_id: int | None = Query(None, description="Фильтр по user_ID"),
book_id: int | None = Query(None, description="Фильтр по book_ID"),
active_only: bool = Query(False, description="Только не возвращенные выдачи"),
page: int = Query(1, gt=0, description="Номер страницы"),
size: int = Query(20, gt=0, lt=101, description="Элементов на странице"),
):
"""Возвращает список выдач с фильтрацией и пагинацией"""
is_staff = is_user_staff(current_user)
statement = select(BookUserLink)
if not is_staff:
statement = statement.where(BookUserLink.user_id == current_user.id)
elif user_id is not None:
statement = statement.where(BookUserLink.user_id == user_id)
if book_id is not None:
statement = statement.where(BookUserLink.book_id == book_id)
if active_only:
statement = statement.where(BookUserLink.returned_at == None) # noqa: E711
total_statement = select(func.count()).select_from(statement.subquery())
total = session.exec(total_statement).one()
offset = (page - 1) * size
statement = statement.order_by(col(BookUserLink.borrowed_at).desc())
statement = statement.offset(offset).limit(size)
loans = session.exec(statement).all()
return LoanList(
loans=[LoanRead(**loan.model_dump()) for loan in loans],
total=total
)
@router.get(
"/analytics",
summary="Аналитика выдач и возвратов",
description="Возвращает аналитику выдач и возвратов. Только для админов.",
)
def get_loans_analytics(
current_user: RequireAdmin,
days: int = Query(30, ge=1, le=365, description="Количество дней для анализа"),
session: Session = Depends(get_session),
):
"""Возвращает аналитику по выдачам и возвратам книг"""
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
total_loans = session.exec(
select(func.count(BookUserLink.id))
.where(BookUserLink.borrowed_at >= start_date)
).one()
active_loans = session.exec(
select(func.count(BookUserLink.id))
.where(BookUserLink.borrowed_at >= start_date)
.where(BookUserLink.returned_at == None) # noqa: E711
).one()
returned_loans = session.exec(
select(func.count(BookUserLink.id))
.where(BookUserLink.borrowed_at >= start_date)
.where(BookUserLink.returned_at != None) # noqa: E711
).one()
overdue_loans = session.exec(
select(func.count(BookUserLink.id))
.where(BookUserLink.returned_at == None) # noqa: E711
.where(BookUserLink.due_date < end_date)
).one()
daily_loans = {}
daily_returns = {}
loans_by_date = session.exec(
select(
cast(BookUserLink.borrowed_at, Date).label("date"),
func.count(BookUserLink.id).label("count")
)
.where(BookUserLink.borrowed_at >= start_date)
.group_by(cast(BookUserLink.borrowed_at, Date))
.order_by(cast(BookUserLink.borrowed_at, Date))
).all()
returns_by_date = session.exec(
select(
cast(BookUserLink.returned_at, Date).label("date"),
func.count(BookUserLink.id).label("count")
)
.where(BookUserLink.returned_at >= start_date)
.where(BookUserLink.returned_at != None) # noqa: E711
.group_by(cast(BookUserLink.returned_at, Date))
.order_by(cast(BookUserLink.returned_at, Date))
).all()
for row in loans_by_date:
date_str = str(row[0]) if isinstance(row, tuple) else str(row.date)
count = row[1] if isinstance(row, tuple) else row.count
daily_loans[date_str] = count
for row in returns_by_date:
date_str = str(row[0]) if isinstance(row, tuple) else str(row.date)
count = row[1] if isinstance(row, tuple) else row.count
daily_returns[date_str] = count
top_books = session.exec(
select(
BookUserLink.book_id,
func.count(BookUserLink.id).label("loan_count")
)
.where(BookUserLink.borrowed_at >= start_date)
.group_by(BookUserLink.book_id)
.order_by(func.count(BookUserLink.id).desc())
.limit(10)
).all()
top_books_data = []
for row in top_books:
book_id = row[0] if isinstance(row, tuple) else row.book_id
loan_count = row[1] if isinstance(row, tuple) else row.loan_count
book = session.get(Book, book_id)
if book:
top_books_data.append({
"book_id": book_id,
"title": book.title,
"loan_count": loan_count
})
reserved_count = session.exec(
select(func.count(Book.id))
.where(Book.status == BookStatus.RESERVED)
).one()
borrowed_count = session.exec(
select(func.count(Book.id))
.where(Book.status == BookStatus.BORROWED)
).one()
return JSONResponse(content={
"summary": {
"total_loans": total_loans,
"active_loans": active_loans,
"returned_loans": returned_loans,
"overdue_loans": overdue_loans,
"reserved_books": reserved_count,
"borrowed_books": borrowed_count,
},
"daily_loans": daily_loans,
"daily_returns": daily_returns,
"top_books": top_books_data,
"period_days": days,
"start_date": start_date.isoformat(),
"end_date": end_date.isoformat(),
})
@router.get(
"/{loan_id}",
response_model=LoanRead,
summary="Получить выдачу по ID",
description="Возвращает выдачу по ID",
)
def get_loan(
current_user: RequireAuth,
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Возвращает информацию о выдаче по ID"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
is_staff = is_user_staff(current_user)
if not is_staff and loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied to this loan"
)
return LoanRead(**loan.model_dump())
@router.put(
"/{loan_id}",
response_model=LoanRead,
summary="Обновить выдачу",
description="Обновляет информацию о выдаче. Сотрудники могут обновлять любые, читатели только свои.",
)
def update_loan(
current_user: RequireAuth,
loan_update: LoanUpdate,
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Обновляет информацию о выдаче"""
db_loan = session.get(BookUserLink, loan_id)
if not db_loan:
raise HTTPException(status_code=404, detail="Loan not found")
is_staff = is_user_staff(current_user)
if not is_staff and db_loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only update your own loans"
)
book = session.get(Book, db_loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
if loan_update.user_id is not None:
if not is_staff:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only staff can change loan user"
)
new_user = session.get(User, loan_update.user_id)
if not new_user:
raise HTTPException(status_code=404, detail="User not found")
db_loan.user_id = loan_update.user_id
if loan_update.due_date is not None:
db_loan.due_date = loan_update.due_date
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"
)
db_loan.returned_at = loan_update.returned_at
book.status = BookStatus.ACTIVE
session.add(db_loan)
session.add(book)
session.commit()
session.refresh(db_loan)
return LoanRead(**db_loan.model_dump())
@router.post(
"/{loan_id}/confirm",
response_model=LoanRead,
summary="Подтвердить бронь",
description="Подтверждает бронирование и меняет статус книги на BORROWED",
)
def confirm_loan(
current_user: RequireStaff,
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Подтверждает бронирование и меняет статус книги на BORROWED"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned")
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
raise HTTPException(
status_code=400,
detail=f"Cannot confirm loan for book with status: {book.status}"
)
book.status = BookStatus.BORROWED
session.add(loan)
session.add(book)
session.commit()
session.refresh(loan)
return LoanRead(**loan.model_dump())
@router.post(
"/{loan_id}/return",
response_model=LoanRead,
summary="Вернуть книгу",
description="Возвращает книгу и закрывает выдачу",
)
def return_loan(
current_user: RequireStaff,
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Возвращает книгу и закрывает выдачу"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
if loan.returned_at:
raise HTTPException(status_code=400, detail="Loan is already returned")
loan.returned_at = datetime.utcnow()
book = session.get(Book, loan.book_id)
if book:
book.status = BookStatus.ACTIVE
session.add(book)
session.add(loan)
session.commit()
session.refresh(loan)
return LoanRead(**loan.model_dump())
@router.delete(
"/{loan_id}",
response_model=LoanRead,
summary="Удалить выдачу/бронь",
description="Удаляет выдачу/бронь. Работает только для статуса RESERVED.",
)
def delete_loan(
current_user: RequireAuth,
loan_id: int = Path(..., description="Loan ID (integer, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Удаляет выдачу или бронирование (только для RESERVED статуса)"""
loan = session.get(BookUserLink, loan_id)
if not loan:
raise HTTPException(status_code=404, detail="Loan not found")
is_staff = is_user_staff(current_user)
if not is_staff and loan.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own loans"
)
book = session.get(Book, loan.book_id)
if book and book.status != BookStatus.RESERVED:
raise HTTPException(
status_code=400,
detail="Can only delete reservations. Use update endpoint to return borrowed books"
)
loan_read = LoanRead(**loan.model_dump())
session.delete(loan)
if book:
book.status = BookStatus.ACTIVE
session.add(book)
session.commit()
return loan_read
@router.get(
"/book/{book_id}/active",
response_model=LoanRead | None,
summary="Получить активную выдачу книги",
description="Возвращает активную выдачу для указанной книги",
)
def get_active_loan_for_book(
current_user: RequireStaff,
book_id: int = Path(..., description="Book ID (integer, > 0)", gt=0),
session: Session = Depends(get_session),
):
"""Возвращает активную выдачу для указанной книги"""
loan = session.exec(
select(BookUserLink)
.where(BookUserLink.book_id == book_id)
.where(BookUserLink.returned_at == None) # noqa: E711
).first()
if not loan:
return None
return LoanRead(**loan.model_dump())
@router.post(
"/issue",
response_model=LoanRead,
summary="Выдать книгу напрямую",
description="Только для администраторов. Создает выдачу и устанавливает статус книги на BORROWED.",
)
def issue_book_directly(
current_user: RequireAdmin,
loan: LoanCreate,
session: Session = Depends(get_session),
):
"""Выдает книгу напрямую без бронирования (только для администраторов)"""
book = session.get(Book, loan.book_id)
if not book:
raise HTTPException(status_code=404, detail="Book not found")
if book.status != BookStatus.ACTIVE:
raise HTTPException(
status_code=400,
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")
db_loan = BookUserLink(
book_id=loan.book_id,
user_id=loan.user_id,
due_date=loan.due_date,
borrowed_at=datetime.utcnow()
)
book.status = BookStatus.BORROWED
session.add(db_loan)
session.add(book)
session.commit()
session.refresh(db_loan)
return LoanRead(**db_loan.model_dump())
+83 -20
View File
@@ -1,4 +1,5 @@
"""Модуль прочих эндпоинтов""" """Модуль прочих эндпоинтов"""
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict from typing import Dict
@@ -18,13 +19,13 @@ templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates"
def get_info(app) -> Dict: def get_info(app) -> Dict:
"""Форматированная информация о приложении""" """Возвращает информацию о приложении"""
return { return {
"status": "ok", "status": "ok",
"app_info": { "app_info": {
"title": app.title, "title": app.title,
"version": app.version, "version": app.version,
"description": app.description.rsplit('|', 1)[0], "description": app.description.rsplit("|", 1)[0],
}, },
"server_time": datetime.now().isoformat(), "server_time": datetime.now().isoformat(),
} }
@@ -32,61 +33,121 @@ def get_info(app) -> Dict:
@router.get("/", include_in_schema=False) @router.get("/", include_in_schema=False)
async def root(request: Request): async def root(request: Request):
"""Эндпоинт главной страницы""" """Рендерит главную страницу"""
return templates.TemplateResponse(request, "index.html") return templates.TemplateResponse(request, "index.html")
@router.get("/genre/create", include_in_schema=False)
async def create_genre(request: Request):
"""Рендерит страницу создания жанра"""
return templates.TemplateResponse(request, "create_genre.html")
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
async def edit_genre(request: Request, genre_id: int):
"""Рендерит страницу редактирования жанра"""
return templates.TemplateResponse(request, "edit_genre.html")
@router.get("/authors", include_in_schema=False) @router.get("/authors", include_in_schema=False)
async def authors(request: Request): async def authors(request: Request):
"""Эндпоинт страницы выбора автора""" """Рендерит страницу списка авторов"""
return templates.TemplateResponse(request, "authors.html") return templates.TemplateResponse(request, "authors.html")
@router.get("/author/create", include_in_schema=False)
async def create_author(request: Request):
"""Рендерит страницу создания автора"""
return templates.TemplateResponse(request, "create_author.html")
@router.get("/author/{author_id}/edit", include_in_schema=False)
async def edit_author(request: Request, author_id: int):
"""Рендерит страницу редактирования автора"""
return templates.TemplateResponse(request, "edit_author.html")
@router.get("/author/{author_id}", include_in_schema=False) @router.get("/author/{author_id}", include_in_schema=False)
async def author(request: Request, author_id: int): async def author(request: Request, author_id: int):
"""Эндпоинт страницы автора""" """Рендерит страницу просмотра автора"""
return templates.TemplateResponse(request, "author.html") return templates.TemplateResponse(request, "author.html")
@router.get("/books", include_in_schema=False) @router.get("/books", include_in_schema=False)
async def books(request: Request): async def books(request: Request):
"""Эндпоинт страницы выбора книг""" """Рендерит страницу списка книг"""
return templates.TemplateResponse(request, "books.html") return templates.TemplateResponse(request, "books.html")
@router.get("/book/create", include_in_schema=False)
async def create_book(request: Request):
"""Рендерит страницу создания книги"""
return templates.TemplateResponse(request, "create_book.html")
@router.get("/book/{book_id}/edit", include_in_schema=False)
async def edit_book(request: Request, book_id: int):
"""Рендерит страницу редактирования книги"""
return templates.TemplateResponse(request, "edit_book.html")
@router.get("/book/{book_id}", include_in_schema=False) @router.get("/book/{book_id}", include_in_schema=False)
async def book(request: Request, book_id: int): async def book(request: Request, book_id: int):
"""Эндпоинт страницы книги""" """Рендерит страницу просмотра книги"""
return templates.TemplateResponse(request, "book.html") return templates.TemplateResponse(request, "book.html")
@router.get("/auth", include_in_schema=False) @router.get("/auth", include_in_schema=False)
async def auth(request: Request): async def auth(request: Request):
"""Эндпоинт страницы авторизации""" """Рендерит страницу авторизации"""
return templates.TemplateResponse(request, "auth.html") return templates.TemplateResponse(request, "auth.html")
@router.get("/set-2fa", include_in_schema=False)
async def set2fa(request: Request):
"""Рендерит страницу установки двухфакторной аутентификации"""
return templates.TemplateResponse(request, "2fa.html")
@router.get("/profile", include_in_schema=False) @router.get("/profile", include_in_schema=False)
async def profile(request: Request): async def profile(request: Request):
"""Эндпоинт страницы профиля""" """Рендерит страницу профиля пользователя"""
return templates.TemplateResponse(request, "profile.html") return templates.TemplateResponse(request, "profile.html")
@router.get("/users", include_in_schema=False)
async def users(request: Request):
"""Рендерит страницу управления пользователями"""
return templates.TemplateResponse(request, "users.html")
@router.get("/my-books", include_in_schema=False)
async def my_books(request: Request):
"""Рендерит страницу моих книг пользователя"""
return templates.TemplateResponse(request, "my_books.html")
@router.get("/analytics", include_in_schema=False)
async def analytics(request: Request):
"""Рендерит страницу аналитики выдач"""
return templates.TemplateResponse(request, "analytics.html")
@router.get("/api", include_in_schema=False) @router.get("/api", include_in_schema=False)
async def api(request: Request, app=Depends(lambda: get_app())): async def api(request: Request, app=Depends(lambda: get_app())):
"""Страница с сылками на документацию API""" """Рендерит страницу с ссылками на документацию API"""
return templates.TemplateResponse(request, "api.html", get_info(app)) return templates.TemplateResponse(request, "api.html", get_info(app))
@router.get("/favicon.ico", include_in_schema=False) @router.get("/favicon.ico", include_in_schema=False)
def redirect_favicon(): def redirect_favicon():
"""Редирект иконки вкладки""" """Редиректит на favicon.svg"""
return RedirectResponse("/favicon.svg") return RedirectResponse("/favicon.svg")
@router.get("/favicon.svg", include_in_schema=False) @router.get("/favicon.svg", include_in_schema=False)
async def favicon(): async def favicon():
"""Эндпоинт иконки вкладки""" """Возвращает иконку сайта"""
return FileResponse( return FileResponse(
"library_service/static/favicon.svg", media_type="image/svg+xml" "library_service/static/favicon.svg", media_type="image/svg+xml"
) )
@@ -98,7 +159,7 @@ async def favicon():
description="Возвращает общую информацию о системе", description="Возвращает общую информацию о системе",
) )
async def api_info(app=Depends(lambda: get_app())): async def api_info(app=Depends(lambda: get_app())):
"""Эндпоинт информации об API""" """Возвращает информацию о сервисе"""
return JSONResponse(content=get_info(app)) return JSONResponse(content=get_info(app))
@@ -108,14 +169,16 @@ async def api_info(app=Depends(lambda: get_app())):
description="Возвращает статистическую информацию о системе", description="Возвращает статистическую информацию о системе",
) )
async def api_stats(session: Session = Depends(get_session)): async def api_stats(session: Session = Depends(get_session)):
"""Эндпоинт стстистики системы""" """Возвращает статистику системы"""
authors = select(func.count()).select_from(Author) authors = select(func.count()).select_from(Author)
books = select(func.count()).select_from(Book) books = select(func.count()).select_from(Book)
genres = select(func.count()).select_from(Genre) genres = select(func.count()).select_from(Genre)
users = select(func.count()).select_from(User) users = select(func.count()).select_from(User)
return JSONResponse(content={ return JSONResponse(
"authors": session.exec(authors).one(), content={
"books": session.exec(books).one(), "authors": session.exec(authors).one(),
"genres": session.exec(genres).one(), "books": session.exec(books).one(),
"users": session.exec(users).one(), "genres": session.exec(genres).one(),
}) "users": session.exec(users).one(),
}
)
+17 -17
View File
@@ -4,7 +4,7 @@ from typing import Dict, List
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select from sqlmodel import Session, select
from library_service.auth import RequireAuth from library_service.auth import RequireStaff
from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink
from library_service.models.dto import AuthorRead, BookRead, GenreRead from library_service.models.dto import AuthorRead, BookRead, GenreRead
from library_service.settings import get_session from library_service.settings import get_session
@@ -14,7 +14,7 @@ router = APIRouter(tags=["relations"])
def check_entity_exists(session, model, entity_id, entity_name): def check_entity_exists(session, model, entity_id, entity_name):
"""Проверка существования связи между сущностями в БД""" """Проверяет существование сущности в базе данных"""
entity = session.get(model, entity_id) entity = session.get(model, entity_id)
if not entity: if not entity:
raise HTTPException(status_code=404, detail=f"{entity_name} not found") raise HTTPException(status_code=404, detail=f"{entity_name} not found")
@@ -22,7 +22,7 @@ def check_entity_exists(session, model, entity_id, entity_name):
def add_relationship(session, link_model, id1, field1, id2, field2, detail): def add_relationship(session, link_model, id1, field1, id2, field2, detail):
"""Создание связи между сущностями в БД""" """Создает связь между сущностями в базе данных"""
existing_link = session.exec( existing_link = session.exec(
select(link_model) select(link_model)
.where(getattr(link_model, field1) == id1) .where(getattr(link_model, field1) == id1)
@@ -40,7 +40,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
def remove_relationship(session, link_model, id1, field1, id2, field2): def remove_relationship(session, link_model, id1, field1, id2, field2):
"""Удаление связи между сущностями в БД""" """Удаляет связь между сущностями в базе данных"""
link = session.exec( link = session.exec(
select(link_model) select(link_model)
.where(getattr(link_model, field1) == id1) .where(getattr(link_model, field1) == id1)
@@ -66,7 +66,7 @@ def get_related(
link_related_field, link_related_field,
read_model read_model
): ):
"""Получение связанных в БД сущностей""" """Возвращает список связанных сущностей"""
check_entity_exists(session, main_model, main_id, main_name) check_entity_exists(session, main_model, main_id, main_name)
related = session.exec( related = session.exec(
@@ -84,12 +84,12 @@ def get_related(
description="Добавляет связь между автором и книгой в систему", description="Добавляет связь между автором и книгой в систему",
) )
def add_author_to_book( def add_author_to_book(
current_user: RequireAuth, current_user: RequireStaff,
author_id: int, author_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт добавления автора к книге""" """Добавляет связь между автором и книгой"""
check_entity_exists(session, Author, author_id, "Author") check_entity_exists(session, Author, author_id, "Author")
check_entity_exists(session, Book, book_id, "Book") check_entity_exists(session, Book, book_id, "Book")
@@ -104,12 +104,12 @@ def add_author_to_book(
description="Удаляет связь между автором и книгой в системе", description="Удаляет связь между автором и книгой в системе",
) )
def remove_author_from_book( def remove_author_from_book(
current_user: RequireAuth, current_user: RequireStaff,
author_id: int, author_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления автора из книги""" """Удаляет связь между автором и книгой"""
return remove_relationship(session, AuthorBookLink, return remove_relationship(session, AuthorBookLink,
author_id, "author_id", book_id, "book_id") author_id, "author_id", book_id, "book_id")
@@ -121,7 +121,7 @@ def remove_author_from_book(
description="Возвращает все книги в системе, написанные автором", description="Возвращает все книги в системе, написанные автором",
) )
def get_books_for_author(author_id: int, session: Session = Depends(get_session)): def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения книг, написанных автором""" """Возвращает список книг автора"""
return get_related(session, return get_related(session,
Author, author_id, "Author", Book, Author, author_id, "Author", Book,
AuthorBookLink, "author_id", "book_id", BookRead) AuthorBookLink, "author_id", "book_id", BookRead)
@@ -134,7 +134,7 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
description="Возвращает всех авторов книги в системе", description="Возвращает всех авторов книги в системе",
) )
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)): def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения авторов книги""" """Возвращает список авторов книги"""
return get_related(session, return get_related(session,
Book, book_id, "Book", Author, Book, book_id, "Book", Author,
AuthorBookLink, "book_id", "author_id", AuthorRead) AuthorBookLink, "book_id", "author_id", AuthorRead)
@@ -147,12 +147,12 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
description="Добавляет связь между книгой и жанром в систему", description="Добавляет связь между книгой и жанром в систему",
) )
def add_genre_to_book( def add_genre_to_book(
current_user: RequireAuth, current_user: RequireStaff,
genre_id: int, genre_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт добавления жанра к книге""" """Добавляет связь между жанром и книгой"""
check_entity_exists(session, Genre, genre_id, "Genre") check_entity_exists(session, Genre, genre_id, "Genre")
check_entity_exists(session, Book, book_id, "Book") check_entity_exists(session, Book, book_id, "Book")
@@ -167,12 +167,12 @@ def add_genre_to_book(
description="Удаляет связь между жанром и книгой в системе", description="Удаляет связь между жанром и книгой в системе",
) )
def remove_genre_from_book( def remove_genre_from_book(
current_user: RequireAuth, current_user: RequireStaff,
genre_id: int, genre_id: int,
book_id: int, book_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
): ):
"""Эндпоинт удаления жанра из книги""" """Удаляет связь между жанром и книгой"""
return remove_relationship(session, GenreBookLink, return remove_relationship(session, GenreBookLink,
genre_id, "genre_id", book_id, "book_id") genre_id, "genre_id", book_id, "book_id")
@@ -184,7 +184,7 @@ def remove_genre_from_book(
description="Возвращает все книги в системе в этом жанре", description="Возвращает все книги в системе в этом жанре",
) )
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)): def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения книг с жанром""" """Возвращает список книг в жанре"""
return get_related(session, return get_related(session,
Genre, genre_id, "Genre", Book, Genre, genre_id, "Genre", Book,
GenreBookLink, "genre_id", "book_id", BookRead) GenreBookLink, "genre_id", "book_id", BookRead)
@@ -197,7 +197,7 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
description="Возвращает все жанры книги в системе", description="Возвращает все жанры книги в системе",
) )
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)): def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
"""Эндпоинт получения жанров книги""" """Возвращает список жанров книги"""
return get_related(session, return get_related(session,
Book, book_id, "Book", Genre, Book, book_id, "Book", Genre,
GenreBookLink, "book_id", "genre_id", GenreRead) GenreBookLink, "book_id", "genre_id", GenreRead)
+76 -50
View File
@@ -1,5 +1,7 @@
"""Модуль настроек проекта""" """Модуль настроек проекта"""
import os, logging import os, logging
from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI from fastapi import FastAPI
@@ -8,46 +10,81 @@ from toml import load
load_dotenv() load_dotenv()
with open("pyproject.toml", 'r', encoding='utf-8') as f: with open("pyproject.toml", "r", encoding="utf-8") as f:
config = load(f) _pyproject = load(f)
_APP_NAME = "library_service"
LOGGING_CONFIG = {
"version": 1,
"disable_existing_loggers": True,
"formatters": {
"json": {
"class": "json_log_formatter.JSONFormatter",
"format": "%(asctime)s %(name)s %(levelname)s %(message)s %(pathname)s %(lineno)d",
},
},
"handlers": {
"console": {
"()": "rich.logging.RichHandler",
"level": "INFO",
"show_time": True,
"show_path": True,
"rich_tracebacks": True,
},
"file": {
"class": "logging.FileHandler",
"filename": Path(__file__).parent / "app.log",
"formatter": "json",
"level": "INFO",
},
},
"loggers": {
"uvicorn": {
"handlers": [],
"level": "INFO",
"propagate": False,
},
_APP_NAME: {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
},
}
OPENAPI_TAGS = [
{"name": "authentication", "description": "Авторизация пользователя."},
{"name": "authors", "description": "Действия с авторами."},
{"name": "books", "description": "Действия с книгами."},
{"name": "genres", "description": "Действия с жанрами."},
{"name": "loans", "description": "Действия с выдачами."},
{"name": "relations", "description": "Действия со связями."},
{"name": "misc", "description": "Прочие."},
]
def get_app(lifespan=None, /) -> FastAPI: def get_app(lifespan=None, /) -> FastAPI:
"""Dependency для получения экземпляра FastAPI application""" """Возвращает экземпляр FastAPI приложения"""
if not hasattr(get_app, 'instance'): project_cfg = _pyproject["project"]
get_app.instance = FastAPI( return FastAPI(
title=config["tool"]["poetry"]["name"], title=project_cfg["name"],
description=config["tool"]["poetry"]["description"] + " | [Вернутьсяна главную](/)", description=f"{project_cfg['description']} | [Вернуться на главную](/)",
version=config["tool"]["poetry"]["version"], version=project_cfg["version"],
lifespan=lifespan, lifespan=lifespan,
openapi_tags=[ openapi_tags=OPENAPI_TAGS,
{ )
"name": "authentication",
"description": "Авторизация пользователя."
}, def get_logger(name: str = _APP_NAME) -> logging.Logger:
{ """Возвращает логгер с указанным именем"""
"name": "authors", return logging.getLogger(name)
"description": "Действия с авторами.",
},
{ def get_session():
"name": "books", """Возвращает сессию базы данных"""
"description": "Действия с книгами.", with Session(engine) as session:
}, yield session
{
"name": "genres",
"description": "Действия с жанрами.",
},
{
"name": "relations",
"description": "Действия с связями.",
},
{
"name": "misc",
"description": "Прочие.",
},
],
)
return get_app.instance
HOST = os.getenv("POSTGRES_HOST") HOST = os.getenv("POSTGRES_HOST")
@@ -56,19 +93,8 @@ USER = os.getenv("POSTGRES_USER")
PASSWORD = os.getenv("POSTGRES_PASSWORD") PASSWORD = os.getenv("POSTGRES_PASSWORD")
DATABASE = os.getenv("POSTGRES_DB") DATABASE = os.getenv("POSTGRES_DB")
if not USER or not PASSWORD or not DATABASE or not HOST: if not all([HOST, PORT, USER, PASSWORD, DATABASE]):
raise ValueError("Missing environment variables") raise ValueError("Missing required POSTGRES environment variables")
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}" POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
engine = create_engine(POSTGRES_DATABASE_URL, echo=False, future=True) engine = create_engine(POSTGRES_DATABASE_URL, echo=False, future=True)
def get_session():
"""Dependency, для получение сессии БД"""
with Session(engine) as session:
yield session
def get_logger(name: str = "uvicorn"):
"""Dependency, для получение логгера"""
return logging.getLogger(name)
+461
View File
@@ -0,0 +1,461 @@
$(async () => {
let secretKey = "";
try {
const data = await Api.get("/api/auth/2fa");
secretKey = data.secret;
$("#secret-code-display").text(secretKey);
const config = {
cellSize: 10,
radius: 4,
strokeWidth: 1.5,
color: "#374151",
arcDur: 500,
arcDelayStep: 10,
fillDur: 300,
fillDelayStep: 10,
squareDur: 800,
shrinkDur: 300,
moveDur: 800,
shrinkFactor: 0.9,
moveFactor: 0.3,
};
const grid = decodeBitmapToGrid(data.bitmap_b64, data.size, data.padding);
const svgHTML = AnimationLib.generateSVG(grid, config);
const $container = $("#qr-container");
$container.find(".loader").remove();
$container.prepend(svgHTML);
AnimationLib.animateCircles(grid, config);
} catch (e) {
console.error(e);
Utils.showToast("Ошибка загрузки данных 2FA", "error");
$("#qr-container").html(
'<div class="text-red-500 text-sm">Ошибка загрузки</div>',
);
}
$("#secret-copy-btn").on("click", function () {
if (!secretKey) return;
navigator.clipboard.writeText(secretKey).then(() => {
Utils.showToast("Код скопирован", "success");
});
});
const $inputs = $(".totp-digit");
const $submitBtn = $("#verify-btn");
const $msg = $("#form-message");
let digits = $inputs.map((_, el) => $(el).val()).get();
while (digits.length < 6) digits.push("");
function updateDigitsState() {
digits = $inputs.map((_, el) => $(el).val()).get();
}
function checkCompletion() {
updateDigitsState();
const isComplete = digits.every((d) => d.length === 1);
if (isComplete) {
$submitBtn.prop("disabled", false);
$msg.text("").removeClass("text-red-600 text-green-600");
} else {
$submitBtn.prop("disabled", true);
}
return isComplete;
}
function getTargetFocusIndex() {
const firstEmptyIndex = digits.findIndex((d) => d === "");
return firstEmptyIndex === -1 ? 5 : firstEmptyIndex;
}
$inputs.on("focus click", function (e) {
const targetIndex = getTargetFocusIndex();
const currentIndex = $(this).data("index");
if (currentIndex !== targetIndex) {
e.preventDefault();
setTimeout(() => {
$inputs.eq(targetIndex).trigger("focus");
const val = $inputs.eq(targetIndex).val();
$inputs.eq(targetIndex).val("").val(val);
}, 0);
}
});
$inputs.on("input", function (e) {
const index = parseInt($(this).data("index"));
const val = $(this).val();
const numericVal = val.replace(/\D/g, "");
if (!numericVal) {
$(this).val("");
digits[index] = "";
return;
}
const digit = numericVal.slice(-1);
$(this).val(digit);
digits[index] = digit;
const targetIndex = getTargetFocusIndex();
$inputs.eq(targetIndex).trigger("focus");
checkCompletion();
});
$inputs.on("keydown", function (e) {
const index = parseInt($(this).data("index"));
if (e.key === "Backspace" || e.key === "Delete") {
e.preventDefault();
const currentVal = $(this).val();
if (currentVal) {
$(this).val("");
digits[index] = "";
} else {
if (index > 0) {
const prevIndex = index - 1;
$inputs.eq(prevIndex).val("");
digits[prevIndex] = "";
$inputs.eq(prevIndex).trigger("focus");
}
}
checkCompletion();
return;
}
if (e.key === "ArrowLeft" || e.key === "ArrowRight") {
e.preventDefault();
}
});
$inputs.on("paste", function (e) {
e.preventDefault();
const clipboardData =
(e.originalEvent || e).clipboardData || window.clipboardData;
const pastedData = clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, 6);
if (pastedData) {
let charIdx = 0;
let startIndex = 0;
if (pastedData.length === 6) {
startIndex = 0;
} else {
startIndex = digits.findIndex((d) => d === "");
if (startIndex === -1) startIndex = 0;
}
for (let i = startIndex; i < 6 && charIdx < pastedData.length; i++) {
digits[i] = pastedData[charIdx];
$inputs.eq(i).val(pastedData[charIdx]);
charIdx++;
}
checkCompletion();
const nextFocus = getTargetFocusIndex();
$inputs.eq(nextFocus).trigger("focus");
}
});
$("#totp-form").on("submit", async function (e) {
e.preventDefault();
if (!checkCompletion()) return;
const code = digits.join("");
$submitBtn.prop("disabled", true).text("Проверка...");
$msg.text("").attr("class", "mb-4 text-center text-sm min-h-[20px]");
try {
await Api.post("/api/auth/2fa/verify", {
code: code,
secret: secretKey,
});
$msg.text("Код принят!").addClass("text-green-600");
Utils.showToast("2FA успешно активирована", "success");
setTimeout(() => {
window.location.href = "/profile";
}, 1000);
} catch (err) {
const errorText = err.message || "Неверный код";
$msg.text(errorText).addClass("text-red-600");
$submitBtn.prop("disabled", false).text("Подтвердить");
}
});
checkCompletion();
});
function decodeBitmapToGrid(b64Data, size, padding) {
const binaryString = atob(b64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const grid = [];
let bitIndex = 0;
for (let r = 0; r < size; r++) {
const row = [];
for (let c = 0; c < size; c++) {
const bytePos = Math.floor(bitIndex / 8);
const bitPos = 7 - (bitIndex % 8);
if (bytePos < bytes.length) {
const bit = (bytes[bytePos] >> bitPos) & 1;
row.push(bit === 0);
} else {
row.push(false);
}
bitIndex++;
}
grid.push(row);
}
return grid;
}
const AnimationLib = {
generateSVG(grid, config) {
const { cellSize, radius, strokeWidth, color } = config;
const width = grid[0].length * cellSize;
const height = grid.length * cellSize;
let svg = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg" class="mx-auto block" style="transition: all 0.5s ease;">`;
for (let row = 0; row < grid.length; row++) {
for (let col = 0; col < grid[row].length; col++) {
const cx = col * cellSize + cellSize / 2;
const cy = row * cellSize + cellSize / 2;
const circumference = 2 * Math.PI * radius;
const isClockwise = (row + col) % 2 === 0;
const initialOffset = isClockwise ? circumference : -circumference;
const squareX = cx - radius;
const squareY = cy - radius;
const squareSize = 2 * radius;
svg += `<rect x="${squareX}" y="${squareY}" width="${squareSize}" height="${squareSize}" rx="${radius}" ry="${radius}" fill="${color}" opacity="0" id="square_${row}_${col}"></rect>`;
svg += `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="none" stroke="${color}" stroke-width="${strokeWidth}" stroke-dasharray="${circumference}" stroke-dashoffset="${initialOffset}" id="circle_${row}_${col}"></circle>`;
if (grid[row][col]) {
svg += `<circle cx="${cx}" cy="${cy}" r="0" fill="${color}" id="inner_${row}_${col}"></circle>`;
}
}
}
svg += "</svg>";
return svg;
},
animateCircles(grid, config) {
const {
radius,
cellSize,
arcDur,
arcDelayStep,
fillDur,
fillDelayStep,
squareDur,
shrinkDur,
moveDur,
shrinkFactor,
moveFactor,
} = config;
const rows = grid.length;
const cols = grid[0].length;
const centerRow = Math.floor(rows / 2);
const centerCol = Math.floor(cols / 2);
const centerX = centerCol * cellSize + cellSize / 2 - radius;
const centerY = centerRow * cellSize + cellSize / 2 - radius;
const elements = [];
for (let row = 0; row < rows; row++) {
elements[row] = [];
for (let col = 0; col < cols; col++) {
elements[row][col] = {
circle: document.getElementById(`circle_${row}_${col}`),
square: document.getElementById(`square_${row}_${col}`),
inner: grid[row][col]
? document.getElementById(`inner_${row}_${col}`)
: null,
};
}
}
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
const { circle } = elements[row][col];
if (circle) {
const isClockwise = (row + col) % 2 === 0;
setTimeout(
() => {
this.rafAnimate(
circle,
"stroke-dashoffset",
isClockwise ? 2 * Math.PI * radius : -2 * Math.PI * radius,
0,
arcDur,
);
},
(row + col) * arcDelayStep,
);
}
}
}
const maxDelayFirst = (rows + cols - 2) * arcDelayStep;
setTimeout(() => {
let maxDist = 0;
const fills = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c]) {
const d = Math.sqrt((r - centerRow) ** 2 + (c - centerCol) ** 2);
fills.push({ r, c, delay: d * fillDelayStep });
maxDist = Math.max(maxDist, d);
}
}
}
fills.forEach(({ r, c, delay }) => {
const { inner } = elements[r][c];
if (inner) {
setTimeout(() => {
this.rafAnimate(inner, "r", 0, radius, fillDur);
}, delay);
}
});
setTimeout(
() => {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const { circle, square, inner } = elements[r][c];
if (grid[r][c]) {
this.rafMorphToSquare(circle, square, inner, radius, squareDur);
} else {
this.rafFadeOut(circle, squareDur);
if (square) square.remove();
}
}
}
setTimeout(() => {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c]) {
this.rafShrink(
elements[r][c].square,
2 * radius,
2 * radius * shrinkFactor,
shrinkDur,
);
}
}
}
setTimeout(() => {
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c]) {
const sq = elements[r][c].square;
const cX = parseFloat(sq.getAttribute("x"));
const cY = parseFloat(sq.getAttribute("y"));
const tX = cX + (centerX - cX) * moveFactor;
const tY = cY + (centerY - cY) * moveFactor;
this.rafMove(sq, cX, cY, tX, tY, moveDur);
}
}
}
setTimeout(() => {
const svg = document.querySelector("#qr-container svg");
if (svg) {
svg.style.borderRadius = "10%";
svg.style.border = "5px black dotted";
}
}, moveDur);
}, shrinkDur);
}, squareDur);
},
maxDist * fillDelayStep + fillDur,
);
}, maxDelayFirst + arcDur);
},
rafAnimate(el, attr, from, to, dur) {
const start = performance.now();
const step = (now) => {
const p = Math.min((now - start) / dur, 1);
el.setAttribute(attr, from + (to - from) * p);
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
},
rafMorphToSquare(circle, square, inner, radius, dur) {
const start = performance.now();
const step = (now) => {
const p = Math.min((now - start) / dur, 1);
const r = radius * (1 - p);
square.setAttribute("rx", r);
square.setAttribute("ry", r);
square.setAttribute("opacity", p);
circle.setAttribute("opacity", 1 - p);
if (p < 1) requestAnimationFrame(step);
else {
circle.remove();
if (inner) inner.remove();
square.removeAttribute("opacity");
}
};
requestAnimationFrame(step);
},
rafFadeOut(el, dur) {
const start = performance.now();
const step = (now) => {
const p = Math.min((now - start) / dur, 1);
el.setAttribute("opacity", 1 - p);
if (p < 1) requestAnimationFrame(step);
else el.remove();
};
requestAnimationFrame(step);
},
rafShrink(el, fromS, toS, dur) {
const start = performance.now();
const diff = fromS - toS;
const ox = parseFloat(el.getAttribute("x"));
const oy = parseFloat(el.getAttribute("y"));
const step = (now) => {
const p = Math.min((now - start) / dur, 1);
const cur = fromS - diff * p;
const off = (fromS - cur) / 2;
el.setAttribute("width", cur);
el.setAttribute("height", cur);
el.setAttribute("x", ox + off);
el.setAttribute("y", oy + off);
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
},
rafMove(el, fx, fy, tx, ty, dur) {
const start = performance.now();
const step = (now) => {
const p = Math.min((now - start) / dur, 1);
const ease = 1 - Math.pow(1 - p, 3);
el.setAttribute("x", fx + (tx - fx) * ease);
el.setAttribute("y", fy + (ty - fy) * ease);
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
},
};
+262
View File
@@ -0,0 +1,262 @@
$(document).ready(() => {
if (!window.isAdmin()) {
$(".container").html(
'<div class="bg-white rounded-xl shadow-sm p-8 text-center border border-gray-100"><svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg><h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3><p class="text-gray-500 mb-4">Только администраторы могут просматривать аналитику</p><a href="/" class="inline-block px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition">На главную</a></div>'
);
return;
}
let loansChart = null;
let returnsChart = null;
let currentPeriod = 30;
init();
function init() {
$("#period-select").on("change", function () {
currentPeriod = parseInt($(this).val());
loadAnalytics();
});
$("#refresh-btn").on("click", loadAnalytics);
loadAnalytics();
}
async function loadAnalytics() {
try {
const data = await Api.get(`/api/loans/analytics?days=${currentPeriod}`);
renderSummary(data.summary);
renderCharts(data);
renderTopBooks(data.top_books);
} catch (error) {
console.error("Failed to load analytics", error);
Utils.showToast("Ошибка загрузки аналитики", "error");
}
}
function renderSummary(summary) {
$("#total-loans").text(summary.total_loans || 0);
$("#active-loans").text(summary.active_loans || 0);
$("#returned-loans").text(summary.returned_loans || 0);
$("#overdue-loans").text(summary.overdue_loans || 0);
$("#reserved-books").text(summary.reserved_books || 0);
$("#borrowed-books").text(summary.borrowed_books || 0);
}
function renderCharts(data) {
// Подготовка данных для графиков
const startDate = new Date(data.start_date);
const endDate = new Date(data.end_date);
const dates = [];
const loansData = [];
const returnsData = [];
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const dateStr = d.toISOString().split("T")[0];
dates.push(new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }));
loansData.push(data.daily_loans[dateStr] || 0);
returnsData.push(data.daily_returns[dateStr] || 0);
}
// График выдач
const loansCtx = document.getElementById("loans-chart");
if (loansChart) {
loansChart.destroy();
}
loansChart = new Chart(loansCtx, {
type: "line",
data: {
labels: dates,
datasets: [
{
label: "Выдачи",
data: loansData,
borderColor: "rgb(75, 85, 99)",
backgroundColor: "rgba(75, 85, 99, 0.05)",
borderWidth: 1.5,
fill: true,
tension: 0.3,
pointRadius: 2.5,
pointHoverRadius: 4,
pointBackgroundColor: "rgb(75, 85, 99)",
pointBorderColor: "#fff",
pointBorderWidth: 1.5,
pointStyle: "circle",
},
],
},
options: {
responsive: true,
maintainAspectRatio: true,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 10,
titleFont: { size: 12, weight: "500" },
bodyFont: { size: 11 },
cornerRadius: 6,
displayColors: false,
borderColor: "rgba(255, 255, 255, 0.08)",
borderWidth: 1,
titleSpacing: 4,
bodySpacing: 4,
},
},
scales: {
y: {
beginAtZero: true,
grid: {
color: "rgba(0, 0, 0, 0.03)",
drawBorder: false,
lineWidth: 1,
},
ticks: {
precision: 0,
font: { size: 10 },
color: "rgba(0, 0, 0, 0.4)",
padding: 8,
},
},
x: {
grid: {
display: false,
},
ticks: {
maxRotation: 0,
minRotation: 0,
font: { size: 10 },
color: "rgba(0, 0, 0, 0.4)",
padding: 8,
},
},
},
},
});
// График возвратов
const returnsCtx = document.getElementById("returns-chart");
if (returnsChart) {
returnsChart.destroy();
}
returnsChart = new Chart(returnsCtx, {
type: "line",
data: {
labels: dates,
datasets: [
{
label: "Возвраты",
data: returnsData,
borderColor: "rgb(107, 114, 128)",
backgroundColor: "rgba(107, 114, 128, 0.05)",
borderWidth: 1.5,
fill: true,
tension: 0.3,
pointRadius: 2.5,
pointHoverRadius: 4,
pointBackgroundColor: "rgb(107, 114, 128)",
pointBorderColor: "#fff",
pointBorderWidth: 1.5,
pointStyle: "circle",
},
],
},
options: {
responsive: true,
maintainAspectRatio: true,
interaction: {
intersect: false,
mode: "index",
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 10,
titleFont: { size: 12, weight: "500" },
bodyFont: { size: 11 },
cornerRadius: 6,
displayColors: false,
borderColor: "rgba(255, 255, 255, 0.08)",
borderWidth: 1,
titleSpacing: 4,
bodySpacing: 4,
},
},
scales: {
y: {
beginAtZero: true,
grid: {
color: "rgba(0, 0, 0, 0.03)",
drawBorder: false,
lineWidth: 1,
},
ticks: {
precision: 0,
font: { size: 10 },
color: "rgba(0, 0, 0, 0.4)",
padding: 8,
},
},
x: {
grid: {
display: false,
},
ticks: {
maxRotation: 0,
minRotation: 0,
font: { size: 10 },
color: "rgba(0, 0, 0, 0.4)",
padding: 8,
},
},
},
},
});
}
function renderTopBooks(topBooks) {
const $container = $("#top-books-container");
$container.empty();
if (!topBooks || topBooks.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет данных</div>'
);
return;
}
topBooks.forEach((book, index) => {
const $item = $(`
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg border border-gray-100 hover:bg-gray-100 transition-colors">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="w-7 h-7 bg-gray-600 text-white rounded-full flex items-center justify-center font-medium text-xs flex-shrink-0">
${index + 1}
</div>
<div class="flex-1 min-w-0">
<a href="/book/${book.book_id}" class="text-sm font-medium text-gray-900 hover:text-gray-600 transition-colors block truncate">
${Utils.escapeHtml(book.title)}
</a>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0 ml-3">
<span class="px-2.5 py-1 bg-gray-200 text-gray-700 rounded-full text-xs font-medium">
${book.loan_count} ${book.loan_count === 1 ? "выдача" : book.loan_count < 5 ? "выдачи" : "выдач"}
</span>
</div>
</div>
`);
$container.append($item);
});
}
});
+142 -277
View File
@@ -1,287 +1,152 @@
$(function () { $(() => {
const $loginTab = $("#login-tab"); $("#login-tab").on("click", function () {
const $registerTab = $("#register-tab"); $(this)
const $loginForm = $("#login-form"); .removeClass("text-gray-400 hover:text-gray-600")
const $registerForm = $("#register-form"); .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
$("#register-tab")
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600");
const $guestLink = $("#guest-link"); $("#login-form").removeClass("hidden");
const $userBtn = $("#user-btn"); $("#register-form").addClass("hidden");
const $userDropdown = $("#user-dropdown"); });
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
const $menuContainer = $("#user-menu-container");
function switchToLogin() { $("#register-tab").on("click", function () {
$loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400"); $(this)
$registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400"); .removeClass("text-gray-400 hover:text-gray-600")
$loginForm.removeClass("hidden"); $registerForm.addClass("hidden"); .addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
history.replaceState(null, "", "/auth#login"); $("#login-tab")
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
.addClass("text-gray-400 hover:text-gray-600");
$("#register-form").removeClass("hidden");
$("#login-form").addClass("hidden");
});
$("body").on("click", ".toggle-password", function () {
const $btn = $(this);
const $input = $btn.siblings("input");
const isPassword = $input.attr("type") === "password";
$input.attr("type", isPassword ? "text" : "password");
$btn.find("svg").toggleClass("hidden");
});
$("#register-password").on("input", function () {
const password = $(this).val();
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const levels = [
{ width: "0%", color: "", text: "" },
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
{ width: "100%", color: "bg-green-500", text: "Отличный" },
];
const level = levels[strength];
const $bar = $("#password-strength-bar");
$bar.css("width", level.width);
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
$("#password-strength-text").text(level.text);
checkPasswordMatch();
});
function checkPasswordMatch() {
const password = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
const $error = $("#password-match-error");
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
} else {
$error.addClass("hidden");
return true;
} }
function switchToRegister() {
$registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
$loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
$registerForm.removeClass("hidden"); $loginForm.addClass("hidden");
history.replaceState(null, "", "/auth#register");
}
$loginTab.on("click", switchToLogin);
$registerTab.on("click", switchToRegister);
$("body").on("click", ".toggle-password", function () {
const $btn = $(this);
const $input = $btn.siblings("input");
const $eyeOpen = $btn.find(".eye-open");
const $eyeClosed = $btn.find(".eye-closed");
if ($input.attr("type") === "password") {
$input.attr("type", "text");
$eyeOpen.addClass("hidden");
$eyeClosed.removeClass("hidden");
} else {
$input.attr("type", "password");
$eyeOpen.removeClass("hidden");
$eyeClosed.addClass("hidden");
}
});
$("#register-password").on("input", function () {
const password = $(this).val();
let strength = 0;
if (password.length >= 8) strength++;
if (password.length >= 12) strength++;
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
if (/\d/.test(password)) strength++;
if (/[^a-zA-Z0-9]/.test(password)) strength++;
const levels = [
{ width: "0%", color: "", text: "" },
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
{ width: "100%", color: "bg-green-500", text: "Отличный" },
];
const level = levels[strength];
const $bar = $("#password-strength-bar");
$bar.css("width", level.width);
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
$("#password-strength-text").text(level.text);
checkPasswordMatch();
});
function checkPasswordMatch() {
const password = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
const $error = $("#password-match-error");
if (confirm && password !== confirm) {
$error.removeClass("hidden");
return false;
} else {
$error.addClass("hidden");
return true;
}
}
$("#register-password-confirm").on("input", checkPasswordMatch);
$loginForm.on("submit", async function (event) {
event.preventDefault();
const $errorDiv = $("#login-error");
const $submitBtn = $("#login-submit");
const username = $("#login-username").val();
const password = $("#login-password").val();
$errorDiv.addClass("hidden");
$submitBtn.prop("disabled", true).text("Вход...");
try {
const formData = new URLSearchParams();
formData.append("username", username);
formData.append("password", password);
const response = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData.toString(),
});
const data = await response.json();
if (response.ok) {
localStorage.setItem("access_token", data.access_token);
if (data.refresh_token) {
localStorage.setItem("refresh_token", data.refresh_token);
}
window.location.href = "/";
} else {
$errorDiv.text(data.detail || "Неверное имя пользователя или пароль");
$errorDiv.removeClass("hidden");
$submitBtn.prop("disabled", false).text("Войти");
}
} catch (error) {
console.error("Login error:", error);
$errorDiv.text("Ошибка соединения с сервером");
$errorDiv.removeClass("hidden");
$submitBtn.prop("disabled", false).text("Войти");
}
});
$registerForm.on("submit", async function (event) {
event.preventDefault();
const $errorDiv = $("#register-error");
const $successDiv = $("#register-success");
const $submitBtn = $("#register-submit");
if (!checkPasswordMatch()) {
$errorDiv.text("Пароли не совпадают").removeClass("hidden");
return;
}
const userData = {
username: $("#register-username").val(),
email: $("#register-email").val(),
full_name: $("#register-fullname").val() || null,
password: $("#register-password").val(),
};
$errorDiv.addClass("hidden");
$successDiv.addClass("hidden");
$submitBtn.prop("disabled", true).text("Регистрация...");
try {
const response = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userData),
});
const data = await response.json();
if (response.ok) {
$successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden");
setTimeout(() => {
$("#login-username").val(userData.username);
switchToLogin();
}, 2000);
} else {
let errorMessage = data.detail;
if (Array.isArray(data.detail)) {
errorMessage = data.detail.map((err) => err.msg).join(". ");
}
$errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden");
}
} catch (error) {
console.error("Register error:", error);
$errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
} finally {
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
}
});
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
});
$(document).on("click", function (e) {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
closeDropdown();
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && isDropdownOpen) {
closeDropdown();
}
});
$logoutBtn.on("click", function () {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/";
});
function showGuest() {
$guestLink.removeClass("hidden");
$userBtn.addClass("hidden").removeClass("flex");
closeDropdown();
}
function showUser(user) {
$guestLink.addClass("hidden");
$userBtn.removeClass("hidden").addClass("flex");
const displayName = user.full_name || user.username;
const firstLetter = displayName.charAt(0).toUpperCase();
$userAvatar.text(firstLetter);
$dropdownName.text(displayName);
$dropdownUsername.text("@" + user.username);
$dropdownEmail.text(user.email);
}
function updateUserAvatar(email) {
if (!email) return;
const cleanEmail = email.trim().toLowerCase();
const emailHash = sha256(cleanEmail);
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
const avatarImg = document.getElementById('user-avatar');
if (avatarImg) { avatarImg.src = avatarUrl; }
} }
if (window.location.hash === "#register") { switchToRegister(); } $("#register-password-confirm").on("input", checkPasswordMatch);
const token = localStorage.getItem("access_token"); $("#login-form").on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#login-submit");
const username = $("#login-username").val();
const password = $("#login-password").val();
if (!token) { const rememberMe = $("#remember-me").prop("checked");
showGuest(); $submitBtn.prop("disabled", true).text("Вход...");
} else {
fetch("/api/auth/me", {
headers: { Authorization: "Bearer " + token },
})
.then((response) => {
if (response.ok) return response.json();
throw new Error("Unauthorized");
})
.then((user) => {
showUser(user);
updateUserAvatar(user.email);
document.getElementById('user-btn').classList.remove('hidden'); try {
document.getElementById('guest-link').classList.add('hidden'); const formData = new URLSearchParams();
if (window.location.pathname === "/auth") { window.location.href = "/"; } formData.append("username", username);
}) formData.append("password", password);
.catch(() => {
localStorage.removeItem("access_token"); const data = await Api.postForm("/api/auth/token", formData);
localStorage.removeItem("refresh_token"); const storage = rememberMe ? localStorage : sessionStorage;
showGuest();
}); storage.setItem("access_token", data.access_token);
if (rememberMe && data.refresh_token) {
storage.setItem("refresh_token", data.refresh_token);
}
const otherStorage = rememberMe ? sessionStorage : localStorage;
otherStorage.removeItem("access_token");
otherStorage.removeItem("refresh_token");
window.location.href = "/";
} catch (error) {
Utils.showToast(error.message || "Ошибка входа", "error");
} finally {
$submitBtn.prop("disabled", false).text("Войти");
} }
}); });
$("#register-form").on("submit", async function (event) {
event.preventDefault();
const $submitBtn = $("#register-submit");
const pass = $("#register-password").val();
const confirm = $("#register-password-confirm").val();
if (pass !== confirm) {
Utils.showToast("Пароли не совпадают", "error");
return;
}
const userData = {
username: $("#register-username").val(),
email: $("#register-email").val(),
full_name: $("#register-fullname").val() || null,
password: pass,
};
$submitBtn.prop("disabled", true).text("Регистрация...");
try {
await Api.post("/api/auth/register", userData);
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
setTimeout(() => {
$("#login-tab").trigger("click");
$("#login-username").val(userData.username);
}, 1500);
} catch (error) {
let msg = error.message;
if (Array.isArray(error.detail)) {
msg = error.detail.map((e) => e.msg).join(". ");
}
Utils.showToast(msg || "Ошибка регистрации", "error");
} finally {
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
}
});
});
+56 -295
View File
@@ -1,305 +1,66 @@
$(document).ready(() => { $(document).ready(() => {
const pathParts = window.location.pathname.split("/"); const pathParts = window.location.pathname.split("/");
const authorId = pathParts[pathParts.length - 1]; const authorId = pathParts[pathParts.length - 1];
if (!authorId || isNaN(authorId)) { if (!authorId || isNaN(authorId)) {
showErrorState("Некорректный ID автора"); Utils.showToast("Некорректный ID автора", "error");
return;
}
Api.get(`/api/authors/${authorId}`)
.then((author) => {
document.title = `LiB - ${author.name}`;
renderAuthor(author);
renderBooks(author.books);
if (window.canManage()) {
$("#edit-author-btn")
.attr("href", `/author/${author.id}/edit`)
.removeClass("hidden");
}
})
.catch((error) => {
console.error(error);
Utils.showToast("Автор не найден", "error");
$("#author-loader").html('<p class="text-red-500">Ошибка загрузки</p>');
});
function renderAuthor(author) {
$("#author-name").text(author.name);
$("#author-id").text(`ID: ${author.id}`);
$("#author-avatar").text(author.name.charAt(0).toUpperCase());
const count = author.books ? author.books.length : 0;
$("#author-books-count").text(`${count} книг в библиотеке`);
$("#author-loader").addClass("hidden");
$("#author-content").removeClass("hidden");
}
function renderBooks(books) {
const $container = $("#books-container");
const tpl = document.getElementById("book-item-template");
$container.empty();
if (!books || books.length === 0) {
$container.html('<p class="text-gray-500 italic">Книг пока нет</p>');
return; return;
} }
loadAuthor(authorId); books.forEach((book) => {
const clone = tpl.content.cloneNode(true);
const card = clone.querySelector(".book-card");
function loadAuthor(id) { card.dataset.id = book.id;
showLoadingState(); clone.querySelector(".book-title").textContent = book.title;
clone.querySelector(".book-desc").textContent =
book.description || "Описание отсутствует";
fetch(`/api/authors/${id}`) $container.append(clone);
.then((response) => {
if (!response.ok) {
if (response.status === 404) {
throw new Error("Автор не найден");
}
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((author) => {
renderAuthor(author);
renderBooks(author.books);
document.title = `LiB - ${author.name}`;
})
.catch((error) => {
console.error("Error loading author:", error);
showErrorState(error.message);
});
}
function renderAuthor(author) {
const $card = $("#author-card");
const firstLetter = author.name.charAt(0).toUpperCase();
const booksCount = author.books ? author.books.length : 0;
const booksWord = getWordForm(booksCount, ["книга", "книги", "книг"]);
$card.html(`
<div class="flex items-start">
<!-- Аватар -->
<div class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0">
${firstLetter}
</div>
<!-- Информация -->
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(author.name)}</h1>
<span class="text-sm text-gray-500">ID: ${author.id}</span>
</div>
<div class="flex items-center text-gray-600 mb-4">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span>${booksCount} ${booksWord} в библиотеке</span>
</div>
<!-- Кнопка назад -->
<a href="/authors" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Вернуться к списку авторов
</a>
</div>
</div>
`);
}
function renderBooks(books) {
const $container = $("#books-container");
$container.empty();
if (!books || books.length === 0) {
$container.html(`
<div class="text-center py-8">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<p class="text-gray-500">У этого автора пока нет книг в библиотеке</p>
</div>
`);
return;
}
const $grid = $('<div class="space-y-4"></div>');
books.forEach((book) => {
const $bookCard = $(`
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors mb-2">
${escapeHtml(book.title)}
</h3>
<p class="text-gray-600 text-sm line-clamp-3">
${escapeHtml(book.description || "Описание отсутствует")}
</p>
</div>
<svg class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</div>
`);
$grid.append($bookCard);
});
$container.append($grid);
$container.off("click", ".book-card").on("click", ".book-card", function () {
const bookId = $(this).data("id");
window.location.href = `/book/${bookId}`;
});
}
function showLoadingState() {
const $authorCard = $("#author-card");
const $booksContainer = $("#books-container");
$authorCard.html(`
<div class="flex items-start animate-pulse">
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
<div class="flex-1">
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
</div>
</div>
`);
$booksContainer.html(`
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="border border-gray-200 rounded-lg p-4 animate-pulse">
<div class="h-5 bg-gray-200 rounded w-1/2 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-1"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
`
)
.join("")}
</div>
`);
}
function showErrorState(message) {
const $authorCard = $("#author-card");
const $booksSection = $("#books-section");
$booksSection.hide();
$authorCard.html(`
<div class="text-center py-8">
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
<p class="text-gray-500 mb-6">Не удалось загрузить информацию об авторе</p>
<div class="flex justify-center gap-4">
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
Попробовать снова
</button>
<a href="/authors" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
К списку авторов
</a>
</div>
</div>
`);
$("#retry-btn").on("click", function () {
$booksSection.show();
loadAuthor(authorId);
});
}
function getWordForm(number, forms) {
const cases = [2, 0, 1, 1, 1, 2];
const index =
number % 100 > 4 && number % 100 < 20
? 2
: cases[Math.min(number % 10, 5)];
return forms[index];
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
}); });
}
$(document).on("click", function (e) { $("#books-container").on("click", ".book-card", function () {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) { window.location.href = `/book/${$(this).data("id")}`;
closeDropdown();
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && isDropdownOpen) {
closeDropdown();
}
});
$logoutBtn.on("click", function () {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.reload();
});
function showGuest() {
$guestLink.removeClass("hidden");
$userBtn.addClass("hidden").removeClass("flex");
closeDropdown();
}
function showUser(user) {
$guestLink.addClass("hidden");
$userBtn.removeClass("hidden").addClass("flex");
const displayName = user.full_name || user.username;
const firstLetter = displayName.charAt(0).toUpperCase();
$userAvatar.text(firstLetter);
$dropdownName.text(displayName);
$dropdownUsername.text("@" + user.username);
$dropdownEmail.text(user.email);
}
function updateUserAvatar(email) {
if (!email) return;
const cleanEmail = email.trim().toLowerCase();
const emailHash = sha256(cleanEmail);
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
const avatarImg = document.getElementById("user-avatar");
if (avatarImg) {
avatarImg.src = avatarUrl;
}
}
const token = localStorage.getItem("access_token");
if (!token) {
showGuest();
} else {
fetch("/api/auth/me", {
headers: { Authorization: "Bearer " + token },
})
.then((response) => {
if (response.ok) return response.json();
throw new Error("Unauthorized");
})
.then((user) => {
showUser(user);
updateUserAvatar(user.email);
document.getElementById("user-btn").classList.remove("hidden");
document.getElementById("guest-link").classList.add("hidden");
})
.catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
showGuest();
});
}
}); });
});
+165 -399
View File
@@ -1,417 +1,183 @@
$(document).ready(() => { $(document).ready(() => {
let allAuthors = []; let allAuthors = [];
let filteredAuthors = []; let filteredAuthors = [];
let currentPage = 1; let currentPage = 1;
let pageSize = 12; let pageSize = 24;
let currentSort = "name_asc"; let currentSort = "name_asc";
loadAuthors(); loadAuthors();
function loadAuthors() { function loadAuthors() {
showLoadingState(); showLoadingState();
fetch("/api/authors") Api.get("/api/authors")
.then((response) => { .then((data) => {
if (!response.ok) { allAuthors = data.authors;
throw new Error(`HTTP error! status: ${response.status}`); applyFiltersAndSort();
} })
return response.json(); .catch((error) => {
}) console.error(error);
.then((data) => { Utils.showToast("Не удалось загрузить авторов", "error");
allAuthors = data.authors; $("#authors-container").empty();
applyFiltersAndSort(); });
}) }
.catch((error) => {
console.error("Error loading authors:", error); function applyFiltersAndSort() {
showErrorState(); const searchQuery = $("#author-search-input").val().trim().toLowerCase();
});
filteredAuthors = allAuthors.filter((author) =>
author.name.toLowerCase().includes(searchQuery),
);
filteredAuthors.sort((a, b) => {
const nameA = a.name.toLowerCase();
const nameB = b.name.toLowerCase();
return currentSort === "name_asc"
? nameA.localeCompare(nameB, "ru")
: nameB.localeCompare(nameA, "ru");
});
const total = filteredAuthors.length;
$("#results-counter").text(
total === 0 ? "Авторы не найдены" : `Найдено: ${total}`,
);
renderAuthors();
renderPagination();
}
function renderAuthors() {
const $container = $("#authors-container");
const tpl = document.getElementById("author-card-template");
const emptyTpl = document.getElementById("empty-state-template");
$container.empty();
if (filteredAuthors.length === 0) {
$container.append(emptyTpl.content.cloneNode(true));
return;
} }
function applyFiltersAndSort() { const startIndex = (currentPage - 1) * pageSize;
const searchQuery = $("#author-search-input").val().trim().toLowerCase(); const pageAuthors = filteredAuthors.slice(
startIndex,
startIndex + pageSize,
);
filteredAuthors = allAuthors.filter((author) => pageAuthors.forEach((author) => {
author.name.toLowerCase().includes(searchQuery) const clone = tpl.content.cloneNode(true);
); const card = clone.querySelector(".author-card");
filteredAuthors.sort((a, b) => { card.dataset.id = author.id;
const nameA = a.name.toLowerCase(); clone.querySelector(".author-name").textContent = author.name;
const nameB = b.name.toLowerCase(); clone.querySelector(".author-id").textContent = `ID: ${author.id}`;
clone.querySelector(".author-avatar").textContent = author.name
.charAt(0)
.toUpperCase();
if (currentSort === "name_asc") { $container.append(clone);
return nameA.localeCompare(nameB, "ru"); });
} else { }
return nameB.localeCompare(nameA, "ru");
}
});
updateResultsCounter(); function renderPagination() {
$("#pagination-container").empty();
const totalPages = Math.ceil(filteredAuthors.length / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border rounded-lg hover:bg-gray-50" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border rounded-lg hover:bg-gray-50" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = [];
for (let i = 1; i <= totalPages; i++) {
if (
i === 1 ||
i === totalPages ||
(i >= currentPage - 2 && i <= currentPage + 2)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border hover:bg-gray-50"}" data-page="${page}">${page}</button>
`);
}
});
$("#pagination-container").append($pagination);
$("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
renderAuthors();
renderPagination();
scrollToTop();
}
});
$("#next-page").on("click", function () {
if (currentPage < totalPages) {
currentPage++;
renderAuthors();
renderPagination();
scrollToTop();
}
});
$(".page-btn").on("click", function () {
currentPage = parseInt($(this).data("page"));
renderAuthors(); renderAuthors();
renderPagination(); renderPagination();
} scrollToTop();
});
}
function updateResultsCounter() { function showLoadingState() {
const $counter = $("#results-counter"); $("#authors-container").html(`
const total = filteredAuthors.length; ${Array(6)
.fill()
if (total === 0) { .map(
$counter.text("Авторы не найдены"); () => `
} else { <div class="bg-white p-4 rounded-lg shadow-md animate-pulse flex items-center">
const wordForm = getWordForm(total, ["автор", "автора", "авторов"]); <div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
$counter.text(`Найдено: ${total} ${wordForm}`); <div class="flex-1">
} <div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
} <div class="h-4 bg-gray-200 rounded w-1/4"></div>
</div>
function getWordForm(number, forms) {
const cases = [2, 0, 1, 1, 1, 2];
const index =
number % 100 > 4 && number % 100 < 20
? 2
: cases[Math.min(number % 10, 5)];
return forms[index];
}
function renderAuthors() {
const $container = $("#authors-container");
$container.empty();
if (filteredAuthors.length === 0) {
$container.html(`
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Авторы не найдены</h3>
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
</div>
`);
return;
}
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const pageAuthors = filteredAuthors.slice(startIndex, endIndex);
const $grid = $('<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>');
pageAuthors.forEach((author) => {
const firstLetter = author.name.charAt(0).toUpperCase();
const $authorCard = $(`
<div class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer author-card" data-id="${author.id}">
<div class="flex items-center">
<div class="w-12 h-12 bg-gray-500 text-white rounded-full flex items-center justify-center text-xl font-bold mr-4">
${firstLetter}
</div>
<div class="flex-1">
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
${escapeHtml(author.name)}
</h3>
<p class="text-sm text-gray-500">ID: ${author.id}</p>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</div>
</div>
`);
$grid.append($authorCard);
});
$container.append($grid);
$container.off("click", ".author-card").on("click", ".author-card", function () {
const authorId = $(this).data("id");
window.location.href = `/author/${authorId}`;
});
}
function renderPagination() {
const $paginationContainer = $("#pagination-container");
$paginationContainer.empty();
const totalPages = Math.ceil(filteredAuthors.length / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
${page}
</button>
`);
}
});
$paginationContainer.append($pagination);
$paginationContainer.find("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
renderAuthors();
renderPagination();
scrollToTop();
}
});
$paginationContainer.find("#next-page").on("click", function () {
if (currentPage < totalPages) {
currentPage++;
renderAuthors();
renderPagination();
scrollToTop();
}
});
$paginationContainer.find(".page-btn").on("click", function () {
const page = parseInt($(this).data("page"));
if (page !== currentPage) {
currentPage = page;
renderAuthors();
renderPagination();
scrollToTop();
}
});
}
function generatePageNumbers(current, total) {
const pages = [];
const delta = 2;
for (let i = 1; i <= total; i++) {
if (
i === 1 ||
i === total ||
(i >= current - delta && i <= current + delta)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return pages;
}
function scrollToTop() {
$("html, body").animate({ scrollTop: 0 }, 300);
}
function showLoadingState() {
const $container = $("#authors-container");
$container.html(`
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${Array(6)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="flex items-center">
<div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
<div class="flex-1">
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
</div> </div>
</div> `,
</div> )
` .join("")}
) `);
.join("")} }
</div>
`);
}
function showErrorState() { function scrollToTop() {
const $container = $("#authors-container"); $("html, body").animate({ scrollTop: 0 }, 300);
$container.html(` }
<div class="bg-red-50 p-8 rounded-lg shadow-md text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
<p class="text-red-700 mb-4">Не удалось загрузить список авторов</p>
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
Попробовать снова
</button>
</div>
`);
$("#retry-btn").on("click", loadAuthors); $("#author-search-input").on("input", function () {
} currentPage = 1;
applyFiltersAndSort();
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function initializeFilters() {
const $authorSearch = $("#author-search-input");
const $resetBtn = $("#reset-filters-btn");
const $sortRadios = $('input[name="sort"]');
let searchTimeout;
$authorSearch.on("input", function () {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
currentPage = 1;
applyFiltersAndSort();
}, 300);
});
$authorSearch.on("keypress", function (e) {
if (e.which === 13) {
clearTimeout(searchTimeout);
currentPage = 1;
applyFiltersAndSort();
}
});
$sortRadios.on("change", function () {
currentSort = $(this).val();
currentPage = 1;
applyFiltersAndSort();
});
$resetBtn.on("click", function () {
$authorSearch.val("");
$('input[name="sort"][value="name_asc"]').prop("checked", true);
currentSort = "name_asc";
currentPage = 1;
applyFiltersAndSort();
});
}
initializeFilters();
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
});
$(document).on("click", function (e) {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
closeDropdown();
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && isDropdownOpen) {
closeDropdown();
}
});
$logoutBtn.on("click", function () {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.reload();
});
function showGuest() {
$guestLink.removeClass("hidden");
$userBtn.addClass("hidden").removeClass("flex");
closeDropdown();
}
function showUser(user) {
$guestLink.addClass("hidden");
$userBtn.removeClass("hidden").addClass("flex");
const displayName = user.full_name || user.username;
const firstLetter = displayName.charAt(0).toUpperCase();
$userAvatar.text(firstLetter);
$dropdownName.text(displayName);
$dropdownUsername.text("@" + user.username);
$dropdownEmail.text(user.email);
}
function updateUserAvatar(email) {
if (!email) return;
const cleanEmail = email.trim().toLowerCase();
const emailHash = sha256(cleanEmail);
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
const avatarImg = document.getElementById("user-avatar");
if (avatarImg) {
avatarImg.src = avatarUrl;
}
}
const token = localStorage.getItem("access_token");
if (!token) {
showGuest();
} else {
fetch("/api/auth/me", {
headers: { Authorization: "Bearer " + token },
})
.then((response) => {
if (response.ok) return response.json();
throw new Error("Unauthorized");
})
.then((user) => {
showUser(user);
updateUserAvatar(user.email);
document.getElementById("user-btn").classList.remove("hidden");
document.getElementById("guest-link").classList.add("hidden");
})
.catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
showGuest();
});
}
}); });
$('input[name="sort"]').on("change", function () {
currentSort = $(this).val();
currentPage = 1;
applyFiltersAndSort();
});
$("#authors-container").on("click", ".author-card", function () {
window.location.href = `/author/${$(this).data("id")}`;
});
});
-15
View File
@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<svg
width="800px"
height="800px"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
fill="#000000"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

+527 -295
View File
@@ -1,321 +1,553 @@
$(document).ready(() => { $(document).ready(() => {
const pathParts = window.location.pathname.split("/"); const STATUS_CONFIG = {
const bookId = pathParts[pathParts.length - 1]; active: {
label: "Доступна",
bgClass: "bg-green-100",
textClass: "text-green-800",
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>`,
},
borrowed: {
label: "Выдана",
bgClass: "bg-yellow-100",
textClass: "text-yellow-800",
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>`,
},
reserved: {
label: "Забронирована",
bgClass: "bg-blue-100",
textClass: "text-blue-800",
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg>`,
},
restoration: {
label: "На реставрации",
bgClass: "bg-orange-100",
textClass: "text-orange-800",
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"></path></svg>`,
},
written_off: {
label: "Списана",
bgClass: "bg-red-100",
textClass: "text-red-800",
icon: `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path></svg>`,
},
};
const pathParts = window.location.pathname.split("/");
const bookId = parseInt(pathParts[pathParts.length - 1]);
let currentBook = null;
let cachedUsers = null;
let selectedLoanUserId = null;
let activeLoan = null;
init();
function init() {
if (!bookId || isNaN(bookId)) { if (!bookId || isNaN(bookId)) {
showErrorState("Некорректный ID книги"); Utils.showToast("Некорректный ID книги", "error");
return; return;
}
loadBookData();
setupEventHandlers();
}
function setupEventHandlers() {
$(document).on("click", (e) => {
const $menu = $("#status-menu");
const $toggleBtn = $("#status-toggle-btn");
if (
$menu.length &&
!$menu.hasClass("hidden") &&
!$toggleBtn.is(e.target) &&
$toggleBtn.has(e.target).length === 0 &&
!$menu.has(e.target).length
) {
$menu.addClass("hidden");
}
});
$("#cancel-loan-btn").on("click", closeLoanModal);
$("#user-search-input").on("input", handleUserSearch);
$("#confirm-loan-btn").on("click", submitLoan);
$("#refresh-loans-btn").on("click", loadLoans);
const future = new Date();
future.setDate(future.getDate() + 14);
$("#loan-due-date").val(future.toISOString().split("T")[0]);
}
function loadBookData() {
Api.get(`/api/books/${bookId}`)
.then((book) => {
currentBook = book;
document.title = `LiB - ${book.title}`;
renderBook(book);
if (window.canManage()) {
$("#edit-book-btn")
.attr("href", `/book/${book.id}/edit`)
.removeClass("hidden");
$("#loans-section").removeClass("hidden");
loadLoans();
}
})
.catch((error) => {
console.error(error);
Utils.showToast("Книга не найдена", "error");
$("#book-loader").html(
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
);
});
}
async function loadLoans() {
if (!window.canManage()) return;
try {
const data = await Api.get(
`/api/loans/?book_id=${bookId}&active_only=true&page=1&size=10`
);
activeLoan = data.loans.length > 0 ? data.loans[0] : null;
renderLoans(data.loans);
} catch (error) {
console.error("Failed to load loans", error);
$("#loans-container").html(
'<div class="text-center text-red-500 py-4">Ошибка загрузки выдач</div>',
);
}
}
function renderLoans(loans) {
const $container = $("#loans-container");
$container.empty();
if (!loans || loans.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>',
);
return;
} }
loadBook(bookId); loans.forEach((loan) => {
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString(
"ru-RU"
);
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const isOverdue =
!loan.returned_at && new Date(loan.due_date) < new Date();
function loadBook(id) { const $loanCard = $(`
showLoadingState(); <div class="border border-gray-200 rounded-lg p-4 ${
isOverdue ? "bg-red-50 border-red-300" : "bg-gray-50"
fetch(`/api/books/${id}`) }">
.then((response) => { <div class="flex items-start justify-between">
if (!response.ok) {
if (response.status === 404) {
throw new Error("Книга не найдена");
}
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((book) => {
renderBook(book);
renderAuthors(book.authors);
renderGenres(book.genres);
document.title = `LiB - ${book.title}`;
})
.catch((error) => {
console.error("Error loading book:", error);
showErrorState(error.message);
});
}
function renderBook(book) {
const $card = $("#book-card");
const authorsText = book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
$card.html(`
<div class="flex flex-col md:flex-row items-start">
<!-- Иконка книги -->
<div class="w-32 h-40 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center mb-4 md:mb-0 md:mr-6 flex-shrink-0 shadow-md">
<svg class="w-16 h-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
</div>
<!-- Информация о книге -->
<div class="flex-1"> <div class="flex-1">
<div class="flex items-start justify-between mb-2"> <div class="flex items-center gap-2 mb-2">
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(book.title)}</h1> <span class="font-medium text-gray-900">ID выдачи: ${loan.id}</span>
<span class="text-sm text-gray-500 ml-4">ID: ${book.id}</span> ${
isOverdue
? '<span class="px-2 py-1 bg-red-100 text-red-800 text-xs rounded-full">Просрочена</span>'
: ""
}
</div>
<p class="text-sm text-gray-600 mb-1">
<span class="font-medium">Дата выдачи:</span> ${borrowedDate}
</p>
<p class="text-sm text-gray-600 mb-1">
<span class="font-medium">Срок возврата:</span> ${dueDate}
</p>
<p class="text-sm text-gray-600">
<span class="font-medium">Пользователь ID:</span> ${loan.user_id}
</p>
</div> </div>
<div class="flex flex-col gap-2">
<p class="text-lg text-gray-600 mb-4"> ${
${escapeHtml(authorsText)} !loan.returned_at && currentBook.status === "reserved"
</p> ? `<button class="confirm-loan-btn px-3 py-1.5 bg-blue-600 text-white text-sm rounded hover:bg-blue-700 transition-colors" data-loan-id="${loan.id}">
Подтвердить
<div class="prose prose-gray max-w-none mb-6"> </button>`
<p class="text-gray-700 leading-relaxed"> : ""
${escapeHtml(book.description || "Описание отсутствует")} }
</p> ${
</div> !loan.returned_at
? `<button class="return-loan-btn px-3 py-1.5 bg-green-600 text-white text-sm rounded hover:bg-green-700 transition-colors" data-loan-id="${loan.id}">
<!-- Кнопка назад --> Вернуть
<a href="/books" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors"> </button>`
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> : ""
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> }
</svg>
Вернуться к списку книг
</a>
</div> </div>
</div>
</div> </div>
`); `);
}
function renderAuthors(authors) { $loanCard.find(".confirm-loan-btn").on("click", function () {
const $container = $("#authors-container"); const loanId = $(this).data("loan-id");
const $section = $("#authors-section"); confirmLoan(loanId);
$container.empty(); });
if (!authors || authors.length === 0) { $loanCard.find(".return-loan-btn").on("click", function () {
$section.hide(); const loanId = $(this).data("loan-id");
return; returnLoan(loanId);
} });
const $grid = $('<div class="flex flex-wrap gap-3"></div>'); $container.append($loanCard);
authors.forEach((author) => {
const firstLetter = author.name.charAt(0).toUpperCase();
const $authorCard = $(`
<a href="/author/${author.id}" class="flex items-center bg-gray-50 hover:bg-gray-100 rounded-lg p-3 transition-colors duration-200 border border-gray-200">
<div class="w-10 h-10 bg-gray-500 text-white rounded-full flex items-center justify-center text-lg font-bold mr-3">
${firstLetter}
</div>
<span class="text-gray-900 font-medium">${escapeHtml(author.name)}</span>
<svg class="w-4 h-4 text-gray-400 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</a>
`);
$grid.append($authorCard);
});
$container.append($grid);
}
function renderGenres(genres) {
const $container = $("#genres-container");
const $section = $("#genres-section");
$container.empty();
if (!genres || genres.length === 0) {
$section.hide();
return;
}
const $grid = $('<div class="flex flex-wrap gap-2"></div>');
genres.forEach((genre) => {
const $genreTag = $(`
<a href="/books?genre_id=${genre.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-full transition-colors duration-200">
<svg class="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
</svg>
${escapeHtml(genre.name)}
</a>
`);
$grid.append($genreTag);
});
$container.append($grid);
}
function showLoadingState() {
const $bookCard = $("#book-card");
const $authorsContainer = $("#authors-container");
const $genresContainer = $("#genres-container");
$bookCard.html(`
<div class="flex flex-col md:flex-row items-start animate-pulse">
<div class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"></div>
<div class="flex-1">
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
<div class="space-y-2 mb-6">
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
</div>
</div>
`);
$authorsContainer.html(`
<div class="flex gap-3 animate-pulse">
<div class="flex items-center bg-gray-100 rounded-lg p-3">
<div class="w-10 h-10 bg-gray-200 rounded-full mr-3"></div>
<div class="h-5 bg-gray-200 rounded w-24"></div>
</div>
</div>
`);
$genresContainer.html(`
<div class="flex gap-2 animate-pulse">
<div class="h-10 bg-gray-200 rounded-full w-24"></div>
<div class="h-10 bg-gray-200 rounded-full w-32"></div>
</div>
`);
}
function showErrorState(message) {
const $bookCard = $("#book-card");
const $authorsSection = $("#authors-section");
const $genresSection = $("#genres-section");
$authorsSection.hide();
$genresSection.hide();
$bookCard.html(`
<div class="text-center py-8">
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
<p class="text-gray-500 mb-6">Не удалось загрузить информацию о книге</p>
<div class="flex justify-center gap-4">
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
Попробовать снова
</button>
<a href="/books" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
К списку книг
</a>
</div>
</div>
`);
$("#retry-btn").on("click", function () {
$authorsSection.show();
$genresSection.show();
loadBook(bookId);
});
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
}); });
}
$(document).on("click", function (e) { async function confirmLoan(loanId) {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) { try {
closeDropdown(); await Api.post(`/api/loans/${loanId}/confirm`);
} Utils.showToast("Бронь подтверждена", "success");
}); loadBookData();
loadLoans();
} catch (error) {
console.error(error);
Utils.showToast(error.message || "Ошибка подтверждения брони", "error");
}
}
$(document).on("keydown", function (e) { async function returnLoan(loanId) {
if (e.key === "Escape" && isDropdownOpen) { if (!confirm("Вы уверены, что хотите вернуть эту книгу?")) {
closeDropdown(); return;
}
});
$logoutBtn.on("click", function () {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.reload();
});
function showGuest() {
$guestLink.removeClass("hidden");
$userBtn.addClass("hidden").removeClass("flex");
closeDropdown();
} }
function showUser(user) { try {
$guestLink.addClass("hidden"); await Api.post(`/api/loans/${loanId}/return`);
$userBtn.removeClass("hidden").addClass("flex"); Utils.showToast("Книга возвращена", "success");
loadBookData();
const displayName = user.full_name || user.username; loadLoans();
const firstLetter = displayName.charAt(0).toUpperCase(); } catch (error) {
console.error(error);
$userAvatar.text(firstLetter); Utils.showToast(error.message || "Ошибка возврата книги", "error");
$dropdownName.text(displayName);
$dropdownUsername.text("@" + user.username);
$dropdownEmail.text(user.email);
} }
}
function updateUserAvatar(email) { function getStatusConfig(status) {
if (!email) return; return (
const cleanEmail = email.trim().toLowerCase(); STATUS_CONFIG[status] || {
const emailHash = sha256(cleanEmail); label: status || "Неизвестно",
bgClass: "bg-gray-100",
textClass: "text-gray-800",
icon: "",
}
);
}
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`; function renderBook(book) {
const avatarImg = document.getElementById("user-avatar"); $("#book-title").text(book.title);
if (avatarImg) { $("#book-id").text(`ID: ${book.id}`);
avatarImg.src = avatarUrl; $("#book-authors-text").text(
} book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
} );
$("#book-description").text(book.description || "Описание отсутствует");
const token = localStorage.getItem("access_token"); renderStatusWidget(book);
if (!token) { if (!window.canManage() && book.status === "active") {
showGuest(); renderReserveButton();
} else { } else {
fetch("/api/auth/me", { $("#book-actions-container").empty();
headers: { Authorization: "Bearer " + token },
})
.then((response) => {
if (response.ok) return response.json();
throw new Error("Unauthorized");
})
.then((user) => {
showUser(user);
updateUserAvatar(user.email);
document.getElementById("user-btn").classList.remove("hidden");
document.getElementById("guest-link").classList.add("hidden");
})
.catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
showGuest();
});
} }
if (book.genres && book.genres.length > 0) {
$("#genres-section").removeClass("hidden");
const $genres = $("#genres-container");
$genres.empty();
book.genres.forEach((g) => {
$genres.append(`
<a href="/books?genre_id=${g.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-full text-sm transition-colors border border-gray-200">
${Utils.escapeHtml(g.name)}
</a>
`);
});
}
if (book.authors && book.authors.length > 0) {
$("#authors-section").removeClass("hidden");
const $authors = $("#authors-container");
$authors.empty();
book.authors.forEach((a) => {
$authors.append(`
<a href="/author/${a.id}" class="flex items-center bg-white hover:bg-gray-50 rounded-lg p-2 border border-gray-200 shadow-sm transition-colors group">
<div class="w-8 h-8 bg-gray-200 text-gray-600 group-hover:bg-gray-300 rounded-full flex items-center justify-center text-sm font-bold mr-2 transition-colors">
${a.name.charAt(0).toUpperCase()}
</div>
<span class="text-gray-800 font-medium text-sm">${Utils.escapeHtml(a.name)}</span>
</a>
`);
});
}
$("#book-loader").addClass("hidden");
$("#book-content").removeClass("hidden");
}
function renderStatusWidget(book) {
const $container = $("#book-status-container");
$container.empty();
const config = getStatusConfig(book.status);
if (window.canManage()) {
const $dropdownHTML = $(`
<div class="relative inline-block text-left w-full md:w-auto">
<button id="status-toggle-btn" type="button" class="w-full justify-center md:w-auto inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold transition-all shadow-sm ${config.bgClass} ${config.textClass} hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-400">
${config.icon}
<span class="ml-2">${config.label}</span>
<svg class="ml-2 -mr-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="status-menu" class="hidden absolute left-0 md:left-1/2 md:-translate-x-1/2 mt-2 w-56 rounded-xl shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none overflow-hidden z-20">
<div class="py-1" role="menu">
${Object.entries(STATUS_CONFIG)
.map(([key, conf]) => {
const isCurrent = book.status === key;
return `
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${isCurrent ? "bg-gray-50 font-medium" : "text-gray-700"}"
data-status="${key}">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full ${conf.bgClass} ${conf.textClass}">
${conf.icon}
</span>
<span>${conf.label}</span>
${isCurrent ? '<svg class="ml-auto h-4 w-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>' : ""}
</button>
`;
})
.join("")}
</div>
</div>
</div>
`);
$container.append($dropdownHTML);
$("#status-toggle-btn").on("click", (e) => {
e.stopPropagation();
$("#status-menu").toggleClass("hidden");
});
$(".status-option").on("click", function () {
const newStatus = $(this).data("status");
$("#status-menu").addClass("hidden");
if (newStatus === currentBook.status) return;
if (newStatus === "borrowed") {
openLoanModal();
} else {
updateBookStatus(newStatus);
}
});
} else {
$container.append(`
<span class="inline-flex items-center px-4 py-1.5 rounded-full text-sm font-medium ${config.bgClass} ${config.textClass} shadow-sm">
${config.icon}
${config.label}
</span>
`);
}
}
function renderReserveButton() {
const $container = $("#book-actions-container");
$container.html(`
<button id="reserve-btn" class="w-full flex items-center justify-center px-4 py-2.5 bg-gray-800 text-white font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-all shadow-sm">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Зарезервировать
</button>
`);
$("#reserve-btn").on("click", function () {
const user = window.getUser();
if (!user) {
Utils.showToast("Необходима авторизация", "error");
return;
}
Api.post("/api/loans/", {
book_id: currentBook.id,
user_id: user.id,
due_date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
})
.then((loan) => {
Utils.showToast("Книга забронирована", "success");
loadBookData();
})
.catch((err) => {
Utils.showToast(err.message || "Ошибка бронирования", "error");
});
}); });
}
async function updateBookStatus(newStatus) {
const $toggleBtn = $("#status-toggle-btn");
const originalContent = $toggleBtn.html();
$toggleBtn.prop("disabled", true).addClass("opacity-75").html(`
<svg class="animate-spin h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
Обновление...
`);
try {
const payload = {
status: newStatus,
};
const updatedBook = await Api.put(
`/api/books/${currentBook.id}`,
payload,
);
currentBook = updatedBook;
Utils.showToast("Статус успешно изменен", "success");
renderStatusWidget(updatedBook);
loadLoans();
} catch (error) {
console.error(error);
Utils.showToast(error.message || "Ошибка при смене статуса", "error");
$toggleBtn
.prop("disabled", false)
.removeClass("opacity-75")
.html(originalContent);
}
}
function openLoanModal() {
$("#loan-modal").removeClass("hidden");
$("#user-search-input").val("")[0].focus();
$("#users-list-container").html(
'<div class="p-4 text-center text-gray-500 text-sm">Загрузка списка пользователей...</div>',
);
$("#confirm-loan-btn").prop("disabled", true);
selectedLoanUserId = null;
fetchUsers();
}
function closeLoanModal() {
$("#loan-modal").addClass("hidden");
}
async function fetchUsers() {
if (cachedUsers) {
renderUsersList(cachedUsers);
return;
}
try {
const data = await Api.get("/api/auth/users?skip=0&limit=500");
cachedUsers = data.users;
renderUsersList(cachedUsers);
} catch (error) {
console.error("Failed to load users", error);
$("#users-list-container").html(
'<div class="p-4 text-center text-red-500 text-sm">Ошибка загрузки пользователей</div>',
);
}
}
function renderUsersList(users) {
const $container = $("#users-list-container");
$container.empty();
if (!users || users.length === 0) {
$container.html(
'<div class="p-4 text-center text-gray-500 text-sm">Пользователи не найдены</div>',
);
return;
}
users.forEach((user) => {
const roleBadges = user.roles
.map((r) => {
const color =
r === "admin"
? "bg-purple-100 text-purple-800"
: r === "librarian"
? "bg-blue-100 text-blue-800"
: "bg-gray-100 text-gray-800";
return `<span class="text-xs px-2 py-0.5 rounded-full ${color} mr-1">${r}</span>`;
})
.join("");
const $item = $(`
<div class="user-item p-3 hover:bg-blue-50 cursor-pointer transition-colors flex items-center justify-between group" data-id="${user.id}">
<div>
<div class="font-medium text-gray-900">${Utils.escapeHtml(user.full_name || user.username)}</div>
<div class="text-xs text-gray-500">@${Utils.escapeHtml(user.username)}${Utils.escapeHtml(user.email)}</div>
</div>
<div>${roleBadges}</div>
</div>
`);
$item.on("click", function () {
$(".user-item").removeClass("bg-blue-100 border-l-4 border-blue-500");
$(this).addClass("bg-blue-100 border-l-4 border-blue-500");
selectedLoanUserId = user.id;
$("#confirm-loan-btn")
.prop("disabled", false)
.text(`Выдать для ${user.username}`);
});
$container.append($item);
});
}
function handleUserSearch() {
const query = $(this).val().toLowerCase();
if (!cachedUsers) return;
if (!query) {
renderUsersList(cachedUsers);
return;
}
const filtered = cachedUsers.filter(
(u) =>
u.username.toLowerCase().includes(query) ||
(u.full_name && u.full_name.toLowerCase().includes(query)) ||
u.email.toLowerCase().includes(query),
);
renderUsersList(filtered);
}
async function submitLoan() {
if (!selectedLoanUserId) return;
const dueDate = $("#loan-due-date").val();
if (!dueDate) {
Utils.showToast("Выберите дату возврата", "error");
return;
}
const $btn = $("#confirm-loan-btn");
const originalText = $btn.text();
$btn.prop("disabled", true).text("Обработка...");
try {
const payload = {
book_id: currentBook.id,
user_id: selectedLoanUserId,
due_date: new Date(dueDate).toISOString(),
};
// Используем прямой эндпоинт выдачи для администраторов
if (window.isAdmin()) {
await Api.post("/api/loans/issue", payload);
} else {
// Для библиотекарей создаем бронь, которую потом нужно подтвердить
await Api.post("/api/loans/", payload);
}
Utils.showToast("Книга успешно выдана", "success");
closeLoanModal();
loadBookData();
loadLoans();
} catch (error) {
console.error(error);
Utils.showToast(error.message || "Ошибка выдачи", "error");
} finally {
$btn.prop("disabled", false).text(originalText);
}
}
});
+264 -385
View File
@@ -1,189 +1,232 @@
$(document).ready(() => { $(document).ready(() => {
const STATUS_CONFIG = {
active: {
label: "Доступна",
bgClass: "bg-green-100",
textClass: "text-green-800",
},
borrowed: {
label: "Выдана",
bgClass: "bg-yellow-100",
textClass: "text-yellow-800",
},
reserved: {
label: "Забронирована",
bgClass: "bg-blue-100",
textClass: "text-blue-800",
},
restoration: {
label: "На реставрации",
bgClass: "bg-orange-100",
textClass: "text-orange-800",
},
written_off: {
label: "Списана",
bgClass: "bg-red-100",
textClass: "text-red-800",
},
};
function getStatusConfig(status) {
return (
STATUS_CONFIG[status] || {
label: status || "Неизвестно",
bgClass: "bg-gray-100",
textClass: "text-gray-800",
}
);
}
let selectedAuthors = new Map(); let selectedAuthors = new Map();
let selectedGenres = new Map(); let selectedGenres = new Map();
let currentPage = 1; let currentPage = 1;
let pageSize = 20; let pageSize = 12;
let totalBooks = 0; let totalBooks = 0;
Promise.all([ const urlParams = new URLSearchParams(window.location.search);
fetch("/api/authors").then((response) => response.json()), const genreIdsFromUrl = urlParams.getAll("genre_id");
fetch("/api/genres").then((response) => response.json()), 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]) => { .then(([authorsData, genresData]) => {
const $dropdown = $("#author-dropdown"); initAuthors(authorsData.authors);
authorsData.authors.forEach((author) => { initGenres(genresData.genres);
$("<div>") initializeAuthorDropdownListeners();
.addClass("p-2 hover:bg-gray-100 cursor-pointer author-item") renderChips();
.attr("data-id", author.id)
.attr("data-name", author.name)
.text(author.name)
.appendTo($dropdown);
});
const $list = $("#genres-list");
genresData.genres.forEach((genre) => {
$("<li>")
.addClass("mb-1")
.html(
`<label class="custom-checkbox flex items-center">
<input type="checkbox" data-id="${genre.id}" data-name="${genre.name}" />
<span class="checkmark"></span>
${genre.name}
</label>`,
)
.appendTo($list);
});
initializeAuthorDropdown();
initializeFilters();
loadBooks(); loadBooks();
}) })
.catch((error) => console.error("Error loading data:", error)); .catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
});
function initAuthors(authors) {
const $dropdown = $("#author-dropdown");
authors.forEach((author) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors",
)
.attr("data-id", author.id)
.attr("data-name", author.name)
.text(author.name)
.appendTo($dropdown);
if (authorIdsFromUrl.includes(String(author.id))) {
selectedAuthors.set(author.id, author.name);
}
});
}
function initGenres(genres) {
const $list = $("#genres-list");
genres.forEach((genre) => {
const isChecked = genreIdsFromUrl.includes(String(genre.id));
if (isChecked) selectedGenres.set(genre.id, genre.name);
const editButton = window.canManage()
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
</a>`
: "";
$list.append(`
<li class="mb-1">
<div class="flex items-center">
<label class="custom-checkbox flex items-center flex-1">
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
</label>
${editButton}
</div>
</li>
`);
});
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
$list.on("change", "input", function () {
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
});
}
function loadBooks() { function loadBooks() {
const searchQuery = $("#book-search-input").val().trim(); const searchQuery = $("#book-search-input").val().trim();
const params = new URLSearchParams(); const params = new URLSearchParams();
if (searchQuery.length >= 3) {
params.append("q", searchQuery);
}
selectedAuthors.forEach((name, id) => { params.append("q", searchQuery);
params.append("author_ids", id); selectedAuthors.forEach((_, id) => params.append("author_ids", id));
}); selectedGenres.forEach((_, id) => params.append("genre_ids", id));
selectedGenres.forEach((name, id) => { const browserParams = new URLSearchParams();
params.append("genre_ids", id); browserParams.append("q", searchQuery);
}); selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
const newUrl =
window.location.pathname +
(browserParams.toString() ? `?${browserParams.toString()}` : "");
window.history.replaceState({}, "", newUrl);
params.append("page", currentPage); params.append("page", currentPage);
params.append("size", pageSize); params.append("size", pageSize);
const url = `/api/books/filter?${params.toString()}`;
showLoadingState(); showLoadingState();
fetch(url) Api.get(`/api/books/filter?${params.toString()}`)
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then((data) => { .then((data) => {
totalBooks = data.total; totalBooks = data.total;
renderBooks(data.books); renderBooks(data.books);
renderPagination(); renderPagination();
}) })
.catch((error) => { .catch((error) => {
console.error("Error loading books:", error); console.error(error);
showErrorState(); Utils.showToast("Не удалось загрузить книги", "error");
$("#books-container").html(
document.getElementById("empty-state-template").innerHTML,
);
}); });
} }
function renderBooks(books) { function renderBooks(books) {
const $container = $("#books-container"); const $container = $("#books-container");
const tpl = document.getElementById("book-card-template");
const emptyTpl = document.getElementById("empty-state-template");
const badgeTpl = document.getElementById("genre-badge-template");
$container.empty(); $container.empty();
if (books.length === 0) { if (books.length === 0) {
$container.html(` $container.append(emptyTpl.content.cloneNode(true));
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Книги не найдены</h3>
<p class="text-gray-500">Попробуйте изменить параметры поиска или фильтры</p>
</div>
`);
return; return;
} }
books.forEach((book) => { books.forEach((book) => {
const authorsText = const clone = tpl.content.cloneNode(true);
const card = clone.querySelector(".book-card");
card.dataset.id = book.id;
clone.querySelector(".book-title").textContent = book.title;
clone.querySelector(".book-authors").textContent =
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен"; book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
const genresText = clone.querySelector(".book-desc").textContent = book.description || "";
book.genres.map((g) => g.name).join(", ") || "Без жанра";
const $bookCard = $(` const statusConfig = getStatusConfig(book.status);
<div class="bg-white p-4 rounded-lg shadow-md mb-4 hover:shadow-lg transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}"> const statusEl = clone.querySelector(".book-status");
<div class="flex justify-between items-start"> statusEl.textContent = statusConfig.label;
<div class="flex-1"> statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
<h3 class="text-lg font-bold mb-1 text-gray-900 hover:text-blue-600 transition-colors">
${escapeHtml(book.title)}
</h3>
<p class="text-sm text-gray-600 mb-2">
<span class="font-medium">Авторы:</span> ${escapeHtml(authorsText)}
</p>
<p class="text-gray-700 text-sm mb-2">
${escapeHtml(book.description || "Описание отсутствует")}
</p>
<div class="flex flex-wrap gap-1">
${book.genres
.map(
(g) => `
<span class="inline-block bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full">
${escapeHtml(g.name)}
</span>
`,
)
.join("")}
</div>
</div>
</div>
</div>
`);
$container.append($bookCard); const genresContainer = clone.querySelector(".book-genres");
}); book.genres.forEach((g) => {
const badge = badgeTpl.content.cloneNode(true);
const span = badge.querySelector("span");
span.textContent = g.name;
genresContainer.appendChild(badge);
});
$container.on("click", ".book-card", function () { $container.append(clone);
const bookId = $(this).data("id");
window.location.href = `/book/${bookId}`;
}); });
} }
function renderPagination() { function renderPagination() {
$("#pagination-container").remove(); $("#pagination-container").empty();
const totalPages = Math.ceil(totalBooks / pageSize); const totalPages = Math.ceil(totalBooks / pageSize);
if (totalPages <= 1) return; if (totalPages <= 1) return;
const $pagination = $(` const $pagination = $(`
<div id="pagination-container" class="flex justify-center items-center gap-2 mt-6 mb-4"> <div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}> <button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div id="page-numbers" class="flex gap-1"></div>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/> <button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</svg> </div>
</button> `);
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers"); const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages); const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => { pages.forEach((page) => {
if (page === "...") { if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2">...</span>`); $pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
} else { } else {
const isActive = page === currentPage; const isActive = page === currentPage;
$pageNumbers.append(` $pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}"> <button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
${page} `);
</button>
`);
} }
}); });
$("#books-container").after($pagination); $("#pagination-container").append($pagination);
$("#prev-page").on("click", function () { $("#prev-page").on("click", function () {
if (currentPage > 1) { if (currentPage > 1) {
@@ -192,7 +235,6 @@ $(document).ready(() => {
scrollToTop(); scrollToTop();
} }
}); });
$("#next-page").on("click", function () { $("#next-page").on("click", function () {
if (currentPage < totalPages) { if (currentPage < totalPages) {
currentPage++; currentPage++;
@@ -200,7 +242,6 @@ $(document).ready(() => {
scrollToTop(); scrollToTop();
} }
}); });
$(".page-btn").on("click", function () { $(".page-btn").on("click", function () {
const page = parseInt($(this).data("page")); const page = parseInt($(this).data("page"));
if (page !== currentPage) { if (page !== currentPage) {
@@ -214,7 +255,6 @@ $(document).ready(() => {
function generatePageNumbers(current, total) { function generatePageNumbers(current, total) {
const pages = []; const pages = [];
const delta = 2; const delta = 2;
for (let i = 1; i <= total; i++) { for (let i = 1; i <= total; i++) {
if ( if (
i === 1 || i === 1 ||
@@ -226,124 +266,86 @@ $(document).ready(() => {
pages.push("..."); pages.push("...");
} }
} }
return pages; return pages;
} }
function scrollToTop() { function scrollToTop() {
$("html, body").animate({ scrollTop: 0 }, 300); window.scrollTo({ top: 0, behavior: "smooth" });
} }
function showLoadingState() { function showLoadingState() {
const $container = $("#books-container"); $("#books-container").html(`
$container.html(` <div class="space-y-4">
<div class="space-y-4"> ${Array(3)
${Array(3) .fill()
.fill() .map(
.map( () => `
() => ` <div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse"> <div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div> <div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div> <div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div> </div>
<div class="flex gap-2"> `,
<div class="h-6 bg-gray-200 rounded-full w-16"></div> )
<div class="h-6 bg-gray-200 rounded-full w-20"></div> .join("")}
</div> </div>
</div> `);
`,
)
.join("")}
</div>
`);
} }
function showErrorState() { function renderChips() {
const $container = $("#books-container"); const $container = $("#selected-authors-container");
$container.html(` const $dropdown = $("#author-dropdown");
<div class="bg-red-50 p-8 rounded-lg shadow-md text-center">
<svg class="mx-auto h-12 w-12 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<h3 class="text-lg font-medium text-red-900 mb-2">Ошибка загрузки</h3>
<p class="text-red-700 mb-4">Не удалось загрузить список книг</p>
<button id="retry-btn" class="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700">
Попробовать снова
</button>
</div>
`);
$("#retry-btn").on("click", loadBooks); $container.empty();
selectedAuthors.forEach((name, id) => {
$(`<span class="author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).appendTo($container);
});
$dropdown.find(".author-item").each(function () {
const id = parseInt($(this).data("id"));
if (selectedAuthors.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
} else {
$(this)
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
});
} }
function escapeHtml(text) { function initializeAuthorDropdownListeners() {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function initializeAuthorDropdown() {
const $input = $("#author-search-input"); const $input = $("#author-search-input");
const $dropdown = $("#author-dropdown"); const $dropdown = $("#author-dropdown");
const $container = $("#selected-authors-container"); const $container = $("#selected-authors-container");
function updateHighlights() { $input.on("focus", function () {
$dropdown.find(".author-item").each(function () {
const id = $(this).attr("data-id");
const isSelected = selectedAuthors.has(parseInt(id));
$(this)
.toggleClass("bg-gray-300 text-gray-600", isSelected)
.toggleClass("hover:bg-gray-100", !isSelected);
});
}
function filterDropdown(query) {
const lowerQuery = query.toLowerCase();
$dropdown.find(".author-item").each(function () {
$(this).toggle($(this).text().toLowerCase().includes(lowerQuery));
});
}
function renderChips() {
$container.find(".author-chip").remove();
selectedAuthors.forEach((name, id) => {
$(`<span class="author-chip flex items-center bg-gray-500 text-white text-sm font-medium px-2.5 py-0.5 rounded-full">
${escapeHtml(name)}
<button type="button" class="remove-author ml-1.5 inline-flex items-center p-0.5 text-gray-200 hover:text-white hover:bg-gray-600 rounded-full" data-id="${id}">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 14 14">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
</svg>
</button>
</span>`).insertBefore($input);
});
updateHighlights();
}
function toggleAuthor(id, name) {
id = parseInt(id);
if (selectedAuthors.has(id)) {
selectedAuthors.delete(id);
} else {
selectedAuthors.add(id, name);
selectedAuthors.set(id, name);
}
$input.val("");
filterDropdown("");
renderChips();
}
$input.on("focus", () => $dropdown.removeClass("hidden"));
$input.on("input", function () {
filterDropdown($(this).val().toLowerCase());
$dropdown.removeClass("hidden"); $dropdown.removeClass("hidden");
}); });
$(document).on("click", (e) => { $input.on("input", function () {
const val = $(this).val().toLowerCase();
$dropdown.removeClass("hidden");
$dropdown.find(".author-item").each(function () {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(val));
});
});
$(document).on("click", function (e) {
if ( if (
!$(e.target).closest("#selected-authors-container, #author-dropdown") !$(e.target).closest(
.length "#author-search-input, #author-dropdown, #selected-authors-container",
).length
) { ) {
$dropdown.addClass("hidden"); $dropdown.addClass("hidden");
} }
@@ -351,184 +353,61 @@ $(document).ready(() => {
$dropdown.on("click", ".author-item", function (e) { $dropdown.on("click", ".author-item", function (e) {
e.stopPropagation(); e.stopPropagation();
toggleAuthor($(this).attr("data-id"), $(this).attr("data-name")); const id = parseInt($(this).data("id"));
$input.focus(); const name = $(this).data("name");
if (selectedAuthors.has(id)) {
selectedAuthors.delete(id);
} else {
selectedAuthors.set(id, name);
}
$input.val("");
$dropdown.find(".author-item").show();
renderChips();
$input[0].focus();
}); });
$container.on("click", ".remove-author", function (e) { $container.on("click", ".remove-author", function (e) {
e.stopPropagation(); e.stopPropagation();
selectedAuthors.delete(parseInt($(this).attr("data-id"))); const id = parseInt($(this).data("id"));
selectedAuthors.delete(id);
renderChips(); renderChips();
$input.focus();
}); });
$container.on("click", (e) => {
if (!$(e.target).closest(".author-chip").length) {
$input.focus();
}
});
window.renderAuthorChips = renderChips;
window.updateAuthorHighlights = updateHighlights;
} }
function initializeFilters() { $("#books-container").on("click", ".book-card", function () {
const $bookSearch = $("#book-search-input"); window.location.href = `/book/${$(this).data("id")}`;
const $applyBtn = $("#apply-filters-btn"); });
const $resetBtn = $("#reset-filters-btn");
$("#genres-list").on("change", "input[type='checkbox']", function () { $("#apply-filters-btn").on("click", function () {
const id = parseInt($(this).attr("data-id")); currentPage = 1;
const name = $(this).attr("data-name"); loadBooks();
if ($(this).is(":checked")) { });
selectedGenres.set(id, name);
} else {
selectedGenres.delete(id);
}
});
$applyBtn.on("click", function () { $("#reset-filters-btn").on("click", function () {
$("#book-search-input").val("");
selectedAuthors.clear();
selectedGenres.clear();
$("#genres-list input").prop("checked", false);
renderChips();
currentPage = 1;
loadBooks();
});
$("#book-search-input").on("keypress", function (e) {
if (e.which === 13) {
currentPage = 1; currentPage = 1;
loadBooks(); loadBooks();
});
$resetBtn.on("click", function () {
$bookSearch.val("");
selectedAuthors.clear();
$("#selected-authors-container .author-chip").remove();
if (window.updateAuthorHighlights) window.updateAuthorHighlights();
selectedGenres.clear();
$("#genres-list input[type='checkbox']").prop("checked", false);
currentPage = 1;
loadBooks();
});
let searchTimeout;
$bookSearch.on("input", function () {
clearTimeout(searchTimeout);
const query = $(this).val().trim();
if (query.length >= 3 || query.length === 0) {
searchTimeout = setTimeout(() => {
currentPage = 1;
loadBooks();
}, 500);
}
});
$bookSearch.on("keypress", function (e) {
if (e.which === 13) {
clearTimeout(searchTimeout);
currentPage = 1;
loadBooks();
}
});
}
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
});
$(document).on("click", function (e) {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
closeDropdown();
} }
}); });
$(document).on("keydown", function (e) { function showAdminControls() {
if (e.key === "Escape" && isDropdownOpen) { if (window.canManage()) {
closeDropdown(); $("#admin-actions").removeClass("hidden");
}
});
$logoutBtn.on("click", function () {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.reload();
});
function showGuest() {
$guestLink.removeClass("hidden");
$userBtn.addClass("hidden").removeClass("flex");
closeDropdown();
}
function showUser(user) {
$guestLink.addClass("hidden");
$userBtn.removeClass("hidden").addClass("flex");
const displayName = user.full_name || user.username;
const firstLetter = displayName.charAt(0).toUpperCase();
$userAvatar.text(firstLetter);
$dropdownName.text(displayName);
$dropdownUsername.text("@" + user.username);
$dropdownEmail.text(user.email);
}
function updateUserAvatar(email) {
if (!email) return;
const cleanEmail = email.trim().toLowerCase();
const emailHash = sha256(cleanEmail);
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
const avatarImg = document.getElementById("user-avatar");
if (avatarImg) {
avatarImg.src = avatarUrl;
} }
} }
const token = localStorage.getItem("access_token"); showAdminControls();
setTimeout(showAdminControls, 100);
if (!token) {
showGuest();
} else {
fetch("/api/auth/me", {
headers: { Authorization: "Bearer " + token },
})
.then((response) => {
if (response.ok) return response.json();
throw new Error("Unauthorized");
})
.then((user) => {
showUser(user);
updateUserAvatar(user.email);
document.getElementById("user-btn").classList.remove("hidden");
document.getElementById("guest-link").classList.add("hidden");
})
.catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
showGuest();
});
}
}); });
+89
View File
@@ -0,0 +1,89 @@
$(document).ready(() => {
if (!window.canManage()) return;
setTimeout(() => window.canManage, 100);
const $form = $("#create-author-form");
const $nameInput = $("#author-name");
const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner");
const $successModal = $("#success-modal");
$nameInput.on("input", function () {
$("#name-counter").text(`${this.value.length}/255`);
});
$form.on("submit", async function (e) {
e.preventDefault();
const name = $nameInput.val().trim();
if (!name) {
Utils.showToast("Введите имя автора", "error");
return;
}
setLoading(true);
try {
const author = await Api.post("/api/authors/", { name });
showSuccess(author);
} catch (error) {
console.error("Ошибка создания:", error);
let errorMsg = "Произошла ошибка при создании автора";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
} else if (error.status === 409) {
errorMsg = "Автор с таким именем уже существует";
}
Utils.showToast(errorMsg, "error");
} finally {
setLoading(false);
}
});
function setLoading(isLoading) {
$submitBtn.prop("disabled", isLoading);
if (isLoading) {
$submitText.text("Сохранение...");
$loadingSpinner.removeClass("hidden");
} else {
$submitText.text("Создать автора");
$loadingSpinner.addClass("hidden");
}
}
function showSuccess(author) {
$("#modal-author-name").text(author.name);
$successModal.removeClass("hidden");
}
function resetForm() {
$form[0].reset();
$("#name-counter").text("0/255");
}
$("#modal-close-btn").on("click", function () {
$successModal.addClass("hidden");
resetForm();
$nameInput[0].focus();
});
$successModal.on("click", function (e) {
if (e.target === this) {
window.location.href = "/authors";
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && !$successModal.hasClass("hidden")) {
window.location.href = "/authors";
}
});
});
+346
View File
@@ -0,0 +1,346 @@
$(document).ready(() => {
if (!window.canManage()) return;
setTimeout(() => window.canManage, 100);
let allAuthors = [];
let allGenres = [];
const selectedAuthors = new Map();
const selectedGenres = new Map();
const $form = $("#create-book-form");
const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner");
const $successModal = $("#success-modal");
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
.then(([authorsData, genresData]) => {
allAuthors = authorsData.authors || [];
allGenres = genresData.genres || [];
initAuthors(allAuthors);
initGenres(allGenres);
initializeDropdownListeners();
})
.catch((err) => {
console.error("Ошибка загрузки данных:", err);
Utils.showToast(
"Не удалось загрузить списки авторов или жанров",
"error",
);
});
$("#book-title").on("input", function () {
$("#title-counter").text(`${this.value.length}/255`);
});
$("#book-description").on("input", function () {
$("#desc-counter").text(`${this.value.length}/2000`);
});
function initAuthors(authors) {
const $dropdown = $("#author-dropdown");
$dropdown.empty();
authors.forEach((author) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors text-sm",
)
.attr("data-id", author.id)
.attr("data-name", author.name)
.text(author.name)
.appendTo($dropdown);
});
}
function initGenres(genres) {
const $dropdown = $("#genre-dropdown");
$dropdown.empty();
genres.forEach((genre) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer genre-item transition-colors text-sm",
)
.attr("data-id", genre.id)
.attr("data-name", genre.name)
.text(genre.name)
.appendTo($dropdown);
});
}
function renderAuthorChips() {
const $container = $("#selected-authors-container");
const $dropdown = $("#author-dropdown");
$container.empty();
selectedAuthors.forEach((name, id) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).appendTo($container);
});
$dropdown.find(".author-item").each(function () {
const id = parseInt($(this).data("id"));
if (selectedAuthors.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
} else {
$(this)
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
});
}
function renderGenreChips() {
const $container = $("#selected-genres-container");
const $dropdown = $("#genre-dropdown");
$container.empty();
selectedGenres.forEach((name, id) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-genre mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).appendTo($container);
});
$dropdown.find(".genre-item").each(function () {
const id = parseInt($(this).data("id"));
if (selectedGenres.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
} else {
$(this)
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
});
}
function initializeDropdownListeners() {
const $authorInput = $("#author-search-input");
const $authorDropdown = $("#author-dropdown");
const $authorContainer = $("#selected-authors-container");
$authorInput.on("focus", function () {
$authorDropdown.removeClass("hidden");
});
$authorInput.on("input", function () {
const val = $(this).val().toLowerCase();
$authorDropdown.removeClass("hidden");
$authorDropdown.find(".author-item").each(function () {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(val));
});
});
$authorDropdown.on("click", ".author-item", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
if (selectedAuthors.has(id)) {
selectedAuthors.delete(id);
} else {
selectedAuthors.set(id, name);
}
$authorInput.val("");
$authorDropdown.find(".author-item").show();
renderAuthorChips();
$authorInput[0].focus();
});
$authorContainer.on("click", ".remove-author", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
selectedAuthors.delete(id);
renderAuthorChips();
});
const $genreInput = $("#genre-search-input");
const $genreDropdown = $("#genre-dropdown");
const $genreContainer = $("#selected-genres-container");
$genreInput.on("focus", function () {
$genreDropdown.removeClass("hidden");
});
$genreInput.on("input", function () {
const val = $(this).val().toLowerCase();
$genreDropdown.removeClass("hidden");
$genreDropdown.find(".genre-item").each(function () {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(val));
});
});
$genreDropdown.on("click", ".genre-item", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
if (selectedGenres.has(id)) {
selectedGenres.delete(id);
} else {
selectedGenres.set(id, name);
}
$genreInput.val("");
$genreDropdown.find(".genre-item").show();
renderGenreChips();
$genreInput[0].focus();
});
$genreContainer.on("click", ".remove-genre", function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
selectedGenres.delete(id);
renderGenreChips();
});
$(document).on("click", function (e) {
if (
!$(e.target).closest(
"#author-search-input, #author-dropdown, #selected-authors-container",
).length
) {
$authorDropdown.addClass("hidden");
}
if (
!$(e.target).closest(
"#genre-search-input, #genre-dropdown, #selected-genres-container",
).length
) {
$genreDropdown.addClass("hidden");
}
});
}
$form.on("submit", async function (e) {
e.preventDefault();
const title = $("#book-title").val().trim();
const description = $("#book-description").val().trim();
if (!title) {
Utils.showToast("Введите название книги", "error");
return;
}
setLoading(true);
try {
const bookPayload = {
title: title,
description: description || null,
};
const createdBook = await Api.post("/api/books/", bookPayload);
const linkPromises = [];
selectedAuthors.forEach((_, authorId) => {
linkPromises.push(
Api.post(
`/api/relationships/author-book?author_id=${authorId}&book_id=${createdBook.id}`,
),
);
});
selectedGenres.forEach((_, genreId) => {
linkPromises.push(
Api.post(
`/api/relationships/genre-book?genre_id=${genreId}&book_id=${createdBook.id}`,
),
);
});
if (linkPromises.length > 0) {
await Promise.allSettled(linkPromises);
}
showSuccess(createdBook);
} catch (error) {
console.error("Ошибка создания:", error);
let errorMsg = "Произошла ошибка при создании книги";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
}
Utils.showToast(errorMsg, "error");
} finally {
setLoading(false);
}
});
function setLoading(isLoading) {
$submitBtn.prop("disabled", isLoading);
if (isLoading) {
$submitText.text("Сохранение...");
$loadingSpinner.removeClass("hidden");
} else {
$submitText.text("Создать книгу");
$loadingSpinner.addClass("hidden");
}
}
function showSuccess(book) {
$("#modal-book-title").text(book.title);
$("#modal-link-btn").attr("href", `/book/${book.id}`);
$successModal.removeClass("hidden");
}
function resetForm() {
$form[0].reset();
selectedAuthors.clear();
selectedGenres.clear();
$("#selected-authors-container").empty();
$("#selected-genres-container").empty();
$("#title-counter").text("0/255");
$("#desc-counter").text("0/2000");
$("#author-dropdown .author-item")
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
$("#genre-dropdown .genre-item")
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
$("#modal-close-btn").on("click", function () {
$successModal.addClass("hidden");
resetForm();
window.scrollTo(0, 0);
});
$successModal.on("click", function (e) {
if (e.target === this) {
window.location.href = "/books";
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && !$successModal.hasClass("hidden")) {
window.location.href = "/books";
}
});
});
+89
View File
@@ -0,0 +1,89 @@
$(document).ready(() => {
if (!window.canManage()) return;
setTimeout(() => window.canManage, 100);
const $form = $("#create-genre-form");
const $nameInput = $("#genre-name");
const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner");
const $successModal = $("#success-modal");
$nameInput.on("input", function () {
$("#name-counter").text(`${this.value.length}/100`);
});
$form.on("submit", async function (e) {
e.preventDefault();
const name = $nameInput.val().trim();
if (!name) {
Utils.showToast("Введите название жанра", "error");
return;
}
setLoading(true);
try {
const genre = await Api.post("/api/genres/", { name });
showSuccess(genre);
} catch (error) {
console.error("Ошибка создания:", error);
let errorMsg = "Произошла ошибка при создании жанра";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
} else if (error.status === 409) {
errorMsg = "Жанр с таким названием уже существует";
}
Utils.showToast(errorMsg, "error");
} finally {
setLoading(false);
}
});
function setLoading(isLoading) {
$submitBtn.prop("disabled", isLoading);
if (isLoading) {
$submitText.text("Сохранение...");
$loadingSpinner.removeClass("hidden");
} else {
$submitText.text("Создать жанр");
$loadingSpinner.addClass("hidden");
}
}
function showSuccess(genre) {
$("#modal-genre-name").text(genre.name);
$successModal.removeClass("hidden");
}
function resetForm() {
$form[0].reset();
$("#name-counter").text("0/100");
}
$("#modal-close-btn").on("click", function () {
$successModal.addClass("hidden");
resetForm();
$nameInput[0].focus();
});
$successModal.on("click", function (e) {
if (e.target === this) {
window.location.href = "/books";
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && !$successModal.hasClass("hidden")) {
window.location.href = "/books";
}
});
});
+229
View File
@@ -0,0 +1,229 @@
$(document).ready(() => {
if (!window.canManage()) return;
setTimeout(() => window.canManage(), 100);
const pathParts = window.location.pathname.split("/");
const authorId = parseInt(pathParts[pathParts.length - 2]);
if (!authorId || isNaN(authorId)) {
Utils.showToast("Некорректный ID автора", "error");
setTimeout(() => (window.location.href = "/authors"), 1500);
return;
}
let originalAuthor = null;
let authorBooks = [];
const $form = $("#edit-author-form");
const $loader = $("#loader");
const $dangerZone = $("#danger-zone");
const $nameInput = $("#author-name");
const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner");
const $deleteModal = $("#delete-modal");
const $successModal = $("#success-modal");
Promise.all([
Api.get(`/api/authors/${authorId}`),
Api.get(`/api/authors/${authorId}/books/`),
])
.then(([author, booksData]) => {
originalAuthor = author;
authorBooks = booksData.books || booksData || [];
document.title = `Редактирование: ${author.name} | LiB`;
populateForm(author);
renderAuthorBooks(authorBooks);
$loader.addClass("hidden");
$form.removeClass("hidden");
$dangerZone.removeClass("hidden");
$("#cancel-btn").attr("href", `/author/${authorId}`);
})
.catch((error) => {
console.error(error);
Utils.showToast("Автор не найден", "error");
setTimeout(() => (window.location.href = "/authors"), 1500);
});
function populateForm(author) {
$nameInput.val(author.name);
updateCounter();
}
function updateCounter() {
$("#name-counter").text(`${$nameInput.val().length}/255`);
}
$nameInput.on("input", updateCounter);
function renderAuthorBooks(books) {
const $container = $("#author-books-container");
$container.empty();
$("#books-count").text(books.length > 0 ? `(${books.length})` : "");
if (books.length === 0) {
$container.html(`
<div class="text-sm text-gray-500 text-center py-4">
<svg class="w-8 h-8 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
У автора пока нет книг
</div>
`);
return;
}
books.forEach((book) => {
$container.append(`
<a href="/book/${book.id}" class="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded-lg border transition-colors group">
<div class="flex items-center min-w-0">
<div class="w-8 h-10 bg-gradient-to-br from-gray-400 to-gray-500 rounded flex items-center justify-center flex-shrink-0 mr-3">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
<span class="text-sm font-medium text-gray-900 truncate">${Utils.escapeHtml(book.title)}</span>
</div>
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
`);
});
}
$form.on("submit", async function (e) {
e.preventDefault();
const name = $nameInput.val().trim();
if (!name) {
Utils.showToast("Введите имя автора", "error");
return;
}
if (name === originalAuthor.name) {
Utils.showToast("Нет изменений для сохранения", "info");
return;
}
setLoading(true);
try {
const updatedAuthor = await Api.put(`/api/authors/${authorId}`, { name });
originalAuthor = updatedAuthor;
showSuccessModal(updatedAuthor);
} catch (error) {
console.error("Ошибка обновления:", error);
let errorMsg = "Произошла ошибка при обновлении автора";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
} else if (error.status === 404) {
errorMsg = "Автор не найден";
} else if (error.status === 409) {
errorMsg = "Автор с таким именем уже существует";
}
Utils.showToast(errorMsg, "error");
} finally {
setLoading(false);
}
});
function setLoading(isLoading) {
$submitBtn.prop("disabled", isLoading);
if (isLoading) {
$submitText.text("Сохранение...");
$loadingSpinner.removeClass("hidden");
} else {
$submitText.text("Сохранить изменения");
$loadingSpinner.addClass("hidden");
}
}
function showSuccessModal(author) {
$("#success-author-name").text(author.name);
$("#success-link-btn").attr("href", `/author/${author.id}`);
$successModal.removeClass("hidden");
}
$("#success-close-btn").on("click", function () {
$successModal.addClass("hidden");
});
$successModal.on("click", function (e) {
if (e.target === this) {
$successModal.addClass("hidden");
}
});
$("#delete-btn").on("click", function () {
$("#modal-author-name").text(originalAuthor.name);
if (authorBooks.length > 0) {
$("#modal-books-warning").removeClass("hidden");
} else {
$("#modal-books-warning").addClass("hidden");
}
$deleteModal.removeClass("hidden");
});
$("#cancel-delete-btn").on("click", function () {
$deleteModal.addClass("hidden");
});
$deleteModal.on("click", function (e) {
if (e.target === this) {
$deleteModal.addClass("hidden");
}
});
$("#confirm-delete-btn").on("click", async function () {
const $btn = $(this);
const $spinner = $("#delete-spinner");
$btn.prop("disabled", true);
$spinner.removeClass("hidden");
try {
await Api.delete(`/api/authors/${authorId}`);
Utils.showToast("Автор успешно удалён", "success");
setTimeout(() => (window.location.href = "/authors"), 1000);
} catch (error) {
console.error("Ошибка удаления:", error);
let errorMsg = "Произошла ошибка при удалении автора";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
}
Utils.showToast(errorMsg, "error");
$btn.prop("disabled", false);
$spinner.addClass("hidden");
$deleteModal.addClass("hidden");
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape") {
if (!$deleteModal.hasClass("hidden")) {
$deleteModal.addClass("hidden");
} else if (!$successModal.hasClass("hidden")) {
$successModal.addClass("hidden");
}
}
});
});
+457
View File
@@ -0,0 +1,457 @@
$(document).ready(() => {
if (!window.canManage()) return;
setTimeout(() => window.canManage, 100);
const pathParts = window.location.pathname.split("/");
const bookId = parseInt(pathParts[pathParts.length - 2]);
if (!bookId || isNaN(bookId)) {
Utils.showToast("Некорректный ID книги", "error");
setTimeout(() => (window.location.href = "/books"), 1500);
return;
}
let originalBook = null;
let allAuthors = [];
let allGenres = [];
const currentAuthors = new Map();
const currentGenres = new Map();
const $form = $("#edit-book-form");
const $loader = $("#loader");
const $dangerZone = $("#danger-zone");
const $titleInput = $("#book-title");
const $descInput = $("#book-description");
const $statusSelect = $("#book-status");
const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner");
const $deleteModal = $("#delete-modal");
const $successModal = $("#success-modal");
Promise.all([
Api.get(`/api/books/${bookId}`),
Api.get(`/api/books/${bookId}/authors/`),
Api.get(`/api/books/${bookId}/genres/`),
Api.get("/api/authors"),
Api.get("/api/genres"),
])
.then(([book, bookAuthors, bookGenres, authorsData, genresData]) => {
originalBook = book;
allAuthors = authorsData.authors || [];
allGenres = genresData.genres || [];
(bookAuthors.authors || bookAuthors || []).forEach((a) =>
currentAuthors.set(a.id, a.name),
);
(bookGenres.genres || bookGenres || []).forEach((g) =>
currentGenres.set(g.id, g.name),
);
document.title = `Редактирование: ${book.title} | LiB`;
populateForm(book);
initAuthorsDropdown();
initGenresDropdown();
renderCurrentAuthors();
renderCurrentGenres();
$loader.addClass("hidden");
$form.removeClass("hidden");
$dangerZone.removeClass("hidden");
$("#cancel-btn").attr("href", `/book/${bookId}`);
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
setTimeout(() => (window.location.href = "/books"), 1500);
});
function populateForm(book) {
$titleInput.val(book.title);
$descInput.val(book.description || "");
$statusSelect.val(book.status);
updateCounters();
}
function updateCounters() {
$("#title-counter").text(`${$titleInput.val().length}/255`);
$("#desc-counter").text(`${$descInput.val().length}/2000`);
}
$titleInput.on("input", updateCounters);
$descInput.on("input", updateCounters);
function initAuthorsDropdown() {
const $dropdown = $("#author-dropdown");
$dropdown.empty();
allAuthors.forEach((author) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors text-sm",
)
.attr("data-id", author.id)
.attr("data-name", author.name)
.text(author.name)
.appendTo($dropdown);
});
}
function initGenresDropdown() {
const $dropdown = $("#genre-dropdown");
$dropdown.empty();
allGenres.forEach((genre) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer genre-item transition-colors text-sm",
)
.attr("data-id", genre.id)
.attr("data-name", genre.name)
.text(genre.name)
.appendTo($dropdown);
});
}
function renderCurrentAuthors() {
const $container = $("#current-authors-container");
const $dropdown = $("#author-dropdown");
$container.empty();
$("#authors-count").text(
currentAuthors.size > 0 ? `(${currentAuthors.size})` : "",
);
currentAuthors.forEach((name, id) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}" data-name="${Utils.escapeHtml(name)}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).appendTo($container);
});
$dropdown.find(".author-item").each(function () {
const id = parseInt($(this).data("id"));
if (currentAuthors.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
} else {
$(this)
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
});
}
function renderCurrentGenres() {
const $container = $("#current-genres-container");
const $dropdown = $("#genre-dropdown");
$container.empty();
$("#genres-count").text(
currentGenres.size > 0 ? `(${currentGenres.size})` : "",
);
currentGenres.forEach((name, id) => {
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
${Utils.escapeHtml(name)}
<button type="button" class="remove-genre mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}" data-name="${Utils.escapeHtml(name)}">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</span>`).appendTo($container);
});
$dropdown.find(".genre-item").each(function () {
const id = parseInt($(this).data("id"));
if (currentGenres.has(id)) {
$(this)
.addClass("bg-gray-200 text-gray-900 font-semibold")
.removeClass("hover:bg-gray-100");
} else {
$(this)
.removeClass("bg-gray-200 text-gray-900 font-semibold")
.addClass("hover:bg-gray-100");
}
});
}
const $authorInput = $("#author-search-input");
const $authorDropdown = $("#author-dropdown");
const $authorContainer = $("#current-authors-container");
$authorInput.on("focus", function () {
$authorDropdown.removeClass("hidden");
});
$authorInput.on("input", function () {
const val = $(this).val().toLowerCase();
$authorDropdown.removeClass("hidden");
$authorDropdown.find(".author-item").each(function () {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(val));
});
});
$authorDropdown.on("click", ".author-item", async function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
if (currentAuthors.has(id)) {
return;
}
$(this).addClass("opacity-50 pointer-events-none");
try {
await Api.post(
`/api/relationships/author-book?author_id=${id}&book_id=${bookId}`,
);
currentAuthors.set(id, name);
renderCurrentAuthors();
Utils.showToast(`Автор "${name}" добавлен`, "success");
} catch (error) {
console.error(error);
Utils.showToast("Ошибка добавления автора", "error");
} finally {
$(this).removeClass("opacity-50 pointer-events-none");
}
$authorInput.val("");
$authorDropdown.find(".author-item").show();
});
$authorContainer.on("click", ".remove-author", async function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
const $chip = $(this).parent();
$chip.addClass("opacity-50");
try {
await Api.delete(
`/api/relationships/author-book?author_id=${id}&book_id=${bookId}`,
);
currentAuthors.delete(id);
renderCurrentAuthors();
Utils.showToast(`Автор "${name}" удалён`, "success");
} catch (error) {
console.error(error);
Utils.showToast("Ошибка удаления автора", "error");
$chip.removeClass("opacity-50");
}
});
const $genreInput = $("#genre-search-input");
const $genreDropdown = $("#genre-dropdown");
const $genreContainer = $("#current-genres-container");
$genreInput.on("focus", function () {
$genreDropdown.removeClass("hidden");
});
$genreInput.on("input", function () {
const val = $(this).val().toLowerCase();
$genreDropdown.removeClass("hidden");
$genreDropdown.find(".genre-item").each(function () {
const text = $(this).text().toLowerCase();
$(this).toggle(text.includes(val));
});
});
$genreDropdown.on("click", ".genre-item", async function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
if (currentGenres.has(id)) {
return;
}
$(this).addClass("opacity-50 pointer-events-none");
try {
await Api.post(
`/api/relationships/genre-book?genre_id=${id}&book_id=${bookId}`,
);
currentGenres.set(id, name);
renderCurrentGenres();
Utils.showToast(`Жанр "${name}" добавлен`, "success");
} catch (error) {
console.error(error);
Utils.showToast("Ошибка добавления жанра", "error");
} finally {
$(this).removeClass("opacity-50 pointer-events-none");
}
$genreInput.val("");
$genreDropdown.find(".genre-item").show();
});
$genreContainer.on("click", ".remove-genre", async function (e) {
e.stopPropagation();
const id = parseInt($(this).data("id"));
const name = $(this).data("name");
const $chip = $(this).parent();
$chip.addClass("opacity-50");
try {
await Api.delete(
`/api/relationships/genre-book?genre_id=${id}&book_id=${bookId}`,
);
currentGenres.delete(id);
renderCurrentGenres();
Utils.showToast(`Жанр "${name}" удалён`, "success");
} catch (error) {
console.error(error);
Utils.showToast("Ошибка удаления жанра", "error");
$chip.removeClass("opacity-50");
}
});
$(document).on("click", function (e) {
if (!$(e.target).closest("#author-search-input, #author-dropdown").length) {
$authorDropdown.addClass("hidden");
}
if (!$(e.target).closest("#genre-search-input, #genre-dropdown").length) {
$genreDropdown.addClass("hidden");
}
});
$form.on("submit", async function (e) {
e.preventDefault();
const title = $titleInput.val().trim();
const description = $descInput.val().trim();
const status = $statusSelect.val();
if (!title) {
Utils.showToast("Введите название книги", "error");
return;
}
const payload = {};
if (title !== originalBook.title) payload.title = title;
if (description !== (originalBook.description || ""))
payload.description = description || null;
if (status !== originalBook.status) payload.status = status;
if (Object.keys(payload).length === 0) {
Utils.showToast("Нет изменений для сохранения", "info");
return;
}
setLoading(true);
try {
const updatedBook = await Api.put(`/api/books/${bookId}`, payload);
originalBook = updatedBook;
showSuccessModal(updatedBook);
} catch (error) {
console.error("Ошибка обновления:", error);
let errorMsg = "Произошла ошибка при обновлении книги";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
} else if (error.status === 404) {
errorMsg = "Книга не найдена";
}
Utils.showToast(errorMsg, "error");
} finally {
setLoading(false);
}
});
function setLoading(isLoading) {
$submitBtn.prop("disabled", isLoading);
if (isLoading) {
$submitText.text("Сохранение...");
$loadingSpinner.removeClass("hidden");
} else {
$submitText.text("Сохранить изменения");
$loadingSpinner.addClass("hidden");
}
}
function showSuccessModal(book) {
$("#success-book-title").text(book.title);
$("#success-link-btn").attr("href", `/book/${book.id}`);
$successModal.removeClass("hidden");
}
$("#success-close-btn").on("click", function () {
$successModal.addClass("hidden");
});
$successModal.on("click", function (e) {
if (e.target === this) {
$successModal.addClass("hidden");
}
});
$("#delete-btn").on("click", function () {
$("#modal-book-title").text(originalBook.title);
$deleteModal.removeClass("hidden");
});
$("#cancel-delete-btn").on("click", function () {
$deleteModal.addClass("hidden");
});
$deleteModal.on("click", function (e) {
if (e.target === this) {
$deleteModal.addClass("hidden");
}
});
$("#confirm-delete-btn").on("click", async function () {
const $btn = $(this);
const $spinner = $("#delete-spinner");
$btn.prop("disabled", true);
$spinner.removeClass("hidden");
try {
await Api.delete(`/api/books/${bookId}`);
Utils.showToast("Книга успешно удалена", "success");
setTimeout(() => (window.location.href = "/books"), 1000);
} catch (error) {
console.error("Ошибка удаления:", error);
let errorMsg = "Произошла ошибка при удалении книги";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
}
Utils.showToast(errorMsg, "error");
$btn.prop("disabled", false);
$spinner.addClass("hidden");
$deleteModal.addClass("hidden");
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape") {
if (!$deleteModal.hasClass("hidden")) {
$deleteModal.addClass("hidden");
} else if (!$successModal.hasClass("hidden")) {
$successModal.addClass("hidden");
}
}
});
});
+233
View File
@@ -0,0 +1,233 @@
$(document).ready(() => {
if (!window.canManage()) {
Utils.showToast("У вас недостаточно прав", "error");
setTimeout(() => (window.location.href = "/"), 1500);
return;
}
const pathParts = window.location.pathname.split("/");
const genreId = parseInt(pathParts[pathParts.length - 2]);
if (!genreId || isNaN(genreId)) {
Utils.showToast("Некорректный ID жанра", "error");
setTimeout(() => (window.location.href = "/"), 1500);
return;
}
let originalGenre = null;
let genreBooks = [];
const $form = $("#edit-genre-form");
const $loader = $("#loader");
const $dangerZone = $("#danger-zone");
const $nameInput = $("#genre-name");
const $submitBtn = $("#submit-btn");
const $submitText = $("#submit-text");
const $loadingSpinner = $("#loading-spinner");
const $deleteModal = $("#delete-modal");
const $successModal = $("#success-modal");
Promise.all([
Api.get(`/api/genres/${genreId}`),
Api.get(`/api/genres/${genreId}/books`),
])
.then(([genre, booksData]) => {
originalGenre = genre;
genreBooks = booksData.books || booksData || [];
document.title = `Редактирование: ${genre.name} | LiB`;
populateForm(genre);
renderGenreBooks(genreBooks);
$loader.addClass("hidden");
$form.removeClass("hidden");
$dangerZone.removeClass("hidden");
})
.catch((error) => {
console.error(error);
Utils.showToast("Жанр не найден", "error");
setTimeout(() => (window.location.href = "/"), 1500);
});
function populateForm(genre) {
$nameInput.val(genre.name);
updateCounter();
}
function updateCounter() {
$("#name-counter").text(`${$nameInput.val().length}/100`);
}
$nameInput.on("input", updateCounter);
function renderGenreBooks(books) {
const $container = $("#genre-books-container");
$container.empty();
$("#books-count").text(books.length > 0 ? `(${books.length})` : "");
if (books.length === 0) {
$container.html(`
<div class="text-sm text-gray-500 text-center py-4">
<svg class="w-8 h-8 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
В этом жанре пока нет книг
</div>
`);
return;
}
books.forEach((book) => {
$container.append(`
<a href="/book/${book.id}" class="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded-lg border transition-colors group">
<div class="flex items-center min-w-0">
<div class="w-8 h-10 bg-gradient-to-br from-gray-400 to-gray-500 rounded flex items-center justify-center flex-shrink-0 mr-3">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
<div class="min-w-0">
<span class="text-sm font-medium text-gray-900 truncate block">${Utils.escapeHtml(book.title)}</span>
${book.authors && book.authors.length > 0 ? `<span class="text-xs text-gray-500 truncate block">${Utils.escapeHtml(book.authors.map((a) => a.name).join(", "))}</span>` : ""}
</div>
</div>
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
`);
});
}
$form.on("submit", async function (e) {
e.preventDefault();
const name = $nameInput.val().trim();
if (!name) {
Utils.showToast("Введите название жанра", "error");
return;
}
if (name === originalGenre.name) {
Utils.showToast("Нет изменений для сохранения", "info");
return;
}
setLoading(true);
try {
const updatedGenre = await Api.put(`/api/genres/${genreId}`, { name });
originalGenre = updatedGenre;
showSuccessModal(updatedGenre);
} catch (error) {
console.error("Ошибка обновления:", error);
let errorMsg = "Произошла ошибка при обновлении жанра";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
} else if (error.status === 404) {
errorMsg = "Жанр не найден";
} else if (error.status === 409) {
errorMsg = "Жанр с таким названием уже существует";
}
Utils.showToast(errorMsg, "error");
} finally {
setLoading(false);
}
});
function setLoading(isLoading) {
$submitBtn.prop("disabled", isLoading);
if (isLoading) {
$submitText.text("Сохранение...");
$loadingSpinner.removeClass("hidden");
} else {
$submitText.text("Сохранить изменения");
$loadingSpinner.addClass("hidden");
}
}
function showSuccessModal(genre) {
$("#success-genre-name").text(genre.name);
$successModal.removeClass("hidden");
}
$("#success-close-btn").on("click", function () {
$successModal.addClass("hidden");
});
$successModal.on("click", function (e) {
if (e.target === this) {
$successModal.addClass("hidden");
}
});
$("#delete-btn").on("click", function () {
$("#modal-genre-name").text(originalGenre.name);
if (genreBooks.length > 0) {
$("#modal-books-warning").removeClass("hidden");
} else {
$("#modal-books-warning").addClass("hidden");
}
$deleteModal.removeClass("hidden");
});
$("#cancel-delete-btn").on("click", function () {
$deleteModal.addClass("hidden");
});
$deleteModal.on("click", function (e) {
if (e.target === this) {
$deleteModal.addClass("hidden");
}
});
$("#confirm-delete-btn").on("click", async function () {
const $btn = $(this);
const $spinner = $("#delete-spinner");
$btn.prop("disabled", true);
$spinner.removeClass("hidden");
try {
await Api.delete(`/api/genres/${genreId}`);
Utils.showToast("Жанр успешно удалён", "success");
setTimeout(() => (window.location.href = "/"), 1000);
} catch (error) {
console.error("Ошибка удаления:", error);
let errorMsg = "Произошла ошибка при удалении жанра";
if (error.responseJSON && error.responseJSON.detail) {
errorMsg = error.responseJSON.detail;
} else if (error.status === 401) {
errorMsg = "Вы не авторизованы";
} else if (error.status === 403) {
errorMsg = "У вас недостаточно прав";
}
Utils.showToast(errorMsg, "error");
$btn.prop("disabled", false);
$spinner.addClass("hidden");
$deleteModal.addClass("hidden");
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape") {
if (!$deleteModal.hasClass("hidden")) {
$deleteModal.addClass("hidden");
} else if (!$successModal.hasClass("hidden")) {
$successModal.addClass("hidden");
}
}
});
});
+9 -115
View File
@@ -11,9 +11,9 @@ const bookX = (svgWidth - bookWidth) / 2;
const bookY = (svgHeight - bookHeight) / 2; const bookY = (svgHeight - bookHeight) / 2;
const desiredLineSpacing = 8; const desiredLineSpacing = 8;
const baseLineWidth = 2; const baseLineWidth = 2;
const maxLineWidth = 10; const maxLineWidth = 8;
const maxLineHeight = bookHeight - 24; const maxLineHeight = bookHeight - 24;
const innerPaddingX = 10; const innerPaddingX = 15;
const appearStagger = 8; const appearStagger = 8;
let lineSpacing; let lineSpacing;
@@ -28,7 +28,7 @@ if (lineCount > 1) {
const linesSpan = lineSpacing * (lineCount - 1); const linesSpan = lineSpacing * (lineCount - 1);
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth; const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
const lineStartX = rightBase - linesSpan; const lineStartX = rightBase - linesSpan + maxLineWidth;
const leftLimit = bookX + innerPaddingX; const leftLimit = bookX + innerPaddingX;
@@ -250,21 +250,19 @@ function observeStatCards() {
entries.forEach((entry, index) => { entries.forEach((entry, index) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
setTimeout(() => { setTimeout(() => {
$(entry.target) $(entry.target).addClass("animate-fade-in").css({
.addClass("animate-fade-in") opacity: "1",
.css({ transform: "translateY(0)",
opacity: "1", });
transform: "translateY(0)",
});
}, index * 100); }, index * 100);
observer.unobserve(entry.target); observer.unobserve(entry.target);
} }
}); });
}, },
{ threshold: 0.1 } { threshold: 0.1 },
); );
$cards.each((index, card) => { $cards.each(function (index, card) {
$(card).css({ $(card).css({
opacity: "0", opacity: "0",
transform: "translateY(20px)", transform: "translateY(20px)",
@@ -277,108 +275,4 @@ function observeStatCards() {
$(document).ready(() => { $(document).ready(() => {
loadStats(); loadStats();
observeStatCards(); observeStatCards();
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
});
$(document).on("click", function (e) {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
closeDropdown();
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && isDropdownOpen) {
closeDropdown();
}
});
$logoutBtn.on("click", function () {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.reload();
});
function showGuest() {
$guestLink.removeClass("hidden");
$userBtn.addClass("hidden").removeClass("flex");
closeDropdown();
}
function showUser(user) {
$guestLink.addClass("hidden");
$userBtn.removeClass("hidden").addClass("flex");
const displayName = user.full_name || user.username;
const firstLetter = displayName.charAt(0).toUpperCase();
$userAvatar.text(firstLetter);
$dropdownName.text(displayName);
$dropdownUsername.text("@" + user.username);
$dropdownEmail.text(user.email);
}
function updateUserAvatar(email) {
if (!email) return;
const cleanEmail = email.trim().toLowerCase();
const emailHash = sha256(cleanEmail);
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
const avatarImg = document.getElementById("user-avatar");
if (avatarImg) {
avatarImg.src = avatarUrl;
}
}
const token = localStorage.getItem("access_token");
if (!token) {
showGuest();
} else {
fetch("/api/auth/me", {
headers: { Authorization: "Bearer " + token },
})
.then((response) => {
if (response.ok) return response.json();
throw new Error("Unauthorized");
})
.then((user) => {
showUser(user);
updateUserAvatar(user.email);
document.getElementById("user-btn").classList.remove("hidden");
document.getElementById("guest-link").classList.add("hidden");
})
.catch(() => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
showGuest();
});
}
}); });
+248
View File
@@ -0,0 +1,248 @@
$(document).ready(() => {
let allLoans = [];
let booksCache = new Map();
init();
function init() {
const user = window.getUser();
if (!user) {
Utils.showToast("Необходима авторизация", "error");
window.location.href = "/auth";
return;
}
loadLoans();
}
async function loadLoans() {
try {
const data = await Api.get("/api/loans/?page=1&size=100");
allLoans = data.loans;
// Загружаем информацию о книгах
const bookIds = [...new Set(allLoans.map(loan => loan.book_id))];
await loadBooks(bookIds);
renderLoans();
} catch (error) {
console.error("Failed to load loans", error);
Utils.showToast("Ошибка загрузки выдач", "error");
}
}
async function loadBooks(bookIds) {
const promises = bookIds.map(async (bookId) => {
if (!booksCache.has(bookId)) {
try {
const book = await Api.get(`/api/books/${bookId}`);
booksCache.set(bookId, book);
} catch (error) {
console.error(`Failed to load book ${bookId}`, error);
}
}
});
await Promise.all(promises);
}
function renderLoans() {
const reservations = allLoans.filter(
loan => !loan.returned_at && getBookStatus(loan.book_id) === "reserved"
);
const activeLoans = allLoans.filter(
loan => !loan.returned_at && getBookStatus(loan.book_id) === "borrowed"
);
const returned = allLoans.filter(loan => loan.returned_at !== null);
renderReservations(reservations);
renderActiveLoans(activeLoans);
renderReturned(returned);
}
function getBookStatus(bookId) {
const book = booksCache.get(bookId);
return book ? book.status : null;
}
function renderReservations(reservations) {
const $container = $("#reservations-container");
$("#reservations-count").text(reservations.length);
$container.empty();
if (reservations.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет активных бронирований</div>'
);
return;
}
reservations.forEach((loan) => {
const book = booksCache.get(loan.book_id);
if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const $card = $(`
<div class="border border-blue-200 rounded-lg p-4 bg-blue-50">
<div class="flex items-start justify-between">
<div class="flex-1">
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата бронирования:</span> ${borrowedDate}</p>
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
</div>
<div class="mt-2">
<span class="inline-flex items-center px-2 py-1 bg-blue-100 text-blue-800 rounded-full text-xs font-medium">
Забронирована
</span>
</div>
</div>
<button
class="cancel-reservation-btn ml-4 px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors"
data-loan-id="${loan.id}"
data-book-id="${book.id}"
>
Отменить бронь
</button>
</div>
</div>
`);
$card.find(".cancel-reservation-btn").on("click", function () {
const loanId = $(this).data("loan-id");
const bookId = $(this).data("book-id");
cancelReservation(loanId, bookId);
});
$container.append($card);
});
}
function renderActiveLoans(activeLoans) {
const $container = $("#active-loans-container");
$("#active-loans-count").text(activeLoans.length);
$container.empty();
if (activeLoans.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет активных выдач</div>'
);
return;
}
activeLoans.forEach((loan) => {
const book = booksCache.get(loan.book_id);
if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const isOverdue = new Date(loan.due_date) < new Date();
const $card = $(`
<div class="border ${isOverdue ? "border-red-300 bg-red-50" : "border-yellow-200 bg-yellow-50"} rounded-lg p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-yellow-600 transition-colors">
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
</div>
<div class="mt-2 flex items-center gap-2">
<span class="inline-flex items-center px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
Выдана
</span>
${isOverdue ? '<span class="inline-flex items-center px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium">Просрочена</span>' : ""}
</div>
</div>
</div>
</div>
`);
$container.append($card);
});
}
function renderReturned(returned) {
const $container = $("#returned-container");
$("#returned-count").text(returned.length);
$container.empty();
if (returned.length === 0) {
$container.html(
'<div class="text-center text-gray-500 py-8">Нет возвращенных книг</div>'
);
return;
}
returned.forEach((loan) => {
const book = booksCache.get(loan.book_id);
if (!book) return;
const borrowedDate = new Date(loan.borrowed_at).toLocaleDateString("ru-RU");
const returnedDate = new Date(loan.returned_at).toLocaleDateString("ru-RU");
const dueDate = new Date(loan.due_date).toLocaleDateString("ru-RU");
const $card = $(`
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="flex items-start justify-between">
<div class="flex-1">
<a href="/book/${book.id}" class="text-lg font-semibold text-gray-900 hover:text-gray-600 transition-colors">
${Utils.escapeHtml(book.title)}
</a>
<p class="text-sm text-gray-600 mt-1">
Авторы: ${book.authors.map(a => a.name).join(", ") || "Не указаны"}
</p>
<div class="mt-3 space-y-1 text-sm text-gray-600">
<p><span class="font-medium">Дата выдачи:</span> ${borrowedDate}</p>
<p><span class="font-medium">Срок возврата:</span> ${dueDate}</p>
<p><span class="font-medium">Дата возврата:</span> ${returnedDate}</p>
</div>
<div class="mt-2">
<span class="inline-flex items-center px-2 py-1 bg-gray-100 text-gray-800 rounded-full text-xs font-medium">
Возвращена
</span>
</div>
</div>
</div>
</div>
`);
$container.append($card);
});
}
async function cancelReservation(loanId, bookId) {
if (!confirm("Вы уверены, что хотите отменить бронирование?")) {
return;
}
try {
await Api.delete(`/api/loans/${loanId}`);
Utils.showToast("Бронирование отменено", "success");
// Удаляем из кэша и перезагружаем
allLoans = allLoans.filter(loan => loan.id !== loanId);
const book = booksCache.get(bookId);
if (book) {
book.status = "active";
booksCache.set(bookId, book);
}
renderLoans();
} catch (error) {
console.error(error);
Utils.showToast(error.message || "Ошибка отмены бронирования", "error");
}
}
});
+110 -491
View File
@@ -1,509 +1,128 @@
$(document).ready(() => { $(document).ready(() => {
let currentUser = null; const token = StorageHelper.get("access_token");
let allRoles = []; if (!token) {
window.location.href = "/auth";
return;
}
const token = localStorage.getItem("access_token"); loadProfile();
if (!token) { function loadProfile() {
window.location.href = "/login"; Promise.all([
Api.get("/api/auth/me"),
Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
])
.then(async ([user, rolesData]) => {
document.title = `LiB - ${user.full_name || user.username}`;
await renderProfileHeader(user);
renderInfo(user);
renderRoles(user.roles || [], rolesData.roles || []);
$("#account-section, #roles-section").removeClass("hidden");
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки профиля", "error");
});
}
async function renderProfileHeader(user) {
const avatarUrl = await Utils.getGravatarUrl(user.email);
const displayName = Utils.escapeHtml(user.full_name || user.username);
$("#profile-card").html(`
<div class="flex flex-col sm:flex-row items-center sm:items-start">
<div class="relative mb-4 sm:mb-0 sm:mr-6">
<img src="${avatarUrl}" class="w-24 h-24 rounded-full object-cover border-4 border-gray-200">
${user.is_verified ? '<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1 border-2 border-white"><svg class="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg></div>' : ""}
</div>
<div class="flex-1 text-center sm:text-left">
<h1 class="text-2xl font-bold text-gray-900 mb-1">${displayName}</h1>
<p class="text-gray-500 mb-3">@${Utils.escapeHtml(user.username)}</p>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm ${user.is_active ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800"}">
${user.is_active ? "Активен" : "Заблокирован"}
</span>
</div>
</div>
`);
}
function renderInfo(user) {
const fields = [
{ label: "ID пользователя", value: user.id },
{ label: "Email", value: user.email },
{ label: "Полное имя", value: user.full_name || "Не указано" },
];
const html = fields
.map(
(f) => `
<div class="flex justify-between py-2 border-b last:border-0">
<span class="text-gray-500">${f.label}</span>
<span class="font-medium text-gray-900">${Utils.escapeHtml(String(f.value))}</span>
</div>
`,
)
.join("");
$("#account-info").html(html);
}
function renderRoles(userRoles, allRoles) {
const $container = $("#roles-container");
if (userRoles.length === 0) {
$container.html('<p class="text-gray-500">Нет ролей</p>');
return; return;
} }
loadProfile(); const roleMap = {};
allRoles.forEach((r) => (roleMap[r.name] = r.description));
function loadProfile() { const html = userRoles
showLoadingState(); .map(
(role) => `
<div class="p-3 bg-blue-50 border border-blue-100 rounded text-blue-800">
<div class="font-bold capitalize">${Utils.escapeHtml(role)}</div>
<div class="text-xs opacity-75">${Utils.escapeHtml(roleMap[role] || "")}</div>
</div>
`,
)
.join("");
Promise.all([ $container.html(html);
fetch("/api/auth/me", { }
headers: { Authorization: "Bearer " + token },
}).then((response) => { $("#submit-password-btn").on("click", async function () {
if (!response.ok) { const $btn = $(this);
if (response.status === 401) { const newPass = $("#new-password").val();
throw new Error("Unauthorized"); const confirm = $("#confirm-password").val();
}
throw new Error(`HTTP error! status: ${response.status}`); if (newPass !== confirm) {
} Utils.showToast("Пароли не совпадают", "error");
return response.json(); return;
}),
fetch("/api/auth/roles", {
headers: { Authorization: "Bearer " + token },
}).then((response) => {
if (response.ok) return response.json();
return { roles: [] };
}),
])
.then(([user, rolesData]) => {
currentUser = user;
allRoles = rolesData.roles || [];
renderProfile(user);
renderAccountInfo(user);
renderRoles(user.roles, allRoles);
renderActions();
document.title = `LiB - ${user.full_name || user.username}`;
})
.catch((error) => {
console.error("Error loading profile:", error);
if (error.message === "Unauthorized") {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
} else {
showErrorState(error.message);
}
});
} }
function renderProfile(user) { if (newPass.length < 4) {
const $card = $("#profile-card"); Utils.showToast("Пароль слишком короткий", "error");
const displayName = user.full_name || user.username; return;
const firstLetter = displayName.charAt(0).toUpperCase();
const emailHash = sha256(user.email.trim().toLowerCase());
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
$card.html(`
<div class="flex flex-col sm:flex-row items-center sm:items-start">
<!-- Аватар -->
<div class="relative mb-4 sm:mb-0 sm:mr-6">
<img src="${avatarUrl}" alt="Аватар"
class="w-24 h-24 rounded-full object-cover border-4 border-gray-200"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="w-24 h-24 bg-gray-500 text-white rounded-full items-center justify-center text-4xl font-bold hidden">
${firstLetter}
</div>
<!-- Статус верификации -->
${user.is_verified ? `
<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1" title="Подтверждённый аккаунт">
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</div>
` : ''}
</div>
<!-- Информация -->
<div class="flex-1 text-center sm:text-left">
<h1 class="text-2xl font-bold text-gray-900 mb-1">${escapeHtml(displayName)}</h1>
<p class="text-gray-500 mb-3">@${escapeHtml(user.username)}</p>
<!-- Статусы -->
<div class="flex flex-wrap justify-center sm:justify-start gap-2">
${user.is_active ? `
<span class="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1 rounded-full">
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
Активен
</span>
` : `
<span class="inline-flex items-center bg-red-100 text-red-800 text-sm px-3 py-1 rounded-full">
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
Заблокирован
</span>
`}
${user.is_verified ? `
<span class="inline-flex items-center bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded-full">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
</svg>
Подтверждён
</span>
` : `
<span class="inline-flex items-center bg-yellow-100 text-yellow-800 text-sm px-3 py-1 rounded-full">
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
</svg>
Не подтверждён
</span>
`}
</div>
</div>
</div>
`);
} }
function renderAccountInfo(user) { $btn.prop("disabled", true).text("Меняем...");
const $container = $("#account-container");
$container.html(` try {
<div class="space-y-4"> await Api.put("/api/auth/me", {
<!-- ID пользователя --> password: newPass,
<div class="flex items-center justify-between py-3 border-b border-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
</svg>
<div>
<p class="text-sm text-gray-500">ID пользователя</p>
<p class="text-gray-900">${user.id}</p>
</div>
</div>
</div>
<!-- Имя пользователя -->
<div class="flex items-center justify-between py-3 border-b border-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
<div>
<p class="text-sm text-gray-500">Имя пользователя</p>
<p class="text-gray-900">@${escapeHtml(user.username)}</p>
</div>
</div>
</div>
<!-- Полное имя -->
<div class="flex items-center justify-between py-3 border-b border-gray-100">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/>
</svg>
<div>
<p class="text-sm text-gray-500">Полное имя</p>
<p class="text-gray-900">${escapeHtml(user.full_name || "Не указано")}</p>
</div>
</div>
</div>
<!-- Email -->
<div class="flex items-center justify-between py-3">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<div>
<p class="text-sm text-gray-500">Email</p>
<p class="text-gray-900">${escapeHtml(user.email)}</p>
</div>
</div>
</div>
</div>
`);
}
function renderRoles(userRoles, allRoles) {
const $container = $("#roles-container");
if (!userRoles || userRoles.length === 0) {
$container.html(`
<p class="text-gray-500">У вас нет назначенных ролей</p>
`);
return;
}
const roleDescriptions = {};
allRoles.forEach((role) => {
roleDescriptions[role.name] = role.description;
}); });
const roleIcons = { Utils.showToast("Пароль успешно изменен", "success");
admin: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> window.dispatchEvent(new CustomEvent("close-modal"));
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
</svg>`,
librarian: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>`,
member: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>`,
};
const roleColors = { $("#change-password-form")[0].reset();
admin: "bg-red-100 text-red-800 border-red-200", } catch (error) {
librarian: "bg-blue-100 text-blue-800 border-blue-200", console.error(error);
member: "bg-green-100 text-green-800 border-green-200", Utils.showToast(error.message || "Ошибка смены пароля", "error");
}; } finally {
$btn.prop("disabled", false).text("Сменить");
let rolesHtml = '<div class="space-y-3">';
userRoles.forEach((roleName) => {
const description = roleDescriptions[roleName] || "Описание недоступно";
const icon = roleIcons[roleName] || roleIcons.member;
const colorClass = roleColors[roleName] || roleColors.member;
rolesHtml += `
<div class="flex items-center p-4 rounded-lg border ${colorClass}">
<div class="flex-shrink-0 mr-4">
${icon}
</div>
<div>
<h4 class="font-medium capitalize">${escapeHtml(roleName)}</h4>
<p class="text-sm opacity-75">${escapeHtml(description)}</p>
</div>
</div>
`;
});
rolesHtml += '</div>';
$container.html(rolesHtml);
}
function renderActions() {
const $container = $("#actions-container");
$container.html(`
<div class="space-y-3">
<!-- Смена пароля -->
<button id="change-password-btn" class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
</svg>
<span class="text-gray-700">Сменить пароль</span>
</div>
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
<!-- Выход -->
<button id="logout-profile-btn" class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">
<div class="flex items-center">
<svg class="w-5 h-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
<span class="text-red-700">Выйти из аккаунта</span>
</div>
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</button>
</div>
`);
$("#change-password-btn").on("click", openPasswordModal);
$("#logout-profile-btn").on("click", logout);
}
function openPasswordModal() {
$("#password-modal").removeClass("hidden").addClass("flex");
$("#current-password").focus();
}
function closePasswordModal() {
$("#password-modal").removeClass("flex").addClass("hidden");
$("#password-form")[0].reset();
$("#password-error").addClass("hidden").text("");
}
$("#close-password-modal, #cancel-password").on("click", closePasswordModal);
$("#password-modal").on("click", function (e) {
if (e.target === this) {
closePasswordModal();
}
});
$(document).on("keydown", function (e) {
if (e.key === "Escape" && $("#password-modal").hasClass("flex")) {
closePasswordModal();
}
});
$("#password-form").on("submit", function (e) {
e.preventDefault();
const currentPassword = $("#current-password").val();
const newPassword = $("#new-password").val();
const confirmPassword = $("#confirm-password").val();
const $error = $("#password-error");
if (newPassword !== confirmPassword) {
$error.text("Пароли не совпадают").removeClass("hidden");
return;
}
if (newPassword.length < 6) {
$error.text("Пароль должен содержать минимум 6 символов").removeClass("hidden");
return;
}
// TODO: смена пароля, 2FA
// fetch("/api/auth/change-password", {
// method: "POST",
// headers: {
// "Content-Type": "application/json",
// Authorization: "Bearer " + token,
// },
// body: JSON.stringify({
// current_password: currentPassword,
// new_password: newPassword,
// }),
// })
// .then((response) => {
// if (!response.ok) throw new Error("Ошибка смены пароля");
// return response.json();
// })
// .then(() => {
// closePasswordModal();
// showNotification("Пароль успешно изменён", "success");
// })
// .catch((error) => {
// $error.text(error.message).removeClass("hidden");
// });
$error.text("Функция смены пароля временно недоступна").removeClass("hidden");
});
function logout() {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
}
function showLoadingState() {
const $profileCard = $("#profile-card");
const $accountContainer = $("#account-container");
const $rolesContainer = $("#roles-container");
const $actionsContainer = $("#actions-container");
$profileCard.html(`
<div class="flex flex-col sm:flex-row items-center sm:items-start animate-pulse">
<div class="w-24 h-24 bg-gray-200 rounded-full mb-4 sm:mb-0 sm:mr-6"></div>
<div class="flex-1 text-center sm:text-left">
<div class="h-7 bg-gray-200 rounded w-48 mx-auto sm:mx-0 mb-2"></div>
<div class="h-5 bg-gray-200 rounded w-32 mx-auto sm:mx-0 mb-3"></div>
<div class="flex justify-center sm:justify-start gap-2">
<div class="h-7 bg-gray-200 rounded-full w-20"></div>
<div class="h-7 bg-gray-200 rounded-full w-28"></div>
</div>
</div>
</div>
`);
$accountContainer.html(`
<div class="space-y-4 animate-pulse">
${Array(4)
.fill()
.map(
() => `
<div class="flex items-center py-3 border-b border-gray-100">
<div class="w-5 h-5 bg-gray-200 rounded mr-3"></div>
<div>
<div class="h-3 bg-gray-200 rounded w-16 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-40"></div>
</div>
</div>
`
)
.join("")}
</div>
`);
$rolesContainer.html(`
<div class="space-y-3 animate-pulse">
<div class="h-16 bg-gray-200 rounded-lg"></div>
</div>
`);
$actionsContainer.html(`
<div class="space-y-3 animate-pulse">
<div class="h-14 bg-gray-200 rounded-lg"></div>
<div class="h-14 bg-gray-200 rounded-lg"></div>
</div>
`);
}
function showErrorState(message) {
const $profileCard = $("#profile-card");
const $accountSection = $("#account-section");
const $rolesSection = $("#roles-section");
const $actionsSection = $("#actions-section");
$accountSection.hide();
$rolesSection.hide();
$actionsSection.hide();
$profileCard.html(`
<div class="text-center py-8">
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
<p class="text-gray-500 mb-6">Не удалось загрузить профиль</p>
<div class="flex justify-center gap-4">
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
Попробовать снова
</button>
<a href="/" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
На главную
</a>
</div>
</div>
`);
$("#retry-btn").on("click", function () {
$accountSection.show();
$rolesSection.show();
$actionsSection.show();
loadProfile();
});
}
function escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
const $guestLink = $("#guest-link");
const $userBtn = $("#user-btn");
const $userDropdown = $("#user-dropdown");
const $userArrow = $("#user-arrow");
const $userAvatar = $("#user-avatar");
const $dropdownName = $("#dropdown-name");
const $dropdownUsername = $("#dropdown-username");
const $dropdownEmail = $("#dropdown-email");
const $logoutBtn = $("#logout-btn");
let isDropdownOpen = false;
function openDropdown() {
isDropdownOpen = true;
$userDropdown.removeClass("hidden");
$userArrow.addClass("rotate-180");
}
function closeDropdown() {
isDropdownOpen = false;
$userDropdown.addClass("hidden");
$userArrow.removeClass("rotate-180");
}
$userBtn.on("click", function (e) {
e.stopPropagation();
isDropdownOpen ? closeDropdown() : openDropdown();
});
$(document).on("click", function (e) {
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
closeDropdown();
}
});
$logoutBtn.on("click", logout);
function showGuest() {
$guestLink.removeClass("hidden");
$userBtn.addClass("hidden").removeClass("flex");
closeDropdown();
}
function showUser(user) {
$guestLink.addClass("hidden");
$userBtn.removeClass("hidden").addClass("flex");
const displayName = user.full_name || user.username;
const firstLetter = displayName.charAt(0).toUpperCase();
$userAvatar.text(firstLetter);
$dropdownName.text(displayName);
$dropdownUsername.text("@" + user.username);
$dropdownEmail.text(user.email);
}
if (currentUser) {
showUser(currentUser);
} }
}); });
});
+78 -86
View File
@@ -1,11 +1,74 @@
@keyframes shake {
0%,
to {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-5px);
}
20%,
40%,
60%,
80% {
transform: translateX(5px);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes dropdownFade {
0% {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes pulse-soft {
0%,
to {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@font-face { @font-face {
font-family: "Novem"; font-family: "Novem";
src: url("novem.regular.ttf") format("truetype"); src: url(novem.regular.ttf) format("truetype");
} }
@font-face { @font-face {
font-family: "Dited"; font-family: "Dited";
src: url("dited.regular.ttf") format("truetype"); src: url(dited.regular.ttf) format("truetype");
} }
h1 { h1 {
@@ -13,6 +76,9 @@ h1 {
letter-spacing: 10px; letter-spacing: 10px;
} }
.book-id,
.book-status,
h2,
nav ul li a { nav ul li a {
font-family: "Dited", sans-serif; font-family: "Dited", sans-serif;
letter-spacing: 2.5px; letter-spacing: 2.5px;
@@ -70,7 +136,7 @@ nav ul li a {
top: 6px; top: 6px;
width: 4px; width: 4px;
height: 8px; height: 8px;
border: solid white; border: solid #fff;
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
transform: rotate(45deg); transform: rotate(45deg);
} }
@@ -93,17 +159,6 @@ button:disabled {
animation: fadeIn 0.3s ease-in-out; animation: fadeIn 0.3s ease-in-out;
} }
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.flex.justify-center.gap-4 button:hover { .flex.justify-center.gap-4 button:hover {
transform: translateY(-2px); transform: translateY(-2px);
} }
@@ -112,30 +167,10 @@ button:disabled {
animation: shake 0.5s ease-in-out; animation: shake 0.5s ease-in-out;
} }
@keyframes shake { #req-digit,
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-5px);
}
20%,
40%,
60%,
80% {
transform: translateX(5px);
}
}
#req-length, #req-length,
#req-upper,
#req-lower, #req-lower,
#req-digit { #req-upper {
transition: color 0.2s ease; transition: color 0.2s ease;
} }
@@ -150,17 +185,6 @@ button:disabled {
animation: fadeIn 0.3s ease-in-out; animation: fadeIn 0.3s ease-in-out;
} }
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#login-tab, #login-tab,
#register-tab { #register-tab {
font-family: "Dited", sans-serif; font-family: "Dited", sans-serif;
@@ -172,17 +196,6 @@ button:disabled {
animation: dropdownFade 0.1s ease-out; animation: dropdownFade 0.1s ease-out;
} }
@keyframes dropdownFade {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
#user-arrow.rotate-180 { #user-arrow.rotate-180 {
transform: rotate(180deg); transform: rotate(180deg);
} }
@@ -195,16 +208,6 @@ button:disabled {
min-width: 140px; min-width: 140px;
} }
@keyframes pulse-soft {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.animate-pulse-soft { .animate-pulse-soft {
animation: pulse-soft 2s ease-in-out infinite; animation: pulse-soft 2s ease-in-out infinite;
} }
@@ -213,40 +216,29 @@ button:disabled {
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1)); filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
} }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up { .animate-fade-in-up {
animation: fadeInUp 0.5s ease-out forwards; animation: fadeInUp 0.5s ease-out forwards;
} }
.stat-card:hover svg { .stat-card:hover svg {
transform: scale(1.1); transform: scale(1.1);
transition: transform 0.3s ease;
} }
.stat-card svg { .stat-card svg,
.stat-card:hover svg {
transition: transform 0.3s ease; transition: transform 0.3s ease;
} }
.gradient-text { .gradient-text {
background: linear-gradient(135deg, #374151 0%, #6b7280 100%); background: linear-gradient(135deg, #374151 0, #6b7280 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
} }
.line-clamp-3 { .line-clamp-3 {
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
+701
View File
@@ -0,0 +1,701 @@
$(document).ready(() => {
if (!window.isAdmin()) {
$("#users-container").html(
document.getElementById("access-denied-template").innerHTML,
);
return;
}
setTimeout(() => {
if (!window.isAdmin()) {
$("#users-container").html(
document.getElementById("access-denied-template").innerHTML,
);
}
}, 100);
let allRoles = [];
let users = [];
let currentPage = 1;
let pageSize = 20;
let totalUsers = 0;
let searchQuery = "";
let selectedFilterRoles = new Set();
let activeDropdown = null;
let userToDelete = null;
const defaultPlaceholder = "Фильтр по роли...";
showLoadingState();
Promise.all([
Api.get("/api/auth/users?skip=0&limit=100"),
Api.get("/api/auth/roles"),
])
.then(([usersData, rolesData]) => {
users = usersData.users;
totalUsers = usersData.total;
allRoles = rolesData.roles;
$("#total-users-count").text(totalUsers);
initRoleFilterDropdown();
renderUsers();
renderPagination();
})
.catch((error) => {
console.error(error);
Utils.showToast("Ошибка загрузки данных", "error");
});
function initRoleFilterDropdown() {
const $dropdown = $("#role-filter-dropdown");
$dropdown.empty();
allRoles.forEach((role) => {
$("<div>")
.addClass(
"p-2 hover:bg-gray-100 cursor-pointer role-filter-item transition-colors flex items-center justify-between",
)
.attr("data-name", role.name)
.html(
`<div>
<div class="font-medium text-sm">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
</div>
<svg class="check-icon w-4 h-4 text-green-600 hidden" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
</svg>`,
)
.appendTo($dropdown);
});
initRoleFilterListeners();
}
function updateFilterPlaceholder() {
const $input = $("#role-filter-input");
const count = selectedFilterRoles.size;
if (count === 0) {
$input.attr("placeholder", defaultPlaceholder);
} else {
$input.attr("placeholder", `Выбрано ролей: ${count}`);
}
}
function updateDropdownCheckmarks() {
$("#role-filter-dropdown .role-filter-item").each(function () {
const name = $(this).data("name");
const $check = $(this).find(".check-icon");
if (selectedFilterRoles.has(name)) {
$check.removeClass("hidden");
$(this).addClass("bg-gray-50");
} else {
$check.addClass("hidden");
$(this).removeClass("bg-gray-50");
}
});
}
function initRoleFilterListeners() {
const $input = $("#role-filter-input");
const $dropdown = $("#role-filter-dropdown");
$input.on("focus", function () {
$dropdown.removeClass("hidden");
});
$input.on("input", function () {
const val = $(this).val().toLowerCase();
$dropdown.removeClass("hidden");
$dropdown.find(".role-filter-item").each(function () {
const name = $(this).data("name").toLowerCase();
$(this).toggle(name.includes(val));
});
});
$(document).on("click", function (e) {
if (
!$(e.target).closest("#role-filter-input, #role-filter-dropdown").length
) {
$dropdown.addClass("hidden");
$input.val("");
$dropdown.find(".role-filter-item").show();
}
});
$dropdown.on("click", ".role-filter-item", function (e) {
e.stopPropagation();
const name = $(this).data("name");
if (selectedFilterRoles.has(name)) {
selectedFilterRoles.delete(name);
} else {
selectedFilterRoles.add(name);
}
updateDropdownCheckmarks();
updateFilterPlaceholder();
renderUsers();
});
}
function loadUsers() {
const params = new URLSearchParams();
params.append("skip", (currentPage - 1) * pageSize);
params.append("limit", pageSize);
showLoadingState();
Api.get(`/api/auth/users?${params.toString()}`)
.then((data) => {
users = data.users;
totalUsers = data.total;
$("#total-users-count").text(totalUsers);
renderUsers();
renderPagination();
})
.catch((error) => {
console.error(error);
Utils.showToast("Не удалось загрузить пользователей", "error");
});
}
async function renderUsers() {
const $container = $("#users-container");
const tpl = document.getElementById("user-card-template");
const emptyTpl = document.getElementById("empty-state-template");
const roleBadgeTpl = document.getElementById("role-badge-template");
$container.empty();
let filteredUsers = users;
if (searchQuery) {
const q = searchQuery.toLowerCase();
filteredUsers = filteredUsers.filter(
(user) =>
user.username.toLowerCase().includes(q) ||
user.email.toLowerCase().includes(q) ||
(user.full_name && user.full_name.toLowerCase().includes(q)),
);
}
if (selectedFilterRoles.size > 0) {
filteredUsers = filteredUsers.filter((user) => {
if (!user.roles || user.roles.length === 0) return false;
return Array.from(selectedFilterRoles).every((roleName) =>
user.roles.includes(roleName),
);
});
}
if (filteredUsers.length === 0) {
$container.append(emptyTpl.content.cloneNode(true));
return;
}
const currentUser = window.getUser();
for (const user of filteredUsers) {
const clone = tpl.content.cloneNode(true);
const card = clone.querySelector(".user-card");
card.dataset.id = user.id;
clone.querySelector(".user-fullname").textContent =
user.full_name || user.username;
clone.querySelector(".user-username").textContent = "@" + user.username;
clone.querySelector(".user-email").textContent = user.email;
const avatar = clone.querySelector(".user-avatar");
Utils.getGravatarUrl(user.email).then((url) => {
avatar.src = url;
});
if (user.is_verified) {
clone.querySelector(".user-verified-badge").classList.remove("hidden");
}
if (user.is_active) {
clone.querySelector(".user-active-badge").classList.remove("hidden");
} else {
clone.querySelector(".user-inactive-badge").classList.remove("hidden");
}
const rolesContainer = clone.querySelector(".user-roles");
if (user.roles && user.roles.length > 0) {
user.roles.forEach((roleName) => {
const badge = roleBadgeTpl.content.cloneNode(true);
const badgeSpan = badge.querySelector(".role-badge");
if (roleName === "admin") {
badgeSpan.classList.remove("bg-gray-600");
badgeSpan.classList.add("bg-red-600");
} else if (roleName === "librarian") {
badgeSpan.classList.remove("bg-gray-600");
badgeSpan.classList.add("bg-blue-600");
}
badge.querySelector(".role-name").textContent = roleName;
const removeBtn = badge.querySelector(".remove-role-btn");
removeBtn.dataset.userId = user.id;
removeBtn.dataset.roleName = roleName;
rolesContainer.appendChild(badge);
});
} else {
rolesContainer.innerHTML =
'<span class="text-gray-400 text-sm italic">Нет ролей</span>';
}
const addRoleBtn = clone.querySelector(".add-role-btn");
addRoleBtn.dataset.userId = user.id;
const editBtn = clone.querySelector(".edit-user-btn");
editBtn.dataset.userId = user.id;
const deleteBtn = clone.querySelector(".delete-user-btn");
deleteBtn.dataset.userId = user.id;
if (currentUser && currentUser.id === user.id) {
deleteBtn.classList.add("opacity-30", "cursor-not-allowed");
deleteBtn.disabled = true;
deleteBtn.title = "Нельзя удалить себя";
}
$container.append(clone);
}
}
function showLoadingState() {
$("#users-container").html(`
<div class="space-y-4">
${Array(3)
.fill()
.map(
() => `
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
<div class="flex items-start gap-4">
<div class="w-14 h-14 bg-gray-200 rounded-full"></div>
<div class="flex-1">
<div class="h-5 bg-gray-200 rounded w-1/4 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
<div class="h-4 bg-gray-200 rounded w-1/2 mb-3"></div>
<div class="flex gap-2">
<div class="h-6 bg-gray-200 rounded-full w-16"></div>
<div class="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</div>
</div>
</div>
`,
)
.join("")}
</div>
`);
}
function renderPagination() {
$("#pagination-container").empty();
const totalPages = Math.ceil(totalUsers / pageSize);
if (totalPages <= 1) return;
const $pagination = $(`
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === 1 ? "disabled" : ""}>&larr;</button>
<div id="page-numbers" class="flex gap-1"></div>
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === totalPages ? "disabled" : ""}>&rarr;</button>
</div>
`);
const $pageNumbers = $pagination.find("#page-numbers");
const pages = generatePageNumbers(currentPage, totalPages);
pages.forEach((page) => {
if (page === "...") {
$pageNumbers.append(`<span class="px-3 py-2 text-gray-500">...</span>`);
} else {
const isActive = page === currentPage;
$pageNumbers.append(`
<button class="page-btn px-3 py-2 rounded-lg transition-colors ${isActive ? "bg-gray-600 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">${page}</button>
`);
}
});
$("#pagination-container").append($pagination);
$("#prev-page").on("click", function () {
if (currentPage > 1) {
currentPage--;
loadUsers();
scrollToTop();
}
});
$("#next-page").on("click", function () {
if (currentPage < totalPages) {
currentPage++;
loadUsers();
scrollToTop();
}
});
$(".page-btn").on("click", function () {
const page = parseInt($(this).data("page"));
if (page !== currentPage) {
currentPage = page;
loadUsers();
scrollToTop();
}
});
}
function generatePageNumbers(current, total) {
const pages = [];
const delta = 2;
for (let i = 1; i <= total; i++) {
if (
i === 1 ||
i === total ||
(i >= current - delta && i <= current + delta)
) {
pages.push(i);
} else if (pages[pages.length - 1] !== "...") {
pages.push("...");
}
}
return pages;
}
function scrollToTop() {
window.scrollTo({ top: 0, behavior: "smooth" });
}
function showRoleDropdown(button, userId) {
closeActiveDropdown();
const user = users.find((u) => u.id === userId);
const userRoles = user ? user.roles || [] : [];
const availableRoles = allRoles.filter(
(role) => !userRoles.includes(role.name),
);
if (availableRoles.length === 0) {
Utils.showToast("Все роли уже назначены", "info");
return;
}
const $dropdown = $(`
<div class="role-add-dropdown absolute z-50 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden" style="min-width: 200px;">
<div class="p-2 border-b border-gray-100">
<input type="text" class="role-search-input w-full border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400" placeholder="Поиск роли..." autocomplete="off" />
</div>
<div class="role-items max-h-48 overflow-y-auto"></div>
</div>
`);
const $roleItems = $dropdown.find(".role-items");
availableRoles.forEach((role) => {
const roleClass =
role.name === "admin"
? "hover:bg-red-50"
: role.name === "librarian"
? "hover:bg-blue-50"
: "hover:bg-gray-50";
$roleItems.append(`
<div class="role-item p-2 ${roleClass} cursor-pointer transition-colors border-b border-gray-50 last:border-0" data-role-name="${Utils.escapeHtml(role.name)}">
<div class="font-medium text-sm text-gray-800">${Utils.escapeHtml(role.name)}</div>
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
${role.payroll ? `<div class="text-xs text-green-600">Оклад: ${role.payroll}</div>` : ""}
</div>
`);
});
const $button = $(button);
const buttonOffset = $button.offset();
const buttonHeight = $button.outerHeight();
$dropdown.css({
position: "fixed",
top: buttonOffset.top + buttonHeight + 5,
left: Math.max(10, buttonOffset.left - 150),
});
$("body").append($dropdown);
activeDropdown = $dropdown;
setTimeout(() => {
$dropdown.find(".role-search-input").focus();
}, 50);
$dropdown.find(".role-search-input").on("input", function () {
const searchVal = $(this).val().toLowerCase();
$dropdown.find(".role-item").each(function () {
const roleName = $(this).data("role-name").toLowerCase();
$(this).toggle(roleName.includes(searchVal));
});
});
$dropdown.on("click", ".role-item", function () {
const roleName = $(this).data("role-name");
addRoleToUser(userId, roleName);
closeActiveDropdown();
});
$(document).on("keydown.roleDropdown", function (e) {
if (e.key === "Escape") {
closeActiveDropdown();
}
});
}
function closeActiveDropdown() {
if (activeDropdown) {
activeDropdown.remove();
activeDropdown = null;
$(document).off("keydown.roleDropdown");
}
}
function addRoleToUser(userId, roleName) {
Api.request(
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
{
method: "POST",
},
)
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex] = updatedUser;
}
renderUsers();
Utils.showToast(`Роль "${roleName}" добавлена`, "success");
})
.catch((error) => {
console.error(error);
Utils.showToast(error.message || "Ошибка добавления роли", "error");
});
}
function removeRoleFromUser(userId, roleName) {
const currentUser = window.getUser();
if (currentUser && currentUser.id === userId && roleName === "admin") {
Utils.showToast("Нельзя удалить свою роль администратора", "error");
return;
}
Api.request(
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
{
method: "DELETE",
},
)
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex] = updatedUser;
}
renderUsers();
Utils.showToast(`Роль "${roleName}" удалена`, "success");
})
.catch((error) => {
console.error(error);
Utils.showToast(error.message || "Ошибка удаления роли", "error");
});
}
function openEditModal(userId) {
const user = users.find((u) => u.id === userId);
if (!user) return;
$("#edit-user-id").val(user.id);
$("#edit-user-email").val(user.email);
$("#edit-user-fullname").val(user.full_name || "");
$("#edit-user-password").val("");
$("#edit-user-active").prop("checked", user.is_active);
$("#edit-user-verified").prop("checked", user.is_verified);
$("#edit-user-modal").removeClass("hidden");
}
function closeEditModal() {
$("#edit-user-modal").addClass("hidden");
$("#edit-user-form")[0].reset();
}
function saveUserChanges() {
const userId = parseInt($("#edit-user-id").val());
const email = $("#edit-user-email").val().trim();
const fullName = $("#edit-user-fullname").val().trim();
const password = $("#edit-user-password").val();
if (!email) {
Utils.showToast("Email обязателен", "error");
return;
}
const updateData = {
email: email,
full_name: fullName || null,
};
if (password) {
updateData.password = password;
}
// Note: This uses the /api/auth/me endpoint structure
// For admin editing other users, you might need a different endpoint
// Here we'll simulate by updating local data
Api.put(`/api/auth/me`, updateData)
.then((updatedUser) => {
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex] = { ...users[userIndex], ...updatedUser };
}
renderUsers();
closeEditModal();
Utils.showToast("Пользователь обновлён", "success");
})
.catch((error) => {
console.warn("API update failed, updating locally:", error);
const userIndex = users.findIndex((u) => u.id === userId);
if (userIndex !== -1) {
users[userIndex].email = email;
users[userIndex].full_name = fullName || null;
users[userIndex].is_active = $("#edit-user-active").prop("checked");
users[userIndex].is_verified = $("#edit-user-verified").prop(
"checked",
);
}
renderUsers();
closeEditModal();
Utils.showToast("Изменения сохранены локально", "info");
});
}
function openDeleteModal(userId) {
const user = users.find((u) => u.id === userId);
if (!user) return;
const currentUser = window.getUser();
if (currentUser && currentUser.id === userId) {
Utils.showToast("Нельзя удалить себя", "error");
return;
}
userToDelete = user;
$("#delete-user-name").text(user.full_name || user.username);
$("#delete-user-modal").removeClass("hidden");
}
function closeDeleteModal() {
$("#delete-user-modal").addClass("hidden");
userToDelete = null;
}
function confirmDeleteUser() {
if (!userToDelete) return;
Utils.showToast("Удаление пользователей не поддерживается API", "error");
closeDeleteModal();
// When API supports deletion:
// Api.delete(`/api/auth/users/${userToDelete.id}`)
// .then(() => {
// users = users.filter(u => u.id !== userToDelete.id);
// totalUsers--;
// $("#total-users-count").text(totalUsers);
// renderUsers();
// closeDeleteModal();
// Utils.showToast("Пользователь удалён", "success");
// })
// .catch((error) => {
// console.error(error);
// Utils.showToast(error.message || "Ошибка удаления", "error");
// });
}
$("#users-container").on("click", ".add-role-btn", function (e) {
e.stopPropagation();
const userId = parseInt($(this).data("user-id"));
showRoleDropdown(this, userId);
});
$("#users-container").on("click", ".remove-role-btn", function (e) {
e.stopPropagation();
const userId = parseInt($(this).data("user-id"));
const roleName = $(this).data("role-name");
const user = users.find((u) => u.id === userId);
const userName = user ? user.full_name || user.username : "пользователя";
if (confirm(`Удалить роль "${roleName}" у ${userName}?`)) {
removeRoleFromUser(userId, roleName);
}
});
$("#users-container").on("click", ".edit-user-btn", function (e) {
e.stopPropagation();
const userId = parseInt($(this).data("user-id"));
openEditModal(userId);
});
$("#edit-user-form").on("submit", function (e) {
e.preventDefault();
saveUserChanges();
});
$("#cancel-edit-btn, #modal-backdrop").on("click", closeEditModal);
$("#users-container").on("click", ".delete-user-btn", function (e) {
e.stopPropagation();
if ($(this).prop("disabled")) return;
const userId = parseInt($(this).data("user-id"));
openDeleteModal(userId);
});
$("#confirm-delete-btn").on("click", confirmDeleteUser);
$("#cancel-delete-btn, #delete-modal-backdrop").on("click", closeDeleteModal);
$(document).on("click", function (e) {
if (!$(e.target).closest(".role-add-dropdown, .add-role-btn").length) {
closeActiveDropdown();
}
});
let searchTimeout;
$("#user-search-input").on("input", function () {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchQuery = $(this).val().trim();
renderUsers();
}, 300);
});
$("#user-search-input").on("keypress", function (e) {
if (e.which === 13) {
clearTimeout(searchTimeout);
searchQuery = $(this).val().trim();
renderUsers();
}
});
$("#reset-filters-btn").on("click", function () {
$("#user-search-input").val("");
$("#role-filter-input").val("");
searchQuery = "";
selectedFilterRoles.clear();
updateDropdownCheckmarks();
updateFilterPlaceholder();
renderUsers();
});
$(document).on("keydown", function (e) {
if (e.key === "Escape") {
closeEditModal();
closeDeleteModal();
}
});
});
+267
View File
@@ -0,0 +1,267 @@
const StorageHelper = {
get: (key) => {
return localStorage.getItem(key) || sessionStorage.getItem(key);
},
getCurrentStorage: () => {
return localStorage.getItem("refresh_token")
? localStorage
: sessionStorage;
},
clearAll: () => {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
sessionStorage.removeItem("access_token");
sessionStorage.removeItem("refresh_token");
sessionStorage.removeItem("user");
},
};
const Utils = {
escapeHtml: (text) => {
if (!text) return "";
return text.replace(
/[&<>"']/g,
(m) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[m],
);
},
showToast: (message, type = "info") => {
const container = document.getElementById("toast-container");
if (!container) return;
const el = document.createElement("div");
const colors =
type === "error"
? "bg-red-500"
: type === "success"
? "bg-green-500"
: "bg-blue-500";
el.className = `${colors} text-white px-6 py-3 rounded shadow-lg transform transition-all duration-300 translate-y-10 opacity-0 mb-3`;
el.textContent = message;
container.appendChild(el);
requestAnimationFrame(() => {
el.classList.remove("translate-y-10", "opacity-0");
});
setTimeout(() => {
el.classList.add("translate-y-10", "opacity-0");
setTimeout(() => el.remove(), 300);
}, 3000);
},
getGravatarUrl: async (email) => {
if (!email) return "";
const msgBuffer = new TextEncoder().encode(email.trim().toLowerCase());
const hashBuffer = await crypto.subtle.digest("SHA-256", msgBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return `https://www.gravatar.com/avatar/${hashHex}?d=identicon&s=200`;
},
};
const Api = {
getBaseUrl() {
return window.location.origin;
},
async request(endpoint, options = {}) {
const fullUrl = this.getBaseUrl() + endpoint;
const token = StorageHelper.get("access_token");
const headers = {
"Content-Type": "application/json",
...options.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const config = { ...options, headers };
try {
const response = await fetch(fullUrl, config);
const isLoginRequest = endpoint.includes("/auth/token");
if (response.status === 401 && !isLoginRequest) {
const refreshed = await Auth.tryRefresh();
if (refreshed) {
headers["Authorization"] =
`Bearer ${StorageHelper.get("access_token")}`;
const retryResponse = await fetch(fullUrl, { ...options, headers });
if (retryResponse.ok) {
return retryResponse.json();
}
}
Auth.logout();
return null;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.detail ||
errorData.error_description ||
`Ошибка ${response.status}`,
);
}
return response.json();
} catch (error) {
throw error;
}
},
get(endpoint) {
return this.request(endpoint, { method: "GET" });
},
post(endpoint, body) {
return this.request(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
},
put(endpoint, body) {
return this.request(endpoint, {
method: "PUT",
body: JSON.stringify(body),
});
},
delete(endpoint) {
return this.request(endpoint, { method: "DELETE" });
},
postForm(endpoint, formData) {
return this.request(endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: formData.toString(),
});
},
};
const Auth = {
logout: () => {
StorageHelper.clearAll();
window.location.href = "/";
},
tryRefresh: async () => {
const refreshToken = StorageHelper.get("refresh_token");
if (!refreshToken) return false;
const activeStorage = StorageHelper.getCurrentStorage();
try {
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (response.ok) {
const data = await response.json();
activeStorage.setItem("access_token", data.access_token);
activeStorage.setItem("refresh_token", data.refresh_token);
return true;
}
} catch (e) {
console.error("Refresh failed:", e);
}
return false;
},
init: async () => {
const token = StorageHelper.get("access_token");
const refreshToken = StorageHelper.get("refresh_token");
if (!token && !refreshToken) {
localStorage.removeItem("user");
sessionStorage.removeItem("user");
return null;
}
const activeStorage = StorageHelper.getCurrentStorage();
try {
let response = await fetch("/api/auth/me", {
headers: { Authorization: "Bearer " + token },
});
if (response.status === 401 && refreshToken) {
const refreshed = await Auth.tryRefresh();
if (refreshed) {
response = await fetch("/api/auth/me", {
headers: {
Authorization: "Bearer " + StorageHelper.get("access_token"),
},
});
}
}
if (response.ok) {
const user = await response.json();
activeStorage.setItem("user", JSON.stringify(user));
document.dispatchEvent(new CustomEvent("auth:login", { detail: user }));
return user;
}
} catch (e) {
console.error("Auth check failed", e);
}
StorageHelper.clearAll();
return null;
},
};
window.getUser = function () {
const userJson = StorageHelper.get("user");
if (!userJson) return null;
try {
return JSON.parse(userJson);
} catch (e) {
return null;
}
};
window.hasRole = function (roleName) {
const user = window.getUser();
if (!user || !user.roles) {
return false;
}
return user.roles.includes(roleName);
};
window.isAdmin = function () {
return window.hasRole("admin");
};
window.isLibrarian = function () {
return window.hasRole("librarian") || window.hasRole("admin");
};
window.isAuthenticated = function () {
return !!window.getUser();
};
window.canManage = function () {
return (
(typeof window.isAdmin === "function" && window.isAdmin()) ||
(typeof window.isLibrarian === "function" && window.isLibrarian())
);
};
+159
View File
@@ -0,0 +1,159 @@
{% extends "base.html" %} {% block title %}LiB - Настройка 2FA{% endblock %} {%
block content %}
<div class="flex flex-1 items-center justify-center p-4 bg-gray-100">
<div
class="w-full max-w-4xl bg-white rounded-lg shadow-md overflow-hidden flex flex-col md:flex-row"
>
<div
class="w-full md:w-1/2 p-8 bg-gray-50 flex flex-col items-center justify-center border-b md:border-b-0 md:border-r border-gray-200"
>
<h2 class="text-xl font-semibold text-gray-800 mb-2">
Настройка 2FA
</h2>
<p class="text-sm text-gray-500 text-center mb-6">
Отсканируйте код в Google Authenticator
</p>
<div
id="qr-container"
class="relative flex items-center justify-center p-2 mb-4"
style="min-height: 220px"
>
<div class="loader flex items-center justify-center">
<svg
class="animate-spin h-8 w-8 text-gray-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</div>
</div>
<div
class="w-full max-w-[320px] p-4 border-2 border-dashed border-gray-300 rounded-lg bg-white bg-opacity-50 text-center"
>
<p
class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-semibold"
>
Секретный ключ
</p>
<div
id="secret-copy-btn"
class="relative group cursor-pointer"
title="Нажмите, чтобы скопировать"
>
<code
id="secret-code-display"
class="block w-full py-2 bg-gray-100 text-gray-800 rounded border border-gray-200 text-sm font-mono break-all select-all hover:bg-gray-200 transition-colors"
>...</code
>
<div
class="absolute inset-0 flex items-center justify-center bg-gray-800 bg-opacity-90 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity duration-200"
>
Копировать
</div>
</div>
</div>
</div>
<div class="w-full md:w-1/2 p-8 flex flex-col justify-center">
<div class="max-w-xs mx-auto w-full">
<h2
class="text-2xl font-semibold text-gray-800 text-center mb-6"
>
Введите код
</h2>
<form id="totp-form">
<div
class="flex justify-center space-x-2 sm:space-x-4 mb-6"
>
<input
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
data-index="0"
/>
<input
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
data-index="1"
/>
<input
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
data-index="2"
/>
<input
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
data-index="3"
/>
<input
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
data-index="4"
/>
<input
type="text"
inputmode="numeric"
maxlength="1"
autocomplete="one-time-code"
class="totp-digit w-10 h-14 text-center text-xl border-b-2 border-gray-300 focus:border-gray-800 focus:outline-none transition-colors caret-transparent bg-transparent"
data-index="5"
/>
</div>
<div
id="form-message"
class="mb-4 text-center text-sm min-h-[20px]"
></div>
<button
type="submit"
id="verify-btn"
disabled
class="w-full py-2 px-4 bg-gray-800 text-white rounded-md hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
>
Подтвердить
</button>
<a
href="/profile"
class="block w-full text-center mt-4 text-sm text-gray-500 hover:text-gray-700"
>
Отмена
</a>
</form>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/2fa.js"></script>
{% endblock %}
+142
View File
@@ -0,0 +1,142 @@
{% extends "base.html" %} {% block title %}Аналитика - LiB{% endblock %} {% block content %}
<div class="container mx-auto p-4 max-w-7xl">
<div class="mb-8">
<h1 class="text-2xl font-semibold text-gray-900 mb-1">Аналитика выдач и возвратов</h1>
<p class="text-sm text-gray-500">Статистика и графики по выдачам книг</p>
</div>
<!-- Период анализа -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-6 border border-gray-100">
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-600">Период анализа:</label>
<select id="period-select" class="px-3 py-1.5 text-sm border border-gray-200 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-400 transition bg-white">
<option value="7">7 дней</option>
<option value="30" selected>30 дней</option>
<option value="90">90 дней</option>
<option value="180">180 дней</option>
<option value="365">365 дней</option>
</select>
<button id="refresh-btn" class="px-3 py-1.5 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm font-medium">
Обновить
</button>
</div>
</div>
<!-- Общая статистика -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Всего выдач</p>
<p class="text-2xl font-semibold text-gray-900" id="total-loans"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Активные выдачи</p>
<p class="text-2xl font-semibold text-gray-900" id="active-loans"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Возвращено</p>
<p class="text-2xl font-semibold text-gray-900" id="returned-loans"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Просрочено</p>
<p class="text-2xl font-semibold text-red-600" id="overdue-loans"></p>
</div>
<div class="w-10 h-10 bg-red-50 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Забронировано</p>
<p class="text-2xl font-semibold text-gray-900" id="reserved-books"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-5 border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-gray-500 mb-1.5 uppercase tracking-wide">Выдано сейчас</p>
<p class="text-2xl font-semibold text-gray-900" id="borrowed-books"></p>
</div>
<div class="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
</div>
</div>
</div>
</div>
<!-- Графики -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Выдачи по дням</h2>
<div class="h-64">
<canvas id="loans-chart"></canvas>
</div>
</div>
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Возвраты по дням</h2>
<div class="h-64">
<canvas id="returns-chart"></canvas>
</div>
</div>
</div>
<!-- Топ книг -->
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-100">
<h2 class="text-base font-medium text-gray-700 mb-6">Топ книг по выдачам</h2>
<div id="top-books-container" class="space-y-2">
<div class="text-center text-gray-500 py-8">Загрузка данных...</div>
</div>
</div>
</div>
{% endblock %} {% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
{% endblock %} {% block scripts %}
<script src="/static/analytics.js"></script>
{% endblock %}
-4
View File
@@ -19,7 +19,6 @@ block content %}
Регистрация Регистрация
</button> </button>
</div> </div>
<form id="login-form" class="p-6"> <form id="login-form" class="p-6">
<div class="mb-4"> <div class="mb-4">
<label <label
@@ -54,7 +53,6 @@ block content %}
<button <button
type="button" type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
onclick="togglePassword(this)"
> >
<svg <svg
class="eye-open w-5 h-5" class="eye-open w-5 h-5"
@@ -191,7 +189,6 @@ block content %}
<button <button
type="button" type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
onclick="togglePassword(this)"
> >
<svg <svg
class="eye-open w-5 h-5" class="eye-open w-5 h-5"
@@ -260,7 +257,6 @@ block content %}
<button <button
type="button" type="button"
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600" class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
onclick="togglePassword(this)"
> >
<svg <svg
class="eye-open w-5 h-5" class="eye-open w-5 h-5"
+113 -10
View File
@@ -1,16 +1,119 @@
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %} {% block {% extends "base.html" %} {% block content %}
content %} <div class="container mx-auto p-4 max-w-4xl">
<div class="flex flex-1 mt-4 p-4"> <div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<main class="flex-1 max-w-4xl mx-auto"> <div class="flex items-center justify-between mb-4">
<div id="author-card" class="bg-white p-6 rounded-lg shadow-md mb-6"> <a
href="/authors"
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors"
>
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
></path>
</svg>
Вернуться к списку авторов
</a>
<a
id="edit-author-btn"
href="#"
class="hidden p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Редактировать автора"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</a>
</div> </div>
<div id="books-section" class="bg-white p-6 rounded-lg shadow-md"> <div id="author-loader" class="flex items-start animate-pulse">
<h2 class="text-xl font-semibold mb-4">Книги автора</h2> <div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
<div id="books-container"> <div class="flex-1">
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
</div> </div>
</div> </div>
</main> <div id="author-content" class="hidden flex items-start">
<div
id="author-avatar"
class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0"
></div>
<div class="flex-1">
<div class="flex items-center justify-between mb-2">
<h1
id="author-name"
class="text-3xl font-bold text-gray-900"
></h1>
<span id="author-id" class="text-sm text-gray-500"></span>
</div>
<div class="flex items-center text-gray-600 mb-4">
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
<span id="author-books-count"></span>
</div>
</div>
</div>
</div>
<div id="books-section">
<h2 class="text-xl font-bold mb-4">Книги автора</h2>
<div id="books-container" class="space-y-4"></div>
</div>
</div> </div>
<template id="book-item-template">
<div
class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow duration-200 cursor-pointer book-card bg-white"
>
<div class="flex justify-between items-start">
<div class="flex-1">
<h3
class="book-title text-lg font-semibold text-gray-900 hover:text-gray-400 transition-colors mb-2"
></h3>
<p class="book-desc text-gray-600 text-sm line-clamp-3"></p>
</div>
<svg
class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
</div>
</template>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/author.js"></script> <script src="/static/author.js"></script>
{% endblock %} {% endblock %}
+107 -56
View File
@@ -1,20 +1,88 @@
{% extends "base.html" %} {% block title %}LiB - Авторы{% endblock %} {% block {% extends "base.html" %} {% block content %}
content %} <div class="container mx-auto p-4">
<div class="flex flex-1 mt-4 p-4"> <div
<aside class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
> >
<h2 class="text-xl font-semibold mb-4">Поиск</h2> <h2 class="text-2xl font-bold text-gray-800">Авторы</h2>
<div class="relative mb-4">
<input <div class="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
type="text" <div class="relative">
id="author-search-input" <input
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500" type="text"
placeholder="Поиск авторов..." id="author-search-input"
maxlength="50" placeholder="Поиск автора..."
/> class="border rounded-lg pl-3 pr-10 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 w-full sm:w-64"
/>
<svg
class="absolute right-3 top-2.5 h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<div class="flex gap-2 bg-white rounded-lg p-1 border">
<label
class="cursor-pointer px-3 py-1 rounded hover:bg-gray-100 flex items-center gap-1"
>
<input
type="radio"
name="sort"
value="name_asc"
checked
class="hidden peer"
/>
<span
class="text-sm text-gray-600 peer-checked:text-black peer-checked:font-bold"
>А</span
>
</label>
<label
class="cursor-pointer px-3 py-1 rounded hover:bg-gray-100 flex items-center gap-1"
>
<input
type="radio"
name="sort"
value="name_desc"
class="hidden peer"
/>
<span
class="text-sm text-gray-600 peer-checked:text-black peer-checked:font-bold"
>Я-А</span
>
</label>
</div>
</div>
</div>
<div id="results-counter" class="text-sm text-gray-500 mb-4"></div>
<div
id="authors-container"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
></div>
<div id="pagination-container"></div>
</div>
<template id="author-card-template">
<div
class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer author-card"
>
<div class="flex items-center">
<div
class="author-avatar w-12 h-12 bg-gray-500 text-white rounded-full flex items-center justify-center text-xl font-bold mr-4"
></div>
<div class="flex-1">
<h3
class="author-name text-lg font-semibold text-gray-900 hover:text-gray-400 transition-colors"
></h3>
<p class="author-id text-sm text-gray-500"></p>
</div>
<svg <svg
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" class="w-5 h-5 text-gray-400"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -23,50 +91,33 @@ content %}
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" d="M9 5l7 7-7 7"
/> />
</svg> </svg>
</div> </div>
<h2 class="text-xl font-semibold mb-4">Сортировка</h2> </div>
<div class="mb-4"> </template>
<div class="space-y-2"> <template id="empty-state-template">
<label class="flex items-center cursor-pointer"> <div class="col-span-full bg-white p-8 rounded-lg shadow-md text-center">
<input <svg
type="radio" class="mx-auto h-12 w-12 text-gray-400 mb-4"
name="sort" fill="none"
value="name_asc" stroke="currentColor"
checked viewBox="0 0 24 24"
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
/>
<span class="ml-2 text-gray-700">По имени (А-Я)</span>
</label>
<label class="flex items-center cursor-pointer">
<input
type="radio"
name="sort"
value="name_desc"
class="w-4 h-4 text-gray-500 focus:ring-gray-500"
/>
<span class="ml-2 text-gray-700">По имени (Я-А)</span>
</label>
</div>
</div>
<button
id="reset-filters-btn"
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200"
> >
Сбросить <path
</button> stroke-linecap="round"
<div stroke-linejoin="round"
id="results-counter" stroke-width="2"
class="mt-4 text-center text-sm text-gray-500" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
></div> />
</aside> </svg>
<main class="flex-1"> <h3 class="text-lg font-medium text-gray-900 mb-2">
<div id="authors-container"></div> Авторы не найдены
<div id="pagination-container"></div> </h3>
</main> <p class="text-gray-500">Попробуйте изменить параметры поиска</p>
</div> </div>
</template>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/authors.js"></script> <script src="/static/authors.js"></script>
{% endblock %} {% endblock %}
+212 -44
View File
@@ -4,14 +4,30 @@
<title>{% block title %}LiB{% endblock %}</title> <title>{% block title %}LiB{% endblock %}</title>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script async="" src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script> <script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.3/dist/cdn.min.js"
></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cash/8.1.5/cash.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/cash/8.1.5/cash.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<script src="/static/utils.js"></script>
<link rel="stylesheet" href="/static/styles.css" /> <link rel="stylesheet" href="/static/styles.css" />
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="flex flex-col min-h-screen bg-gray-100"> <body
<header class="bg-gray-500 text-white p-4 shadow-md"> class="flex flex-col min-h-screen bg-gray-100"
x-data="{
user: null,
async init() {
document.addEventListener('auth:login', async (e) => {
this.user = e.detail;
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
});
await Auth.init();
}
}"
>
<header class="bg-gray-600 text-white p-4 shadow-md">
<div class="mx-auto pl-5 pr-3 flex justify-between items-center"> <div class="mx-auto pl-5 pr-3 flex justify-between items-center">
<a class="flex gap-4 items-center max-w-10 h-auto" href="/"> <a class="flex gap-4 items-center max-w-10 h-auto" href="/">
<img class="invert" src="/static/logo.svg" /> <img class="invert" src="/static/logo.svg" />
@@ -19,60 +35,212 @@
</a> </a>
<nav> <nav>
<ul class="flex space-x-4"> <ul class="flex space-x-4">
<li><a href="/" class="hover:text-gray-200">Главная</a></li> <li>
<li><a href="/books" class="hover:text-gray-200">Книги</a></li> <a href="/" class="hover:text-gray-200">Главная</a>
<li><a href="/authors" class="hover:text-gray-200">Авторы</a></li> </li>
<li><a href="/about" class="hover:text-gray-200">О нас</a></li> <li>
<li><a href="/api" class="hover:text-gray-200">API</a></li> <a href="/books" class="hover:text-gray-200"
>Книги</a
>
</li>
<li>
<a href="/authors" class="hover:text-gray-200"
>Авторы</a
>
</li>
<li>
<a href="/api" class="hover:text-gray-200">API</a>
</li>
</ul> </ul>
</nav> </nav>
<div class="relative" id="user-menu-area"> <div class="relative" x-data="{ open: false }">
<a href="/auth" id="guest-link" class="block hover:opacity-80 transition"><img class="w-6 h-6 invert" src="/static/avatar.svg" /></a> <template x-if="!user">
<button type="button" id="user-btn" class="hidden items-center gap-2 hover:opacity-80 transition focus:outline-none"> <a
<img href="/auth"
id="user-avatar" class="block hover:opacity-80 transition"
src="https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y" >
class="w-8 h-8 rounded-full border-2 border-white object-cover bg-gray-600" <svg
alt="User Avatar" class="w-7 h-7"
/> fill="none"
stroke="currentColor"
<svg id="user-arrow" class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"> viewBox="0 0 24 24"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> >
</svg> <path
</button> stroke-linecap="round"
<div id="user-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden"> stroke-linejoin="round"
<div class="px-4 py-3 border-b border-gray-200"> stroke-width="1.5"
<p id="dropdown-name" class="text-sm font-semibold text-gray-900 truncate">Пользователь</p> d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
<p id="dropdown-username" class="text-sm text-gray-500 truncate">@username</p> ></path>
<p id="dropdown-email" class="text-xs text-gray-400 truncate mt-1">email@example.com</p> </svg>
</div>
<a href="/profile" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100">
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
<p class="text-gray-700 text-sm">Мой профиль</p>
</a> </a>
<a href="/my-books" class="flex items-center px-4 py-2 text-sm hover:bg-gray-100"> </template>
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg> <template x-if="user">
<p class="text-gray-700 text-sm">Мои книги</p> <div>
</a> <button
<div class="border-t border-gray-200 mt-1 pt-1"> @click="open = !open"
<button type="button" id="logout-btn" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"> @click.outside="open = false"
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg> type="button"
<p class="text-gray-700 text-sm">Выйти</p> class="flex items-center gap-2 hover:opacity-80 transition focus:outline-none"
>
<img
:src="user.avatar"
class="w-8 h-8 rounded-full border border-white object-cover bg-gray-600"
/>
<svg
class="w-4 h-4 transition-transform duration-200"
:class="open ? 'rotate-180' : ''"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button> </button>
<div
x-show="open"
x-transition
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
style="display: none"
>
<div class="px-4 py-3 border-b border-gray-200">
<p
class="text-sm font-semibold truncate"
x-text="user.full_name || user.username"
></p>
<p
class="text-sm text-gray-500 truncate"
x-text="'@' + user.username"
></p>
<p
class="text-xs text-gray-400 truncate mt-1"
x-text="user.email"
></p>
</div>
<a
href="/profile"
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
></path>
</svg>
Мой профиль
</a>
<a
href="/my-books"
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
Мои книги
</a>
<template
x-if="user.roles && user.roles.includes('admin')"
>
<div>
<a
href="/users"
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
Пользователи
</a>
<a
href="/analytics"
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
Аналитика
</a>
</div>
</template>
<div class="border-t border-gray-200">
<button
@click="Auth.logout()"
class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
<svg
class="w-4 h-4 mr-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
></path>
</svg>
Выйти
</button>
</div>
</div>
</div> </div>
</div> </template>
</div> </div>
</div> </div>
</header> </header>
<main class="flex-grow">{% block content %}{% endblock %}</main>
{% block content %}{% endblock %} <div
id="toast-container"
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50"
></div>
<footer class="bg-gray-800 text-white p-4 mt-8"> <footer class="bg-gray-800 text-white p-4 mt-8">
<div class="container mx-auto text-center"> <div class="container mx-auto text-center">
<p>&copy; 2025 My Awesome Library. All rights reserved.</p> <p>&copy; 2025 LiB Library. All rights reserved.</p>
</div> </div>
</footer> </footer>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
</body> </body>
</html> </html>
+292 -13
View File
@@ -1,21 +1,300 @@
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %} {% block {% extends "base.html" %} {% block content %}
content %} <div class="container mx-auto p-4 max-w-6xl">
<div class="flex flex-1 mt-4 p-4"> <div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<main class="flex-1 max-w-4xl mx-auto"> <div class="flex items-center justify-between mb-4">
<div id="book-card" class="bg-white p-6 rounded-lg shadow-md mb-6"> <a
href="/books"
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors"
>
<svg
class="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
></path>
</svg>
Вернуться к списку книг
</a>
<a
id="edit-book-btn"
href="#"
class="hidden p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Редактировать книгу"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</a>
</div> </div>
<div id="authors-section" class="bg-white p-6 rounded-lg shadow-md mb-6"> <div
<h2 class="text-xl font-semibold mb-4">Авторы</h2> id="book-loader"
<div id="authors-container"> class="flex flex-col md:flex-row items-start animate-pulse"
>
<div
class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"
></div>
<div class="flex-1 w-full">
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
<div class="space-y-2 mb-6">
<div class="h-4 bg-gray-200 rounded w-full"></div>
<div class="h-4 bg-gray-200 rounded w-full"></div>
</div>
</div> </div>
</div> </div>
<div id="genres-section" class="bg-white p-6 rounded-lg shadow-md"> <div
<h2 class="text-xl font-semibold mb-4">Жанры</h2> id="book-content"
<div id="genres-container"> class="hidden flex flex-col md:flex-row items-start"
>
<div
class="flex flex-col items-center mb-6 md:mb-0 md:mr-8 flex-shrink-0 w-full md:w-auto"
>
<div
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
>
<svg
class="w-20 h-20 text-white opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
</div>
<div
id="book-status-container"
class="relative w-full flex justify-center z-10 mb-4"
></div>
<div id="book-actions-container" class="w-full"></div>
</div>
<div class="flex-1 w-full">
<div
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
>
<h1
id="book-title"
class="text-3xl font-bold text-gray-900 leading-tight"
></h1>
<span
id="book-id"
class="hidden md:inline-block text-xs font-mono text-gray-400 mt-2 md:mt-0 md:ml-4"
></span>
</div>
<p
id="book-authors-text"
class="text-lg text-gray-600 font-medium mb-6"
></p>
<div class="prose prose-gray max-w-none mb-8">
<h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
>
Описание
</h3>
<p
id="book-description"
class="text-gray-700 leading-relaxed"
></p>
</div>
<div id="genres-section" class="mb-6 hidden">
<h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
>
Жанры
</h3>
<div
id="genres-container"
class="flex flex-wrap gap-2"
></div>
</div>
<div id="authors-section" class="mb-6 hidden">
<h3
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
>
Авторы (детально)
</h3>
<div
id="authors-container"
class="flex flex-wrap gap-3"
></div>
</div>
</div> </div>
</div> </div>
</main> </div>
<!-- Секция выдачи для библиотекарей и администраторов -->
<div id="loans-section" class="hidden bg-white rounded-lg shadow-md p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Выдачи книги</h2>
<button
id="refresh-loans-btn"
class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
title="Обновить список выдач"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
></path>
</svg>
</button>
</div>
<div id="loans-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка информации о выдачах...
</div>
</div>
</div>
</div>
<!-- Модальное окно для выдачи книги -->
<div
id="loan-modal"
class="hidden fixed inset-0 z-50 overflow-y-auto"
aria-labelledby="modal-title"
role="dialog"
aria-modal="true"
>
<div
class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0"
>
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
aria-hidden="true"
></div>
<span
class="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>&#8203;</span
>
<div
class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10"
>
<svg
class="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
></path>
</svg>
</div>
<div
class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full"
>
<h3
class="text-lg leading-6 font-medium text-gray-900"
id="modal-title"
>
Оформить выдачу книги
</h3>
<div class="mt-4">
<div class="relative">
<input
type="text"
id="user-search-input"
class="w-full border border-gray-300 rounded-md px-4 py-2 pl-10 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Поиск пользователя (имя, email)..."
/>
<svg
class="w-5 h-5 text-gray-400 absolute left-3 top-2.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<div
id="users-list-container"
class="mt-4 border border-gray-200 rounded-md max-h-60 overflow-y-auto divide-y divide-gray-100"
>
<div
class="p-4 text-center text-gray-500 text-sm"
>
Начните ввод для поиска...
</div>
</div>
<div class="mt-4">
<label
class="block text-sm font-medium text-gray-700"
>Срок возврата</label
>
<input
type="date"
id="loan-due-date"
class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-blue-500 sm:text-sm"
/>
</div>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button
type="button"
id="confirm-loan-btn"
disabled
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Выдать
</button>
<button
type="button"
id="cancel-loan-btn"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Отмена
</button>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/book.js"></script> <script src="/static/book.js"></script>
{% endblock %} {% endblock %}
+168 -57
View File
@@ -1,79 +1,190 @@
{% extends "base.html" %} {% block title %}LiB - Главная{% endblock %} {% block {% extends "base.html" %} {% block content %}
content %} <div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
<div class="flex flex-1 mt-4 p-4"> <aside class="w-full md:w-1/4">
<aside <div
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96" id="admin-actions"
> class="hidden bg-white px-4 py-2 rounded-lg shadow-md mb-6"
<h2 class="text-xl font-semibold mb-4">Поиск</h2> >
<div class="relative mb-4"> <a
<input href="/author/create"
type="text" class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
id="book-search-input"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
placeholder="Поиск книг (мин. 3 символа)..."
minlength="3"
maxlength="50"
/>
<svg
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <svg
stroke-linecap="round" class="w-5 h-5 mr-2"
stroke-linejoin="round" fill="none"
stroke-width="2" stroke="currentColor"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" viewBox="0 0 24 24"
/>
</svg>
</div>
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
<div class="mb-4">
<h3 class="font-medium mb-2">Авторы</h3>
<div class="relative">
<div
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white min-h-[42px]"
id="selected-authors-container"
> >
<input <path
type="text" stroke-linecap="round"
id="author-search-input" stroke-linejoin="round"
class="flex-grow outline-none bg-transparent min-w-[100px]" stroke-width="2"
placeholder="Начните вводить..." d="M12 4v16m8-8H4"
/> />
</div> </svg>
Добавить автора
</a>
<a
href="/genre/create"
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Добавить жанр
</a>
<a
href="/book/create"
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Добавить книгу
</a>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-bold mb-4">Поиск</h2>
<div class="relative">
<input
type="text"
id="book-search-input"
placeholder="Название книги..."
class="w-full border rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
/>
<svg
class="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h2 class="text-xl font-bold mb-4">Авторы</h2>
<div
id="selected-authors-container"
class="flex flex-wrap gap-2 mb-2 min-h-[0px]"
></div>
<div class="relative">
<input
type="text"
id="author-search-input"
placeholder="Поиск автора..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div <div
id="author-dropdown" id="author-dropdown"
class="absolute z-10 w-full bg-white border border-gray-300 rounded-md mt-1 hidden max-h-60 overflow-y-auto shadow-lg" class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div> ></div>
</div> </div>
</div> </div>
<div class="mb-4"> <div class="bg-white p-4 rounded-lg shadow-md mb-6">
<h3 class="font-medium mb-2">Жанры</h3> <h2 class="text-xl font-bold mb-4">Жанры</h2>
<ul id="genres-list" class="max-h-60 overflow-y-auto"></ul> <ul
id="genres-list"
class="space-y-2 max-h-60 overflow-y-auto text-sm text-gray-700"
></ul>
</div> </div>
<button <button
id="apply-filters-btn" id="apply-filters-btn"
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 mb-2" class="w-full bg-gray-600 text-white font-bold py-2 px-4 rounded-lg hover:bg-gray-700 transition mb-2"
> >
Применить фильтры Применить
</button> </button>
<button <button
id="reset-filters-btn" id="reset-filters-btn"
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200" class="w-full bg-white text-gray-600 border border-gray-300 font-bold py-2 px-4 rounded-lg hover:bg-gray-50 transition"
> >
Сбросить фильтры Сбросить
</button> </button>
<div
id="results-counter"
class="mt-4 text-center text-sm text-gray-500"
></div>
</aside> </aside>
<main class="flex-1"> <main class="w-full md:w-3/4">
<div id="books-container"></div> <div id="books-container" class="grid grid-cols-1 gap-4"></div>
<div id="pagination-container"></div>
</main> </main>
</div> </div>
<template id="book-card-template">
<div
class="bg-white p-4 rounded-lg shadow-md mb-4 hover:shadow-lg transition-shadow duration-200 cursor-pointer book-card"
>
<div class="flex items-start">
<div class="flex-1">
<div class="flex justify-between items-center gap-2 mb-1">
<h3
class="book-title text-lg font-bold text-gray-900 hover:text-gray-400 transition-colors"
></h3>
<span
class="book-status inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
></span>
</div>
<p class="text-sm text-gray-600 mb-2">
<span class="font-medium">Авторы:</span>
<span class="book-authors"></span>
</p>
<p
class="book-desc text-gray-700 text-sm mb-2 line-clamp-3"
></p>
<div class="book-genres flex flex-wrap gap-1"></div>
</div>
</div>
</div>
</template>
<template id="genre-badge-template">
<span
class="inline-block bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full"
></span>
</template>
<template id="empty-state-template">
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Книги не найдены</h3>
<p class="text-gray-500">
Попробуйте изменить параметры поиска или фильтры
</p>
</div>
</template>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/books.js"></script> <script src="/static/books.js"></script>
{% endblock %} {% endblock %}
@@ -0,0 +1,162 @@
{% extends "base.html" %} {% block title %}Создание автора | LiB{% endblock %}
{% block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
Добавить автора
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Укажите имя нового автора для каталога.
</p>
</div>
<form id="create-author-form" class="space-y-6">
<div>
<label
for="author-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Имя автора <span class="text-red-500">*</span>
</label>
<input
type="text"
id="author-name"
name="name"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Имя или псевдоним автора"
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span id="submit-text">Создать автора</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
href="/authors"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Автор успешно добавлен!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Автор
<span
id="modal-author-name"
class="font-bold text-gray-800"
></span>
сохранён в каталоге.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="modal-link-btn"
href="/authors"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
К списку авторов
</a>
<button
id="modal-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Создать ещё
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_author.js"></script>
{% endblock %}
+229
View File
@@ -0,0 +1,229 @@
{% extends "base.html" %} {% block title %}Создание книги | LiB{% endblock %} {%
block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
Добавить новую книгу
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Заполните информацию о книге, укажите авторов и жанры.
</p>
</div>
<form id="create-book-form" class="space-y-6">
<div>
<label
for="book-title"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название книги <span class="text-red-500">*</span>
</label>
<input
type="text"
id="book-title"
name="title"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Название книги..."
/>
<div class="flex justify-end mt-1">
<span id="title-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div>
<label
for="book-description"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Описание
</label>
<textarea
id="book-description"
name="description"
rows="5"
maxlength="2000"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none transition"
placeholder="Краткое описание сюжета..."
></textarea>
<div class="flex justify-end mt-1">
<span id="desc-counter" class="text-xs text-gray-400"
>0/2000</span
>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
Авторы
</h2>
<div
id="selected-authors-container"
class="flex flex-wrap gap-2 mb-3 min-h-[0px]"
></div>
<div class="relative">
<input
type="text"
id="author-search-input"
placeholder="Поиск автора..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="author-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
Жанры
</h2>
<div
id="selected-genres-container"
class="flex flex-wrap gap-2 mb-3 min-h-[0px]"
></div>
<div class="relative">
<input
type="text"
id="genre-search-input"
placeholder="Поиск жанра..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="genre-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span id="submit-text">Создать книгу</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
href="/books"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Книга успешно добавлена!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Книга
<span
id="modal-book-title"
class="font-bold text-gray-800"
></span>
сохранена в каталоге.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="modal-link-btn"
href="#"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Перейти к книге
</a>
<button
id="modal-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Создать ещё
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_book.js"></script>
{% endblock %}
+162
View File
@@ -0,0 +1,162 @@
{% extends "base.html" %} {% block title %}Создание жанра | LiB{% endblock %} {%
block content %}
<div class="container mx-auto p-4 max-w-xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
/>
</svg>
Добавить жанр
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Укажите название нового жанра для каталога.
</p>
</div>
<form id="create-genre-form" class="space-y-6">
<div>
<label
for="genre-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название жанра <span class="text-red-500">*</span>
</label>
<input
type="text"
id="genre-name"
name="name"
required
maxlength="100"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Например: Научная фантастика"
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/100</span
>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
<span id="submit-text">Создать жанр</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
href="/books"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Жанр успешно добавлен!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Жанр
<span
id="modal-genre-name"
class="font-bold text-gray-800"
></span>
сохранён в каталоге.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="modal-link-btn"
href="/books"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
К списку книг
</a>
<button
id="modal-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Создать ещё
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/create_genre.js"></script>
{% endblock %}
+316
View File
@@ -0,0 +1,316 @@
{% extends "base.html" %} {% block title %}Редактирование автора | LiB{%
endblock %} {% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
<span>Редактирование автора</span>
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Измените данные автора или удалите его из каталога.
</p>
</div>
<div id="loader" class="animate-pulse space-y-4">
<div class="h-12 bg-gray-200 rounded w-full"></div>
<div class="h-24 bg-gray-200 rounded w-full"></div>
</div>
<form id="edit-author-form" class="hidden space-y-6">
<div>
<label
for="author-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Имя автора <span class="text-red-500">*</span>
</label>
<input
type="text"
id="author-name"
name="name"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Имя автора..."
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Книги автора</span>
<span
id="books-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="author-books-container"
class="space-y-2 max-h-64 overflow-y-auto"
>
<div class="text-sm text-gray-500 text-center py-4">
Загрузка...
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span id="submit-text">Сохранить изменения</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
id="cancel-btn"
href="/authors"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
Опасная зона
</h3>
<p class="text-sm text-gray-600 mb-4">
Удаление автора необратимо. Связи с книгами будут удалены, но
сами книги сохранятся.
</p>
<button
id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
Удалить автора
</button>
</div>
</div>
</div>
<div
id="delete-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Удалить автора?
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить автора
<span
id="modal-author-name"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
</p>
<p
id="modal-books-warning"
class="hidden text-sm text-orange-600 mt-2"
>
У автора есть связанные книги. Связи будут удалены.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<button
id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
>
<span>Удалить</span>
<svg
id="delete-spinner"
class="hidden animate-spin ml-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<button
id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Отмена
</button>
</div>
</div>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Изменения сохранены!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Автор
<span
id="success-author-name"
class="font-bold text-gray-800"
></span>
успешно обновлён.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="success-link-btn"
href="/authors"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
К списку авторов
</a>
<button
id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Продолжить
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_author.js"></script>
{% endblock %}
+394
View File
@@ -0,0 +1,394 @@
{% extends "base.html" %} {% block title %}Редактирование книги | LiB{% endblock
%} {% block content %}
<div class="container mx-auto p-4 max-w-3xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
<span>Редактирование книги</span>
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Измените информацию о книге, управляйте авторами и жанрами.
</p>
</div>
<div id="loader" class="animate-pulse space-y-4">
<div class="h-12 bg-gray-200 rounded w-full"></div>
<div class="h-32 bg-gray-200 rounded w-full"></div>
<div class="h-12 bg-gray-200 rounded w-1/3"></div>
</div>
<form id="edit-book-form" class="hidden space-y-6">
<div>
<label
for="book-title"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название книги <span class="text-red-500">*</span>
</label>
<input
type="text"
id="book-title"
name="title"
required
maxlength="255"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Название книги..."
/>
<div class="flex justify-end mt-1">
<span id="title-counter" class="text-xs text-gray-400"
>0/255</span
>
</div>
</div>
<div>
<label
for="book-description"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Описание
</label>
<textarea
id="book-description"
name="description"
rows="5"
maxlength="2000"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none transition"
placeholder="Краткое описание сюжета..."
></textarea>
<div class="flex justify-end mt-1">
<span id="desc-counter" class="text-xs text-gray-400"
>0/2000</span
>
</div>
</div>
<div>
<label
for="book-status"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Статус
</label>
<select
id="book-status"
name="status"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition bg-white"
>
<option value="active">Доступна</option>
<option value="borrowed">Выдана</option>
<option value="reserved">Забронирована</option>
<option value="restoration">На реставрации</option>
<option value="written_off">Списана</option>
</select>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Авторы</span>
<span
id="authors-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="current-authors-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div class="relative">
<input
type="text"
id="author-search-input"
placeholder="Добавить автора..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="author-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Жанры</span>
<span
id="genres-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="current-genres-container"
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
></div>
<div class="relative">
<input
type="text"
id="genre-search-input"
placeholder="Добавить жанр..."
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="genre-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span id="submit-text">Сохранить изменения</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
id="cancel-btn"
href="#"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
Опасная зона
</h3>
<p class="text-sm text-gray-600 mb-4">
Удаление книги необратимо. Все связи с авторами и жанрами будут
удалены.
</p>
<button
id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
Удалить книгу
</button>
</div>
</div>
</div>
<div
id="delete-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Удалить книгу?
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить книгу
<span
id="modal-book-title"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<button
id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
>
<span>Удалить</span>
<svg
id="delete-spinner"
class="hidden animate-spin ml-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<button
id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Отмена
</button>
</div>
</div>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Изменения сохранены!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Книга
<span
id="success-book-title"
class="font-bold text-gray-800"
></span>
успешно обновлена.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="success-link-btn"
href="#"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
Перейти к книге
</a>
<button
id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Продолжить
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_book.js"></script>
{% endblock %}
+317
View File
@@ -0,0 +1,317 @@
{% extends "base.html" %} {% block title %}Редактирование жанра | LiB{% endblock
%} {% block content %}
<div class="container mx-auto p-4 max-w-2xl">
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
<div class="mb-8 border-b border-gray-100 pb-4">
<h1
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
>
<svg
class="w-8 h-8 text-gray-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
<span>Редактирование жанра</span>
</h1>
<p class="text-gray-500 mt-2 text-sm ml-11">
Измените данные жанра или удалите его из каталога.
</p>
</div>
<div id="loader" class="animate-pulse space-y-4">
<div class="h-12 bg-gray-200 rounded w-full"></div>
<div class="h-24 bg-gray-200 rounded w-full"></div>
</div>
<form id="edit-genre-form" class="hidden space-y-6">
<div>
<label
for="genre-name"
class="block text-sm font-semibold text-gray-700 mb-2"
>
Название жанра <span class="text-red-500">*</span>
</label>
<input
type="text"
id="genre-name"
name="name"
required
maxlength="100"
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
placeholder="Название жанра..."
/>
<div class="flex justify-end mt-1">
<span id="name-counter" class="text-xs text-gray-400"
>0/100</span
>
</div>
</div>
<div class="bg-white p-4 rounded-lg border border-gray-200">
<h2
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
>
<span>Книги в жанре</span>
<span
id="books-count"
class="text-xs text-gray-400 font-normal"
></span>
</h2>
<div
id="genre-books-container"
class="space-y-2 max-h-64 overflow-y-auto"
>
<div class="text-sm text-gray-500 text-center py-4">
Загрузка...
</div>
</div>
</div>
<div
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
>
<button
type="submit"
id="submit-btn"
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
<span id="submit-text">Сохранить изменения</span>
<svg
id="loading-spinner"
class="hidden animate-spin ml-2 h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<a
id="cancel-btn"
href="/"
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
>
Отмена
</a>
</div>
</form>
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
<h3
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
Опасная зона
</h3>
<p class="text-sm text-gray-600 mb-4">
Удаление жанра необратимо. Связи с книгами будут удалены, но
сами книги сохранятся.
</p>
<button
id="delete-btn"
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
>
<svg
class="w-5 h-5 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
Удалить жанр
</button>
</div>
</div>
</div>
<div
id="delete-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Удалить жанр?
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить жанр
<span
id="modal-genre-name"
class="font-bold text-gray-800"
></span
>? Это действие нельзя отменить.
</p>
<p
id="modal-books-warning"
class="hidden text-sm text-orange-600 mt-2"
>
В этом жанре есть книги. Связи будут удалены.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<button
id="confirm-delete-btn"
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
>
<span>Удалить</span>
<svg
id="delete-spinner"
class="hidden animate-spin ml-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<button
id="cancel-delete-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Отмена
</button>
</div>
</div>
</div>
</div>
<div
id="success-modal"
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
>
<div
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
>
<div class="mt-3 text-center">
<div
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
>
<svg
class="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div>
<h3 class="text-lg leading-6 font-medium text-gray-900">
Изменения сохранены!
</h3>
<div class="mt-2 px-7 py-3">
<p class="text-sm text-gray-500">
Жанр
<span
id="success-genre-name"
class="font-bold text-gray-800"
></span>
успешно обновлён.
</p>
</div>
<div class="flex gap-3 mt-4 justify-center">
<a
id="success-link-btn"
href="/"
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
>
На главную
</a>
<button
id="success-close-btn"
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
>
Продолжить
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/edit_genre.js"></script>
{% endblock %}
+50
View File
@@ -0,0 +1,50 @@
{% extends "base.html" %} {% block content %}
<div class="container mx-auto p-4 max-w-6xl">
<div class="mb-6">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Мои книги</h1>
<p class="text-gray-600">Управление вашими бронированиями и выдачами</p>
</div>
<!-- Бронирования -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Мои бронирования</h2>
<span id="reservations-count" class="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">0</span>
</div>
<div id="reservations-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка бронирований...
</div>
</div>
</div>
<!-- Активные выдачи -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">Активные выдачи</h2>
<span id="active-loans-count" class="px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-sm font-medium">0</span>
</div>
<div id="active-loans-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка активных выдач...
</div>
</div>
</div>
<!-- Возвращенные книги -->
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-bold text-gray-900">История возвратов</h2>
<span id="returned-count" class="px-3 py-1 bg-gray-100 text-gray-800 rounded-full text-sm font-medium">0</span>
</div>
<div id="returned-container" class="space-y-3">
<div class="text-center text-gray-500 py-8">
Загрузка истории...
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/my_books.js"></script>
{% endblock %}
+149 -59
View File
@@ -1,69 +1,159 @@
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %} {% block {% extends "base.html" %} {% block content %}
content %} <div
class="container mx-auto p-4 max-w-2xl"
<div class="flex flex-1 mt-4 p-4"> x-data="{ showPasswordModal: false }"
<main class="flex-1 max-w-2xl mx-auto"> @close-modal.window="showPasswordModal = false"
<div id="profile-card" class="bg-white p-6 rounded-lg shadow-md mb-6"> >
<div id="profile-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
<div class="animate-pulse flex items-center">
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
<div class="h-6 bg-gray-200 w-48 rounded"></div>
</div> </div>
<div id="account-section" class="bg-white p-6 rounded-lg shadow-md mb-6"> </div>
<h2 class="text-xl font-semibold mb-4">Информация об аккаунте</h2> <div
<div id="account-container"> id="account-section"
</div> class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
</div> >
<div id="roles-section" class="bg-white p-6 rounded-lg shadow-md mb-6"> <h2 class="text-xl font-bold mb-4 border-b pb-2">Информация</h2>
<h2 class="text-xl font-semibold mb-4">Роли и права</h2> <div id="account-info" class="space-y-4"></div>
<div id="roles-container"> </div>
</div> <div
</div> id="roles-section"
<div id="actions-section" class="bg-white p-6 rounded-lg shadow-md"> class="bg-white rounded-lg shadow-md p-6 mb-6 hidden"
<h2 class="text-xl font-semibold mb-4">Действия</h2> >
<div id="actions-container"> <h2 class="text-xl font-bold mb-4 border-b pb-2">Роли</h2>
</div> <div id="roles-container" class="space-y-3"></div>
</div> </div>
</main> <div class="bg-white rounded-lg shadow-md p-6 mb-6">
</div> <div class="space-y-3">
<div id="password-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <button
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md mx-4"> @click="showPasswordModal = true"
<div class="flex justify-between items-center mb-4"> class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors"
<h3 class="text-xl font-semibold">Смена пароля</h3> >
<button id="close-password-modal" class="text-gray-400 hover:text-gray-600"> <span class="text-gray-700 font-medium">Сменить пароль</span>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> class="w-5 h-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button>
<button
onclick="Auth.logout()"
class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors"
>
<span class="text-red-700 font-medium">Выйти из аккаунта</span>
<svg
class="w-5 h-5 text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg> </svg>
</button> </button>
</div> </div>
<form id="password-form"> </div>
<div class="mb-4"> <div
<label class="block text-gray-700 text-sm font-medium mb-2">Текущий пароль</label> x-show="showPasswordModal"
<input type="password" id="current-password" class="fixed inset-0 z-50 overflow-y-auto"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500" style="display: none"
required minlength="6"> >
<div
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"
>
<div
x-show="showPasswordModal"
x-transition.opacity
class="fixed inset-0 transition-opacity"
aria-hidden="true"
>
<div
class="absolute inset-0 bg-gray-500 opacity-75"
@click="showPasswordModal = false"
></div>
</div> </div>
<div class="mb-4"> <div
<label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label> x-show="showPasswordModal"
<input type="password" id="new-password" x-transition.scale
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500" class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg w-full"
required minlength="6"> >
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3
class="text-lg leading-6 font-medium text-gray-900 mb-4"
>
Смена пароля
</h3>
<form id="change-password-form">
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Текущий пароль</label
>
<input
type="password"
id="current-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div>
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Новый пароль</label
>
<input
type="password"
id="new-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div>
<div class="mb-4">
<label
class="block text-gray-700 text-sm font-bold mb-2"
>Подтвердите пароль</label
>
<input
type="password"
id="confirm-password"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
/>
</div>
</form>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
>
<button
type="button"
id="submit-password-btn"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 sm:ml-3 sm:w-auto sm:text-sm"
>
Сменить
</button>
<button
type="button"
@click="showPasswordModal = false"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Отмена
</button>
</div>
</div> </div>
<div class="mb-6"> </div>
<label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите новый пароль</label>
<input type="password" id="confirm-password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
required minlength="6">
</div>
<div id="password-error" class="mb-4 text-red-600 text-sm hidden"></div>
<div class="flex gap-3">
<button type="submit" class="flex-1 bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition-colors">
Сменить пароль
</button>
<button type="button" id="cancel-password" class="flex-1 bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
Отмена
</button>
</div>
</form>
</div> </div>
</div> </div>
{% endblock %} {% block scripts %} {% endblock %} {% block scripts %}
<script type="text/javascript" src="/static/profile.js"></script> <script src="/static/profile.js"></script>
{% endblock %} {% endblock %}
+433
View File
@@ -0,0 +1,433 @@
{% extends "base.html" %} {% block title %}Пользователи - LiB{% endblock %} {%
block content %}
<div class="container mx-auto p-4">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">
Управление пользователями
</h1>
<div class="text-sm text-gray-500">
Всего: <span id="total-users-count">0</span>
</div>
</div>
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1 relative">
<input
type="text"
id="user-search-input"
placeholder="Поиск по имени, username или email..."
class="w-full border rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
/>
<svg
class="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
></path>
</svg>
</div>
<div class="relative">
<input
type="text"
id="role-filter-input"
placeholder="Фильтр по роли..."
class="w-full md:w-56 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
autocomplete="off"
/>
<div
id="role-filter-dropdown"
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
></div>
</div>
<button
id="reset-filters-btn"
class="px-4 py-2 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
>
Сбросить
</button>
</div>
</div>
<div id="users-container" class="space-y-4"></div>
<div id="pagination-container"></div>
</div>
<template id="user-card-template">
<div
class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 user-card"
>
<div class="flex items-start gap-4">
<img
class="user-avatar w-14 h-14 rounded-full border-2 border-gray-200 object-cover bg-gray-100"
src=""
alt=""
/>
<div class="flex-1 min-w-0">
<div
class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3
class="user-fullname text-lg font-bold text-gray-900 truncate"
></h3>
<span
class="user-verified-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
>
<svg
class="w-3 h-3 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fill-rule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clip-rule="evenodd"
></path>
</svg>
Подтвержден
</span>
</div>
<p class="user-username text-sm text-gray-500"></p>
<p
class="user-email text-sm text-gray-600 truncate"
></p>
</div>
<div class="flex items-center gap-2">
<span
class="user-active-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
>
Активен
</span>
<span
class="user-inactive-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"
>
Неактивен
</span>
<button
class="edit-user-btn p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Редактировать"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
></path>
</svg>
</button>
<button
class="delete-user-btn p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
title="Удалить"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
></path>
</svg>
</button>
</div>
</div>
<div class="mt-3">
<div class="flex items-center flex-wrap gap-2">
<span class="text-sm font-medium text-gray-700"
>Роли:</span
>
<div class="user-roles flex flex-wrap gap-1"></div>
<div class="relative inline-block">
<button
class="add-role-btn p-1 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors"
title="Добавить роль"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template id="role-badge-template">
<span
class="role-badge inline-flex items-center bg-gray-600 text-white text-xs font-medium px-2.5 py-1 rounded-full"
>
<span class="role-name"></span>
<button
type="button"
class="remove-role-btn ml-1.5 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-red-500 rounded-full w-4 h-4 transition-colors"
title="Удалить роль"
>
<svg
class="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</span>
</template>
<template id="empty-state-template">
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg
class="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">
Пользователи не найдены
</h3>
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
</div>
</template>
<template id="access-denied-template">
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<svg
class="mx-auto h-12 w-12 text-red-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3>
<p class="text-gray-500">У вас нет прав для просмотра этой страницы</p>
<a
href="/"
class="inline-block mt-4 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
>
На главную
</a>
</div>
</template>
<div id="edit-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0"
>
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
id="modal-backdrop"
></div>
<div
class="relative inline-block bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full"
>
<form id="edit-user-form">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">
Редактирование пользователя
</h3>
<input type="hidden" id="edit-user-id" />
<div class="space-y-4">
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>Email</label
>
<input
type="email"
id="edit-user-email"
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>Полное имя</label
>
<input
type="text"
id="edit-user-fullname"
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
/>
</div>
<div>
<label
class="block text-sm font-medium text-gray-700 mb-1"
>Новый пароль (оставьте пустым, чтобы не
менять)</label
>
<input
type="password"
id="edit-user-password"
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
placeholder="••••••••"
/>
</div>
<div class="flex items-center gap-4">
<label
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
id="edit-user-active"
class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<span class="text-sm text-gray-700"
>Активен</span
>
</label>
<label
class="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
id="edit-user-verified"
class="w-4 h-4 text-green-600 rounded focus:ring-green-500"
/>
<span class="text-sm text-gray-700"
>Подтверждён</span
>
</label>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2"
>
<button
type="submit"
class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-white font-medium hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
>
Сохранить
</button>
<button
type="button"
id="cancel-edit-btn"
class="mt-3 sm:mt-0 w-full sm:w-auto inline-flex justify-center rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-gray-700 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
>
Отмена
</button>
</div>
</form>
</div>
</div>
</div>
<div id="delete-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0"
>
<div
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
id="delete-modal-backdrop"
></div>
<div
class="relative inline-block bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full"
>
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
>
<svg
class="h-6 w-6 text-red-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
></path>
</svg>
</div>
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h3 class="text-lg font-medium text-gray-900">
Удаление пользователя
</h3>
<div class="mt-2">
<p class="text-sm text-gray-500">
Вы уверены, что хотите удалить пользователя
<strong id="delete-user-name"></strong>? Это
действие необратимо.
</p>
</div>
</div>
</div>
</div>
<div
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2"
>
<button
type="button"
id="confirm-delete-btn"
class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-red-600 text-white font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition"
>
Удалить
</button>
<button
type="button"
id="cancel-delete-btn"
class="mt-3 sm:mt-0 w-full sm:w-auto inline-flex justify-center rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-gray-700 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
>
Отмена
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script src="/static/users.js"></script>
{% endblock %}
+6
View File
@@ -0,0 +1,6 @@
def main():
print("Hello from libraryapi!")
if __name__ == "__main__":
main()
@@ -0,0 +1,41 @@
"""role payroll
Revision ID: a8e40ab24138
Revises: 02ed6e775351
Create Date: 2025-12-20 13:44:13.807704
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a8e40ab24138'
down_revision: Union[str, None] = '02ed6e775351'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('book', 'status',
existing_type=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
type_=sa.String(),
existing_nullable=False,
existing_server_default=sa.text("'active'::bookstatus"))
op.add_column('roles', sa.Column('payroll', sa.Integer(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('roles', 'payroll')
op.alter_column('book', 'status',
existing_type=sa.String(),
type_=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
existing_nullable=False,
existing_server_default=sa.text("'active'::bookstatus"))
# ### end Alembic commands ###
Generated
-2250
View File
File diff suppressed because it is too large Load Diff
+33 -26
View File
@@ -1,33 +1,40 @@
[tool.poetry] [project]
name = "LibraryAPI" name = "LibraryAPI"
version = "0.2.0" version = "0.4.0"
description = "Это простое API для управления авторами, книгами и их жанрами." description = "Это простое API для управления авторами, книгами и их жанрами."
authors = ["wowlikon"] authors = [{ name = "wowlikon" }]
readme = "README.md" readme = "README.md"
packages = [{ include = "library_service" }] requires-python = ">=3.12"
dependencies = [
"toml>=0.10.2",
"python-dotenv>=0.21.1",
"uvicorn[standard]>=0.40.0",
"pydantic[email]>=2.12.5",
"fastapi[all]>=0.115.14",
"jinja2>=3.1.6",
"psycopg2-binary>=2.9.11",
"alembic>=1.18.0",
"sqlmodel>=0.0.31",
"json-log-formatter>=1.1.1",
"python-jose[cryptography]>=3.5.0",
"passlib[argon2]>=1.7.4",
"aiofiles>=25.1.0",
"qrcode[pil]>=8.2",
"pyotp>=2.9.0",
]
[tool.poetry.dependencies] [dependency-groups]
python = "^3.12" dev = [
fastapi = { extras = ["all"], version = "^0.115.12" } "black>=25.12.0",
psycopg2-binary = "^2.9.10" "isort>=7.0.0",
alembic = "^1.16.1" "pylint>=4.0.4",
python-dotenv = "^0.21.0" "pytest>=8.4.2",
sqlmodel = "^0.0.24" "pytest-asyncio>=1.3.0",
uvicorn = "^0.34.3" ]
jinja2 = "^3.1.6"
toml = "^0.10.2"
python-jose = {extras = ["cryptography"], version = "^3.5.0"}
passlib = {extras = ["argon2"], version = "^1.7.4"}
aiofiles = "^25.1.0"
pydantic = {extras = ["email"], version = "^2.12.5"}
[tool.poetry.group.dev.dependencies] [tool.hatch.build.targets.wheel]
black = "^25.1.0" packages = ["library_service"]
pytest = "^8.4.1"
isort = "^7.0.0"
pytest-asyncio = "^1.3.0"
pylint = "^4.0.4"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["hatchling"]
build-backend = "poetry.core.masonry.api" build-backend = "hatchling.build"
Generated
+1798
View File
File diff suppressed because it is too large Load Diff