mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 12:31:09 +00:00
Compare commits
36 Commits
961bf95af7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a336d50ad0 | |||
| 38642a6910 | |||
| d442a37820 | |||
| 80acdceba6 | |||
| 4368ee0d3c | |||
| 4f9c472a54 | |||
| a6811a3e86 | |||
| 19d322c9d9 | |||
| dfa4d14afc | |||
| 6014db3c81 | |||
| 0e159df16e | |||
| 2f3d6f0e1e | |||
| 657f1b96f2 | |||
| 9f814e7271 | |||
| 09d5739256 | |||
| ec1c32a5bd | |||
| c1ac0ca246 | |||
| 7c3074e8fe | |||
| 1e0c3478a1 | |||
| e507896b7a | |||
| d6ecd4066f | |||
| 758e0fc9e6 | |||
| 83957ff548 | |||
| 5a814d99e6 | |||
| 82d298effe | |||
| 49d1681bcb | |||
| 1141cf5e66 | |||
| 4b06c4fb1f | |||
| 9d25d2e5de | |||
| 5096b45243 | |||
| 2bb7d420ec | |||
| 4839de99af | |||
| 368bb84fe5 | |||
| 09b7cb17a5 | |||
| 3473c31f73 | |||
| a3203d713d |
@@ -0,0 +1,3 @@
|
||||
|
||||
*.log
|
||||
__pycache__/
|
||||
@@ -1,9 +0,0 @@
|
||||
# DEFAULT_ADMIN_USERNAME = "admin"
|
||||
# DEFAULT_ADMIN_EMAIL = "admin@example.com"
|
||||
# DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch"
|
||||
|
||||
POSTGRES_HOST = "localhost"
|
||||
POSTGRES_PORT = "5432"
|
||||
POSTGRES_USER = "postgres"
|
||||
POSTGRES_PASSWORD = "postgres"
|
||||
POSTGRES_DB = "lib"
|
||||
Vendored
+4
@@ -1,3 +1,7 @@
|
||||
.env
|
||||
library_service/static/books/
|
||||
*.log
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
+26
-16
@@ -1,22 +1,32 @@
|
||||
FROM python:3.13 as requirements-stage
|
||||
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.12-slim
|
||||
|
||||
FROM python:3.13
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get -y install gcc postgresql \
|
||||
&& apt-get clean # netcat
|
||||
|
||||
RUN pip install --upgrade pip
|
||||
ENV UV_PROJECT_ENVIRONMENT="/opt/venv"
|
||||
ENV PATH="/opt/venv/bin:$PATH"
|
||||
|
||||
WORKDIR /code
|
||||
COPY --from=requirements-stage /tmp/requirements.txt ./requirements.txt
|
||||
RUN pip install --no-cache-dir --upgrade -r ./requirements.txt
|
||||
COPY . .
|
||||
ENV PYTHONPATH=.
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get -y install gcc libpq-dev \
|
||||
&& 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/
|
||||
|
||||
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=*"]
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||

|
||||
# 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**: Библиотека для визуализации данных
|
||||
|
||||
### **Инструкция по установке**
|
||||
|
||||
1. Клонируйте репозиторий:
|
||||
```bash
|
||||
git clone https://github.com/wowlikon/libraryapi.git
|
||||
git clone https://github.com/wowlikon/LiB.git
|
||||
```
|
||||
|
||||
2. Перейдите в каталог проекта:
|
||||
```bash
|
||||
cd libraryapi
|
||||
cd LiB
|
||||
```
|
||||
|
||||
3. Настройте переменные окружения:
|
||||
```bash
|
||||
cp example-docker.env .env # или example-local.env для запуска без docker
|
||||
edit .env
|
||||
```
|
||||
|
||||
@@ -37,110 +40,244 @@
|
||||
|
||||
5. Запустите приложение:
|
||||
```bash
|
||||
docker compose up api
|
||||
docker compose up api -d
|
||||
```
|
||||
|
||||
Для создания новых миграций:
|
||||
```bash
|
||||
docker compose run --rm -T api alembic revision --autogenerate -m "Migration name"
|
||||
uv run alembic revision --autogenerate -m "Migration name"
|
||||
```
|
||||
|
||||
Для запуска тестов:
|
||||
```bash
|
||||
docker compose up test
|
||||
```
|
||||
### **Роли пользователей**
|
||||
|
||||
- **admin**: Полный доступ ко всем функциям системы
|
||||
- **librarian**: Управление книгами, авторами, жанрами и выдачами
|
||||
- **member**: Просмотр каталога и управление своими выдачами
|
||||
|
||||
### **Эндпоинты API**
|
||||
|
||||
**Авторы**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/authors` | Создать нового автора |
|
||||
| GET | `/authors` | Получить список всех авторов |
|
||||
| GET | `/authors/{id}` | Получить автора по ID с книгами |
|
||||
| PUT | `/authors/{id}` | Обновить автора по ID |
|
||||
| DELETE | `/authors/{id}` | Удалить автора по ID |
|
||||
#### **Аутентификация** (`/api/auth`)
|
||||
|
||||
**Книги**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/books` | Создать новую книгу |
|
||||
| GET | `/books` | Получить список всех книг |
|
||||
| GET | `/book/{id}` | Получить книгу по ID с авторами |
|
||||
| PUT | `/books/{id}` | Обновить книгу по ID |
|
||||
| DELETE | `/books/{id}` | Удалить книгу по ID |
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|------------------------------|----------------|------------------------------------------|
|
||||
| POST | `/register` | Публичный | Регистрация нового пользователя |
|
||||
| POST | `/token` | Публичный | Получение JWT токенов (access + refresh) |
|
||||
| POST | `/refresh` | Публичный | Обновление пары токенов |
|
||||
| GET | `/me` | Авторизованный | Информация о текущем пользователе |
|
||||
| PUT | `/me` | Авторизованный | Обновление профиля текущего пользователя |
|
||||
| GET | `/2fa` | Авторизованный | Создаёт QR-код для включения 2FA |
|
||||
| POST | `/2fa/verify` | Неполный вход | Завершает вход при включеной 2FA |
|
||||
| POST | `/2fa/enable` | Авторизованный | Включает двухваткорную аутентификацию |
|
||||
| POST | `/2fa/disable` | Авторизованный | Выключает двухваткорную аутентификацию |
|
||||
| GET | `/recovery-codes/status` | Авторизованный | Проверяет состояние кодов восстановления |
|
||||
| POST | `/recovery-codes/regenerate` | Авторизованный | Пересоздает коды восстановления пароля |
|
||||
| POST | `/password/reset` | Публичный | Сброс пароля с помощью одноразового кода |
|
||||
|
||||
**Жанры**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/genres` | Создать новый жанр |
|
||||
| GET | `/genres` | Получить список всех жанров |
|
||||
| GET | `/genres/{id}` | Получить жанр по ID |
|
||||
| PUT | `/genres/{id}` | Обновить жанр по ID |
|
||||
| DELETE | `/genres/{id}` | Удалить жанр по ID |
|
||||
#### **Авторы** (`/api/authors`)
|
||||
|
||||
**Связи**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|------------------------------|-----------------------------------|
|
||||
| GET | `/authors/{id}/books` | Получить список книг для автора |
|
||||
| GET | `/books/{id}/authors` | Получить список авторов для книги |
|
||||
| POST | `/relationships/author-book` | Связать автор-книга |
|
||||
| DELETE | `/relationships/author-book` | Разделить автор-книга |
|
||||
| GET | `/genres/{id}/books` | Получить список книг для жанра |
|
||||
| GET | `/books/{id}/genres` | Получить список жанров для книги |
|
||||
| POST | `/relationships/genre-book` | Связать автор-книга |
|
||||
| DELETE | `/relationships/genre-book` | Разделить автор-книга |
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|----------|-----------|---------------------------------|
|
||||
| POST | `/` | Сотрудник | Создать нового автора |
|
||||
| GET | `/` | Публичный | Получить список всех авторов |
|
||||
| GET | `/{id}` | Публичный | Получить автора по ID с книгами |
|
||||
| PUT | `/{id}` | Сотрудник | Обновить автора по ID |
|
||||
| DELETE | `/{id}` | Сотрудник | Удалить автора по ID |
|
||||
|
||||
**Другие**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-------------|-------------------------------|
|
||||
| GET | `/api/info` | Получить информацию о сервисе |
|
||||
#### **Книги** (`/api/books`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|-----------|-----------|----------------------------------------------|
|
||||
| POST | `/` | Сотрудник | Создать новую книгу |
|
||||
| GET | `/` | Публичный | Получить список всех книг |
|
||||
| GET | `/{id}` | Публичный | Получить книгу по ID с авторами и жанрами |
|
||||
| PUT | `/{id}` | Сотрудник | Обновить книгу по ID |
|
||||
| DELETE | `/{id}` | Сотрудник | Удалить книгу по ID |
|
||||
| GET | `/filter` | Публичный | Фильтрация книг по названию, авторам, жанрам |
|
||||
|
||||
#### **Жанры** (`/api/genres`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|----------|-----------|-------------------------------|
|
||||
| POST | `/` | Сотрудник | Создать новый жанр |
|
||||
| GET | `/` | Публичный | Получить список всех жанров |
|
||||
| GET | `/{id}` | Публичный | Получить жанр по ID с книгами |
|
||||
| PUT | `/{id}` | Сотрудник | Обновить жанр по ID |
|
||||
| DELETE | `/{id}` | Сотрудник | Удалить жанр по ID |
|
||||
|
||||
#### **Выдачи** (`/api/loans`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|-------------------------|----------------|------------------------------------------------------------|
|
||||
| POST | `/` | Авторизованный | Создать выдачу/бронь (читатели на себя, cотрудник на всех) |
|
||||
| GET | `/` | Авторизованный | Список выдач (читатели видят свои, Сотрудник видят все) |
|
||||
| GET | `{id}` | Авторизованный | Получить выдачу по ID (читатели только свои) |
|
||||
| PUT | `{id}` | Авторизованный | Обновить выдачу (читатели только свои) |
|
||||
| DELETE | `{id}` | Авторизованный | Удалить выдачу/бронь (только для RESERVED статуса) |
|
||||
| POST | `{id}/confirm` | Сотрудник | Подтвердить бронь (меняет статус на BORROWED) |
|
||||
| POST | `{id}/return` | Сотрудник | Вернуть книгу и закрыть выдачу |
|
||||
| GET | `book/{book_id}/active` | Сотрудник | Получить активную выдачу книги |
|
||||
| POST | `issue` | Админ | Выдать книгу напрямую без бронирования |
|
||||
| GET | `analytics` | Админ | Аналитика выдач и возвратов |
|
||||
|
||||
#### **Связи** (`/api`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|------------------------------|-----------|-------------------------------|
|
||||
| POST | `/relationships/author-book` | Сотрудник | Связать автора и книгу |
|
||||
| DELETE | `/relationships/author-book` | Сотрудник | Удалить связь автор-книга |
|
||||
| GET | `/authors/{id}/books` | Публичный | Получить список книг автора |
|
||||
| GET | `/books/{id}/authors` | Публичный | Получить список авторов книги |
|
||||
| POST | `/relationships/genre-book` | Сотрудник | Связать жанр и книгу |
|
||||
| DELETE | `/relationships/genre-book` | Сотрудник | Удалить связь жанр-книга |
|
||||
| GET | `/genres/{id}/books` | Публичный | Получить список книг жанра |
|
||||
| GET | `/books/{id}/genres` | Публичный | Получить список жанров книги |
|
||||
|
||||
|
||||
#### **Пользователи** (`/api/users`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|--------------------------------|----------------|------------------------------|
|
||||
| POST | `/` | Админ | Создать нового пользователя |
|
||||
| GET | `/` | Админ | Список всех пользователей |
|
||||
| GET | `/{id}` | Админ | Получить пользователя по ID |
|
||||
| PUT | `/{id}` | Админ | Обновить пользователя по ID |
|
||||
| DELETE | `/{id}` | Админ | Удалить пользователя по ID |
|
||||
| POST | `/{user_id}/roles/{role_name}` | Админ | Назначение роли пользователю |
|
||||
| DELETE | `/{user_id}/roles/{role_name}` | Админ | Удаление роли у пользователя |
|
||||
| GET | `/roles` | Авторизованный | Список ролей в системе |
|
||||
|
||||
|
||||
#### **CAPTCHA** (`/api/cap`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|--------|---------------|-----------|-----------------|
|
||||
| POST | `/challenge` | Публичный | Создание задачи |
|
||||
| POST | `/redeem` | Публичный | Проверка задачи |
|
||||
|
||||
|
||||
#### **Прочее** (`/api`)
|
||||
|
||||
| Метод | Эндпоинт | Доступ | Описание |
|
||||
|-------|-----------|-----------|----------------------|
|
||||
| GET | `/info` | Публичный | Информация о сервисе |
|
||||
| GET | `/stats` | Публичный | Статистика системы |
|
||||
| GET | `/schema` | Публичный | Схема базы данных |
|
||||
|
||||
### **Веб-страницы**
|
||||
|
||||
| Путь | Доступ | Описание |
|
||||
|---------------------|----------------|-----------------------------|
|
||||
| `/` | Публичный | Главная страница |
|
||||
| `/api` | Публичный | Ссылки на документацию |
|
||||
| `/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` | Админ | Аналитика выдач и возвратов |
|
||||
|
||||
|
||||
### **Схема базы данных**
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
AUTHOR {
|
||||
int id PK "ID автора"
|
||||
string name "Имя автора"
|
||||
USER {
|
||||
int id PK
|
||||
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 {
|
||||
int id PK "ID книги"
|
||||
string title "Название книги"
|
||||
string description "Описание книги"
|
||||
int id PK
|
||||
string title
|
||||
string description
|
||||
string status
|
||||
}
|
||||
|
||||
AUTHOR {
|
||||
int id PK
|
||||
string name
|
||||
string bio
|
||||
}
|
||||
|
||||
GENRE {
|
||||
int id PK "ID жанра"
|
||||
string name "Название жанра"
|
||||
int id PK
|
||||
string name
|
||||
string description
|
||||
}
|
||||
|
||||
AUTHOR_BOOK {
|
||||
int author_id FK "ID автора"
|
||||
int book_id FK "ID книги"
|
||||
AUTHOR_BOOK_LINK {
|
||||
int author_id FK
|
||||
int book_id FK
|
||||
}
|
||||
|
||||
GENRE_BOOK {
|
||||
int genre_id FK "ID жанра"
|
||||
int book_id FK "ID книги"
|
||||
GENRE_BOOK_LINK {
|
||||
int genre_id FK
|
||||
int book_id FK
|
||||
}
|
||||
|
||||
AUTHOR ||--o{ AUTHOR_BOOK : "писал"
|
||||
BOOK ||--o{ AUTHOR_BOOK : "написан"
|
||||
BOOK_USER_LINK {
|
||||
int id PK
|
||||
int book_id FK
|
||||
int user_id FK
|
||||
datetime borrowed_at
|
||||
datetime due_date
|
||||
datetime returned_at
|
||||
}
|
||||
|
||||
BOOK ||--o{ GENRE_BOOK : "принадлежит"
|
||||
GENRE ||--o{ GENRE_BOOK : "содержит"
|
||||
USER ||--o{ USER_ROLE_LINK : "имеет"
|
||||
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**: Книга выдана пользователю
|
||||
- **RESTORATION**: Книга на реставрации
|
||||
- **WRITTEN_OFF**: Книга списана
|
||||
|
||||
### **Используемые технологии**
|
||||
|
||||
- **FastAPI**: Современный web фреймворк для построения API с использованием Python, известный своей скоростью и простотой использования.
|
||||
- **Pydantic**: Библиотека для валидации данных и управления настройками, использующая аннотации типов Python.
|
||||
- **SQLModel**: Библиотека для взаимодействия с базами данных с использованием классов Python, объединяющая функции SQLAlchemy и Pydantic.
|
||||
- **Alembic**: Легковесный инструмент для миграции базы данных на основе SQLAlchemy.
|
||||
- **PostgreSQL**: Сильная, открытая реляционная система управления базами данных.
|
||||
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах.
|
||||
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker.
|
||||
- **Tailwind**: CSS-фреймворк, позволяющий стилизовать веб-интерфейсы, применяя готовые низкоуровневые классы.
|
||||
- **Cash**: Микро JavaScript-библиотека, созданная как очень быстрая и компактная альтернатива jQuery.
|
||||
- **FastAPI**: Современный веб-фреймворк для построения API на Python
|
||||
- **Pydantic**: Библиотека для валидации данных и управления настройками
|
||||
- **SQLModel**: Библиотека для взаимодействия с базами данных, объединяющая SQLAlchemy и Pydantic
|
||||
- **Alembic**: Инструмент для миграции базы данных на основе SQLAlchemy
|
||||
- **PostgreSQL**: Реляционная система управления базами данных
|
||||
- **Ollama**: Инструмент для локального запуска и управления большими языковыми моделями
|
||||
- **Docker**: Платформа для разработки, распространения и запуска приложений в контейнерах
|
||||
- **Docker Compose**: Инструмент для определения и запуска многоконтейнерных приложений Docker
|
||||
- **Tailwind CSS**: CSS-фреймворк для стилизации интерфейса
|
||||
- **Alpine.js**: Легковесный JavaScript-фреймворк для реактивности
|
||||
- **Chart.js**: Библиотека для визуализации данных
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
# Конфигурация
|
||||
USERNAME = "sys-admin"
|
||||
PASSWORD = "wTKPVqTIMqzXL2EZxYz80w"
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
class LibraryAPI:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url
|
||||
self.token: Optional[str] = None
|
||||
self.session = requests.Session()
|
||||
|
||||
def login(self, username: str, password: str) -> bool:
|
||||
"""Авторизация и получение токена"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/auth/token",
|
||||
data={"username": username, "password": password},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
self.token = response.json()["access_token"]
|
||||
self.session.headers.update({"Authorization": f"Bearer {self.token}"})
|
||||
print(f"✓ Авторизация успешна для пользователя: {username}")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Ошибка авторизации: {response.text}")
|
||||
return False
|
||||
|
||||
def register(self, username: str, email: str, password: str, full_name: str = None) -> bool:
|
||||
"""Регистрация нового пользователя"""
|
||||
data = {
|
||||
"username": username,
|
||||
"email": email,
|
||||
"password": password
|
||||
}
|
||||
if full_name:
|
||||
data["full_name"] = full_name
|
||||
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/auth/register",
|
||||
json=data
|
||||
)
|
||||
if response.status_code == 201:
|
||||
print(f"✓ Пользователь {username} зарегистрирован")
|
||||
return True
|
||||
else:
|
||||
print(f"✗ Ошибка регистрации: {response.text}")
|
||||
return False
|
||||
|
||||
def create_author(self, name: str) -> Optional[int]:
|
||||
"""Создание автора"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/authors/",
|
||||
json={"name": name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
author_id = response.json()["id"]
|
||||
print(f" ✓ Автор создан: {name} (ID: {author_id})")
|
||||
return author_id
|
||||
else:
|
||||
print(f" ✗ Ошибка создания автора {name}: {response.text}")
|
||||
return None
|
||||
|
||||
def create_book(self, title: str, description: str) -> Optional[int]:
|
||||
"""Создание книги"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/books/",
|
||||
json={"title": title, "description": description}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
book_id = response.json()["id"]
|
||||
print(f" ✓ Книга создана: {title} (ID: {book_id})")
|
||||
return book_id
|
||||
else:
|
||||
print(f" ✗ Ошибка создания книги {title}: {response.text}")
|
||||
return None
|
||||
|
||||
def create_genre(self, name: str) -> Optional[int]:
|
||||
"""Создание жанра"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/genres/",
|
||||
json={"name": name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
genre_id = response.json()["id"]
|
||||
print(f" ✓ Жанр создан: {name} (ID: {genre_id})")
|
||||
return genre_id
|
||||
else:
|
||||
print(f" ✗ Ошибка создания жанра {name}: {response.text}")
|
||||
return None
|
||||
|
||||
def link_author_book(self, author_id: int, book_id: int) -> bool:
|
||||
"""Связь автора и книги"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/relationships/author-book",
|
||||
params={"author_id": author_id, "book_id": book_id}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f" ↔ Связь автор-книга: {author_id} ↔ {book_id}")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Ошибка связи автор-книга: {response.text}")
|
||||
return False
|
||||
|
||||
def link_genre_book(self, genre_id: int, book_id: int) -> bool:
|
||||
"""Связь жанра и книги"""
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/api/relationships/genre-book",
|
||||
params={"genre_id": genre_id, "book_id": book_id}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
print(f" ↔ Связь жанр-книга: {genre_id} ↔ {book_id}")
|
||||
return True
|
||||
else:
|
||||
print(f" ✗ Ошибка связи жанр-книга: {response.text}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
api = LibraryAPI(BASE_URL)
|
||||
|
||||
# Авторизация
|
||||
if not api.login(USERNAME, PASSWORD):
|
||||
print("Не удалось авторизоваться. Проверьте логин и пароль.")
|
||||
return
|
||||
|
||||
# === АВТОРЫ (12 авторов) ===
|
||||
print("\n📚 Создание авторов...")
|
||||
authors_data = [
|
||||
"Лев Толстой",
|
||||
"Фёдор Достоевский",
|
||||
"Антон Чехов",
|
||||
"Александр Пушкин",
|
||||
"Михаил Булгаков",
|
||||
"Николай Гоголь",
|
||||
"Иван Тургенев",
|
||||
"Борис Пастернак",
|
||||
"Михаил Лермонтов",
|
||||
"Александр Солженицын",
|
||||
"Максим Горький",
|
||||
"Иван Бунин"
|
||||
]
|
||||
|
||||
authors = {}
|
||||
for name in authors_data:
|
||||
author_id = api.create_author(name)
|
||||
if author_id:
|
||||
authors[name] = author_id
|
||||
|
||||
# === ЖАНРЫ (8 жанров) ===
|
||||
print("\n🏷️ Создание жанров...")
|
||||
genres_data = [
|
||||
"Роман",
|
||||
"Повесть",
|
||||
"Рассказ",
|
||||
"Поэзия",
|
||||
"Драма",
|
||||
"Философская проза",
|
||||
"Историческая проза",
|
||||
"Сатира"
|
||||
]
|
||||
|
||||
genres = {}
|
||||
for name in genres_data:
|
||||
genre_id = api.create_genre(name)
|
||||
if genre_id:
|
||||
genres[name] = genre_id
|
||||
|
||||
# === КНИГИ (25 книг) ===
|
||||
print("\n📖 Создание книг...")
|
||||
books_data = [
|
||||
{
|
||||
"title": "Война и мир",
|
||||
"description": "Роман-эпопея Льва Толстого, описывающий русское общество в эпоху войн против Наполеона в 1805—1812 годах. Одно из величайших произведений мировой литературы.",
|
||||
"authors": ["Лев Толстой"],
|
||||
"genres": ["Роман", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Анна Каренина",
|
||||
"description": "Роман Льва Толстого о трагической любви замужней дамы Анны Карениной к блестящему офицеру Вронскому. История страсти, ревности и роковых решений.",
|
||||
"authors": ["Лев Толстой"],
|
||||
"genres": ["Роман", "Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Преступление и наказание",
|
||||
"description": "Социально-психологический роман Фёдора Достоевского о бедном студенте Раскольникове, совершившем убийство и мучающемся угрызениями совести.",
|
||||
"authors": ["Фёдор Достоевский"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Братья Карамазовы",
|
||||
"description": "Последний роман Достоевского, история семьи Карамазовых, затрагивающая глубокие вопросы веры, свободы воли и морали.",
|
||||
"authors": ["Фёдор Достоевский"],
|
||||
"genres": ["Роман", "Философская проза", "Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Идиот",
|
||||
"description": "Роман о князе Мышкине — человеке с чистой душой, который сталкивается с жестокостью и корыстью петербургского общества.",
|
||||
"authors": ["Фёдор Достоевский"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Вишнёвый сад",
|
||||
"description": "Пьеса Антона Чехова о разорении дворянского гнезда и продаже родового имения с вишнёвым садом.",
|
||||
"authors": ["Антон Чехов"],
|
||||
"genres": ["Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Чайка",
|
||||
"description": "Пьеса Чехова о любви, искусстве и несбывшихся мечтах, разворачивающаяся в усадьбе на берегу озера.",
|
||||
"authors": ["Антон Чехов"],
|
||||
"genres": ["Драма"]
|
||||
},
|
||||
{
|
||||
"title": "Палата № 6",
|
||||
"description": "Повесть о враче психиатрической больницы, который начинает сомневаться в границах между нормой и безумием.",
|
||||
"authors": ["Антон Чехов"],
|
||||
"genres": ["Повесть", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Евгений Онегин",
|
||||
"description": "Роман в стихах Александра Пушкина — энциклопедия русской жизни начала XIX века и история несчастной любви.",
|
||||
"authors": ["Александр Пушкин"],
|
||||
"genres": ["Роман", "Поэзия"]
|
||||
},
|
||||
{
|
||||
"title": "Капитанская дочка",
|
||||
"description": "Исторический роман Пушкина о событиях Пугачёвского восстания, любви и чести.",
|
||||
"authors": ["Александр Пушкин"],
|
||||
"genres": ["Роман", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Пиковая дама",
|
||||
"description": "Повесть о молодом офицере Германне, одержимом желанием узнать тайну трёх карт.",
|
||||
"authors": ["Александр Пушкин"],
|
||||
"genres": ["Повесть"]
|
||||
},
|
||||
{
|
||||
"title": "Мастер и Маргарита",
|
||||
"description": "Роман Михаила Булгакова о визите дьявола в Москву 1930-х годов, переплетённый с историей Понтия Пилата.",
|
||||
"authors": ["Михаил Булгаков"],
|
||||
"genres": ["Роман", "Сатира", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Собачье сердце",
|
||||
"description": "Повесть-сатира о профессоре Преображенском, превратившем бродячего пса в человека.",
|
||||
"authors": ["Михаил Булгаков"],
|
||||
"genres": ["Повесть", "Сатира"]
|
||||
},
|
||||
{
|
||||
"title": "Белая гвардия",
|
||||
"description": "Роман о семье Турбиных в Киеве во время Гражданской войны 1918-1919 годов.",
|
||||
"authors": ["Михаил Булгаков"],
|
||||
"genres": ["Роман", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Мёртвые души",
|
||||
"description": "Поэма Николая Гоголя о похождениях Чичикова, скупающего «мёртвые души» крепостных крестьян.",
|
||||
"authors": ["Николай Гоголь"],
|
||||
"genres": ["Роман", "Сатира"]
|
||||
},
|
||||
{
|
||||
"title": "Ревизор",
|
||||
"description": "Комедия о чиновниках уездного города, принявших проезжего за ревизора из Петербурга.",
|
||||
"authors": ["Николай Гоголь"],
|
||||
"genres": ["Драма", "Сатира"]
|
||||
},
|
||||
{
|
||||
"title": "Шинель",
|
||||
"description": "Повесть о маленьком человеке — титулярном советнике Акакии Башмачкине и его мечте о новой шинели.",
|
||||
"authors": ["Николай Гоголь"],
|
||||
"genres": ["Повесть"]
|
||||
},
|
||||
{
|
||||
"title": "Отцы и дети",
|
||||
"description": "Роман Ивана Тургенева о конфликте поколений и нигилизме на примере Евгения Базарова.",
|
||||
"authors": ["Иван Тургенев"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Записки охотника",
|
||||
"description": "Цикл рассказов Тургенева о русской деревне и крестьянах, написанный с глубоким сочувствием к народу.",
|
||||
"authors": ["Иван Тургенев"],
|
||||
"genres": ["Рассказ"]
|
||||
},
|
||||
{
|
||||
"title": "Доктор Живаго",
|
||||
"description": "Роман Бориса Пастернака о судьбе русского интеллигента в эпоху революции и Гражданской войны.",
|
||||
"authors": ["Борис Пастернак"],
|
||||
"genres": ["Роман", "Историческая проза", "Поэзия"]
|
||||
},
|
||||
{
|
||||
"title": "Герой нашего времени",
|
||||
"description": "Роман Михаила Лермонтова о Печорине — «лишнем человеке», скучающем и разочарованном в жизни.",
|
||||
"authors": ["Михаил Лермонтов"],
|
||||
"genres": ["Роман", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Архипелаг ГУЛАГ",
|
||||
"description": "Документально-художественное исследование Александра Солженицына о системе советских лагерей.",
|
||||
"authors": ["Александр Солженицын"],
|
||||
"genres": ["Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "Один день Ивана Денисовича",
|
||||
"description": "Повесть о одном дне заключённого советского лагеря, положившая начало лагерной прозе.",
|
||||
"authors": ["Александр Солженицын"],
|
||||
"genres": ["Повесть", "Историческая проза"]
|
||||
},
|
||||
{
|
||||
"title": "На дне",
|
||||
"description": "Пьеса Максима Горького о жителях ночлежки для бездомных — людях, оказавшихся на дне жизни.",
|
||||
"authors": ["Максим Горький"],
|
||||
"genres": ["Драма", "Философская проза"]
|
||||
},
|
||||
{
|
||||
"title": "Тёмные аллеи",
|
||||
"description": "Сборник рассказов Ивана Бунина о любви — трагической, мимолётной и прекрасной.",
|
||||
"authors": ["Иван Бунин"],
|
||||
"genres": ["Рассказ"]
|
||||
}
|
||||
]
|
||||
|
||||
books = {}
|
||||
for book in books_data:
|
||||
book_id = api.create_book(book["title"], book["description"])
|
||||
if book_id:
|
||||
books[book["title"]] = {
|
||||
"id": book_id,
|
||||
"authors": book["authors"],
|
||||
"genres": book["genres"]
|
||||
}
|
||||
|
||||
# === СОЗДАНИЕ СВЯЗЕЙ ===
|
||||
print("\n🔗 Создание связей...")
|
||||
|
||||
for book_title, book_info in books.items():
|
||||
book_id = book_info["id"]
|
||||
|
||||
# Связи с авторами
|
||||
for author_name in book_info["authors"]:
|
||||
if author_name in authors:
|
||||
api.link_author_book(authors[author_name], book_id)
|
||||
|
||||
# Связи с жанрами
|
||||
for genre_name in book_info["genres"]:
|
||||
if genre_name in genres:
|
||||
api.link_genre_book(genres[genre_name], book_id)
|
||||
|
||||
# === ИТОГИ ===
|
||||
print("\n" + "=" * 50)
|
||||
print("📊 ИТОГИ:")
|
||||
print(f" • Авторов создано: {len(authors)}")
|
||||
print(f" • Жанров создано: {len(genres)}")
|
||||
print(f" • Книг создано: {len(books)}")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+91
-16
@@ -1,28 +1,103 @@
|
||||
services:
|
||||
db:
|
||||
image: pgvector/pgvector:pg17
|
||||
container_name: db
|
||||
image: postgres:17
|
||||
ports:
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
networks:
|
||||
- proxy
|
||||
ports: # !сменить внешний порт перед использованием!
|
||||
- 5432:5432
|
||||
# volumes:
|
||||
# - ./data/db:/var/lib/postgresql/data
|
||||
env_file:
|
||||
- ./.env
|
||||
command:
|
||||
- "postgres"
|
||||
- "-c"
|
||||
- "wal_level=logical"
|
||||
- "-c"
|
||||
- "max_replication_slots=10"
|
||||
- "-c"
|
||||
- "max_wal_senders=10"
|
||||
- "-c"
|
||||
- "listen_addresses=*"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
replication-setup:
|
||||
image: postgres:17-alpine
|
||||
container_name: replication-setup
|
||||
restart: "no"
|
||||
networks:
|
||||
- proxy
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./setup-replication.sh:/setup-replication.sh
|
||||
entrypoint: ["/bin/sh", "/setup-replication.sh"]
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_started
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
llm:
|
||||
image: ollama/ollama:latest
|
||||
container_name: llm
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- ./data/llm:/root/.ollama
|
||||
networks:
|
||||
- proxy
|
||||
ports: # !только локальный тест!
|
||||
- 11434:11434
|
||||
env_file:
|
||||
- ./.env
|
||||
healthcheck:
|
||||
test: ["CMD", "ollama", "list"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 5g
|
||||
|
||||
api:
|
||||
build: .
|
||||
container_name: api
|
||||
build: .
|
||||
command: bash -c "alembic upgrade head && uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
restart: unless-stopped
|
||||
command: python library_service/main.py
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- proxy
|
||||
ports: # !только локальный тест!
|
||||
- 8000:8000
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- .:/code
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# depends_on:
|
||||
# - db
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
llm:
|
||||
condition: service_healthy
|
||||
|
||||
tests:
|
||||
container_name: tests
|
||||
build: .
|
||||
command: bash -c "pytest tests"
|
||||
volumes:
|
||||
- .:/code
|
||||
networks:
|
||||
proxy: # Рекомендуется использовать через реверс-прокси
|
||||
name: proxy
|
||||
external: true
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST=db
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=lib
|
||||
REMOTE_HOST=
|
||||
REMOTE_PORT=
|
||||
NODE_ID=
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL="http://llm:11434"
|
||||
OLLAMA_MAX_LOADED_MODELS=1
|
||||
OLLAMA_NUM_THREADS=4
|
||||
OLLAMA_KEEP_ALIVE=5m
|
||||
|
||||
# Default admin account
|
||||
DEFAULT_ADMIN_USERNAME="admin"
|
||||
DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||
DEFAULT_ADMIN_PASSWORD="Password12345"
|
||||
SECRET_KEY="your-secret-key-change-in-production"
|
||||
DOMAIN="mydomain.com"
|
||||
|
||||
# JWT
|
||||
ALGORITHM=HS256
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES=5
|
||||
|
||||
# Hash
|
||||
ARGON2_TYPE=id
|
||||
ARGON2_TIME_COST=3
|
||||
ARGON2_MEMORY_COST=65536
|
||||
ARGON2_PARALLELISM=4
|
||||
ARGON2_SALT_LENGTH=16
|
||||
ARGON2_HASH_LENGTH=48
|
||||
|
||||
# Recovery codes
|
||||
RECOVERY_CODES_COUNT=10
|
||||
RECOVERY_CODE_SEGMENTS=4
|
||||
RECOVERY_CODE_SEGMENT_BYTES=2
|
||||
RECOVERY_MIN_REMAINING_WARNING=3
|
||||
RECOVERY_MAX_AGE_DAYS=365
|
||||
|
||||
# TOTP_2FA
|
||||
TOTP_ISSUER=LiB
|
||||
TOTP_VALID_WINDOW=1
|
||||
@@ -0,0 +1,44 @@
|
||||
# Postgres
|
||||
POSTGRES_HOST=localhost
|
||||
POSTGRES_PORT=5432
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=lib
|
||||
|
||||
# Ollama
|
||||
OLLAMA_URL="http://localhost:11434"
|
||||
OLLAMA_MAX_LOADED_MODELS=1
|
||||
OLLAMA_NUM_THREADS=4
|
||||
OLLAMA_KEEP_ALIVE=5m
|
||||
|
||||
# Default admin account
|
||||
DEFAULT_ADMIN_USERNAME="admin"
|
||||
DEFAULT_ADMIN_EMAIL="admin@example.com"
|
||||
DEFAULT_ADMIN_PASSWORD="Password12345"
|
||||
SECRET_KEY="your-secret-key-change-in-production"
|
||||
DOMAIN="mydomain.com"
|
||||
|
||||
# JWT
|
||||
ALGORITHM=HS256
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=15
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES=5
|
||||
|
||||
# Hash
|
||||
ARGON2_TYPE=id
|
||||
ARGON2_TIME_COST=3
|
||||
ARGON2_MEMORY_COST=65536
|
||||
ARGON2_PARALLELISM=4
|
||||
ARGON2_SALT_LENGTH=16
|
||||
ARGON2_HASH_LENGTH=48
|
||||
|
||||
# Recovery codes
|
||||
RECOVERY_CODES_COUNT=10
|
||||
RECOVERY_CODE_SEGMENTS=4
|
||||
RECOVERY_CODE_SEGMENT_BYTES=2
|
||||
RECOVERY_MIN_REMAINING_WARNING=3
|
||||
RECOVERY_MAX_AGE_DAYS=365
|
||||
|
||||
# TOTP_2FA
|
||||
TOTP_ISSUER=LiB
|
||||
TOTP_VALID_WINDOW=1
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE PUBLICATION all_tables_pub FOR ALL TABLES;
|
||||
|
||||
ALTER SYSTEM SET password_encryption = 'scram-sha-256';
|
||||
SELECT pg_reload_conf();
|
||||
@@ -1,221 +0,0 @@
|
||||
"""Модуль авторизации и аутентификации"""
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import TokenData
|
||||
from library_service.settings import get_session, get_logger
|
||||
|
||||
|
||||
# Конфигурация из переменных окружения
|
||||
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", "30"))
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
|
||||
|
||||
|
||||
# Получение логгера
|
||||
logger = get_logger("uvicorn")
|
||||
|
||||
# OAuth2 схема
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
# Хэширование паролей
|
||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверка пароль по его хешу."""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хэширование пароля."""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""Создание JWT access токена."""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
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:
|
||||
"""Создание JWT refresh токена."""
|
||||
to_encode = data.copy()
|
||||
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:
|
||||
"""Декодирование и проверка JWT токенов."""
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
user_id: int = payload.get("user_id")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return TokenData(username=username, user_id=user_id)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
||||
"""Аутентификация пользователя по имени пользователя и паролю."""
|
||||
statement = select(User).where(User.username == username)
|
||||
user = session.exec(statement).first()
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
session: Session = Depends(get_session),
|
||||
) -> User:
|
||||
"""Получить текущего авторизованного пользователя."""
|
||||
token_data = decode_token(token)
|
||||
|
||||
user = session.get(User, token_data.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Получить текущего активного пользователя."""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(role_name: 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 role_name not in user_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Role '{role_name}' required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
return role_checker
|
||||
|
||||
|
||||
# Создание dependencies
|
||||
RequireAuth = Annotated[User, Depends(get_current_active_user)]
|
||||
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
|
||||
RequireModerator = Annotated[User, Depends(require_role("moderator"))]
|
||||
|
||||
|
||||
def seed_roles(session: Session) -> dict[str, Role]:
|
||||
"""Создаёт роли по умолчанию, если их нет."""
|
||||
default_roles = [
|
||||
{"name": "admin", "description": "Администратор системы"},
|
||||
{"name": "moderator", "description": "Модератор"},
|
||||
{"name": "user", "description": "Обычный пользователь"},
|
||||
]
|
||||
|
||||
roles = {}
|
||||
for role_data in default_roles:
|
||||
existing = session.exec(
|
||||
select(Role).where(Role.name == role_data["name"])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
roles[role_data["name"]] = existing
|
||||
else:
|
||||
role = Role(**role_data)
|
||||
session.add(role)
|
||||
session.commit()
|
||||
session.refresh(role)
|
||||
roles[role_data["name"]] = role
|
||||
logger.info(f"[+] Created role: {role_data['name']}")
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
"""Создаёт администратора по умолчанию, если нет ни одного."""
|
||||
existing_admins = session.exec(
|
||||
select(User).join(User.roles).where(Role.name == "admin")
|
||||
).all()
|
||||
|
||||
if existing_admins:
|
||||
logger.info(f"[=] Admin already exists: {existing_admins[0].username}, skipping creation")
|
||||
return None
|
||||
|
||||
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
|
||||
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
|
||||
|
||||
generated = False
|
||||
if not admin_password:
|
||||
import secrets
|
||||
admin_password = secrets.token_urlsafe(16)
|
||||
generated = True
|
||||
|
||||
admin_user = User(
|
||||
username=admin_username,
|
||||
email=admin_email,
|
||||
full_name="Системный администратор",
|
||||
hashed_password=get_password_hash(admin_password),
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
admin_user.roles.append(admin_role)
|
||||
|
||||
session.add(admin_user)
|
||||
session.commit()
|
||||
session.refresh(admin_user)
|
||||
|
||||
logger.info(f"[+] Created admin user: {admin_username}")
|
||||
|
||||
if generated:
|
||||
logger.warning("=" * 50)
|
||||
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
|
||||
logger.warning("[!] Save this password! It won't be shown again!")
|
||||
logger.warning("=" * 50)
|
||||
|
||||
return admin_user
|
||||
|
||||
|
||||
def run_seeds(session: Session) -> None:
|
||||
"""Запускаем создание ролей и администратора."""
|
||||
roles = seed_roles(session)
|
||||
seed_admin(session, roles["admin"])
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Пакет авторизации и аутентификации"""
|
||||
|
||||
from .core import (
|
||||
SECRET_KEY,
|
||||
ALGORITHM,
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES,
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
ARGON2_TIME_COST,
|
||||
ARGON2_MEMORY_COST,
|
||||
ARGON2_PARALLELISM,
|
||||
ARGON2_SALT_LENGTH,
|
||||
ARGON2_HASH_LENGTH,
|
||||
RECOVERY_CODES_COUNT,
|
||||
RECOVERY_CODE_SEGMENTS,
|
||||
RECOVERY_CODE_SEGMENT_BYTES,
|
||||
RECOVERY_MIN_REMAINING_WARNING,
|
||||
RECOVERY_MAX_AGE_DAYS,
|
||||
KeyDeriver,
|
||||
deriver,
|
||||
AES256Cipher,
|
||||
cipher,
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
create_partial_token,
|
||||
decode_token,
|
||||
authenticate_user,
|
||||
get_current_user,
|
||||
get_current_active_user,
|
||||
get_user_from_partial_token,
|
||||
require_role,
|
||||
require_any_role,
|
||||
is_user_staff,
|
||||
is_user_admin,
|
||||
RequireAuth,
|
||||
RequireAdmin,
|
||||
RequireMember,
|
||||
RequireLibrarian,
|
||||
RequirePartialAuth,
|
||||
RequireStaff,
|
||||
)
|
||||
|
||||
from .seed import (
|
||||
seed_roles,
|
||||
seed_admin,
|
||||
run_seeds,
|
||||
)
|
||||
|
||||
from .recovery import (
|
||||
generate_codes_for_user,
|
||||
verify_and_use_code,
|
||||
get_codes_status,
|
||||
)
|
||||
|
||||
from .totp import (
|
||||
generate_secret,
|
||||
get_provisioning_uri,
|
||||
verify_totp_code,
|
||||
qr_to_bitmap_b64,
|
||||
generate_totp_setup,
|
||||
TOTP_ISSUER,
|
||||
TOTP_VALID_WINDOW,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SECRET_KEY",
|
||||
"ALGORITHM",
|
||||
"ACCESS_TOKEN_EXPIRE_MINUTES",
|
||||
"REFRESH_TOKEN_EXPIRE_DAYS",
|
||||
"ARGON2_TIME_COST",
|
||||
"ARGON2_MEMORY_COST",
|
||||
"ARGON2_PARALLELISM",
|
||||
"ARGON2_SALT_LENGTH",
|
||||
"ARGON2_HASH_LENGTH",
|
||||
"RECOVERY_CODES_COUNT",
|
||||
"RECOVERY_CODE_SEGMENTS",
|
||||
"RECOVERY_CODE_SEGMENT_BYTES",
|
||||
"RECOVERY_MIN_REMAINING_WARNING",
|
||||
"RECOVERY_MAX_AGE_DAYS",
|
||||
"KeyDeriver",
|
||||
"deriver",
|
||||
"AES256Cipher",
|
||||
"cipher",
|
||||
"verify_password",
|
||||
"get_password_hash",
|
||||
"create_access_token",
|
||||
"create_refresh_token",
|
||||
"decode_token",
|
||||
"authenticate_user",
|
||||
"get_current_user",
|
||||
"get_current_active_user",
|
||||
"require_role",
|
||||
"require_any_role",
|
||||
"is_user_staff",
|
||||
"is_user_admin",
|
||||
"RequireAuth",
|
||||
"RequireAdmin",
|
||||
"RequireMember",
|
||||
"RequireLibrarian",
|
||||
"RequireStaff",
|
||||
"seed_roles",
|
||||
"seed_admin",
|
||||
"run_seeds",
|
||||
"generate_secre",
|
||||
"get_provisioning_uri",
|
||||
"verify_totp_code",
|
||||
"qr_to_bitmap_b64",
|
||||
"generate_totp_setup," "generate_codes_for_user",
|
||||
"verify_and_use_code",
|
||||
"get_codes_status",
|
||||
"CODES_COUNT",
|
||||
"MIN_REMAINING_WARNING",
|
||||
"TOTP_ISSUER",
|
||||
"TOTP_VALID_WINDOW",
|
||||
]
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Модуль основного функционала авторизации и аутентификации"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated
|
||||
from uuid import uuid4
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from argon2.low_level import hash_secret_raw, Type
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from jose import jwt, JWTError, ExpiredSignatureError
|
||||
from passlib.context import CryptContext
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.models.db import User
|
||||
from library_service.models.dto import TokenData
|
||||
from library_service.settings import get_session, get_logger
|
||||
|
||||
|
||||
# Конфигурация JWT из переменных окружения
|
||||
ALGORITHM = os.getenv("ALGORITHM", "HS256")
|
||||
PARTIAL_TOKEN_EXPIRE_MINUTES = int(os.getenv("PARTIAL_TOKEN_EXPIRE_MINUTES", "5"))
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
|
||||
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", "131072"))
|
||||
ARGON2_PARALLELISM = int(os.getenv("ARGON2_PARALLELISM", "2"))
|
||||
ARGON2_SALT_LENGTH = int(os.getenv("ARGON2_SALT_LENGTH", "16"))
|
||||
ARGON2_HASH_LENGTH = int(os.getenv("ARGON2_HASH_LENGTH", "48"))
|
||||
|
||||
# Конфигурация кодов восстановления
|
||||
RECOVERY_CODES_COUNT = int(os.getenv("RECOVERY_CODES_COUNT", "10"))
|
||||
RECOVERY_CODE_SEGMENTS = int(os.getenv("RECOVERY_CODE_SEGMENTS", "4"))
|
||||
RECOVERY_CODE_SEGMENT_BYTES = int(os.getenv("RECOVERY_CODE_SEGMENT_BYTES", "2"))
|
||||
RECOVERY_MIN_REMAINING_WARNING = int(os.getenv("RECOVERY_MIN_REMAINING_WARNING", "3"))
|
||||
RECOVERY_MAX_AGE_DAYS = int(os.getenv("RECOVERY_MAX_AGE_DAYS", "365"))
|
||||
|
||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||
|
||||
# Получение логгера
|
||||
logger = get_logger()
|
||||
|
||||
# OAuth2 схема
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/token")
|
||||
|
||||
|
||||
class KeyDeriver:
|
||||
def __init__(self, master_key: bytes):
|
||||
self.master_key = master_key
|
||||
|
||||
def derive(
|
||||
self,
|
||||
context: str,
|
||||
key_len: int = 32,
|
||||
time_cost: int = 12,
|
||||
memory_cost: int = 512 * 1024,
|
||||
parallelism: int = 4,
|
||||
) -> bytes:
|
||||
"""
|
||||
Формирование разных ключей из одного.
|
||||
context: любая строка, например "aes", "hmac", "totp"
|
||||
"""
|
||||
salt = hashlib.sha256(context.encode("utf-8")).digest()
|
||||
key = hash_secret_raw(
|
||||
secret=self.master_key,
|
||||
salt=salt,
|
||||
time_cost=time_cost,
|
||||
memory_cost=memory_cost,
|
||||
parallelism=parallelism,
|
||||
hash_len=key_len,
|
||||
type=Type.ID,
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
class AES256Cipher:
|
||||
def __init__(self, key: bytes):
|
||||
if len(key) != 32:
|
||||
raise ValueError("AES-256 требует ключ длиной 32 байта")
|
||||
self.key = key
|
||||
self.aesgcm = AESGCM(key)
|
||||
|
||||
def encrypt(self, plaintext: bytes, nonce_len: int = 12) -> bytes:
|
||||
"""Зашифровывает данные с помощью AES256-GCM"""
|
||||
nonce = os.urandom(nonce_len)
|
||||
ct = self.aesgcm.encrypt(nonce, plaintext, associated_data=None)
|
||||
return nonce + ct
|
||||
|
||||
def decrypt(self, data: bytes, nonce_len: int = 12) -> bytes:
|
||||
"""Расшифровывает данные с помощью AES256-GCM"""
|
||||
nonce = data[:nonce_len]
|
||||
ct = data[nonce_len:]
|
||||
return self.aesgcm.decrypt(nonce, ct, associated_data=None)
|
||||
|
||||
|
||||
# Проверка секретного ключа
|
||||
if not SECRET_KEY:
|
||||
raise RuntimeError("SECRET_KEY environment variable is required")
|
||||
|
||||
deriver = KeyDeriver(SECRET_KEY.encode())
|
||||
|
||||
jwt_key = deriver.derive("jwt", key_len=32)
|
||||
|
||||
aes_key = deriver.derive("totp", key_len=32)
|
||||
cipher = AES256Cipher(aes_key)
|
||||
|
||||
|
||||
# Хэширование паролей
|
||||
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,
|
||||
argon2__hash_len=ARGON2_HASH_LENGTH,
|
||||
)
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверяет пароль по его хешу"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хэширует пароль"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def _create_token(
|
||||
data: dict,
|
||||
expires_delta: timedelta,
|
||||
token_type: str,
|
||||
is_partial: bool = False,
|
||||
) -> str:
|
||||
"""Базовая функция создания токена"""
|
||||
now = datetime.now(timezone.utc)
|
||||
to_encode = {
|
||||
**data,
|
||||
"iat": now,
|
||||
"exp": now + expires_delta,
|
||||
"type": token_type,
|
||||
"partial": is_partial,
|
||||
}
|
||||
if token_type == "refresh":
|
||||
to_encode.update({"jti": str(uuid4())})
|
||||
return jwt.encode(to_encode, jwt_key, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_partial_token(data: dict) -> str:
|
||||
"""Создает partial токен для незавершённой 2FA аутентификации"""
|
||||
delta = timedelta(minutes=PARTIAL_TOKEN_EXPIRE_MINUTES)
|
||||
return _create_token(data, delta, "partial", is_partial=True)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
|
||||
"""Создает JWT access токен"""
|
||||
delta = expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
return _create_token(data, delta, "access")
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Создает JWT refresh токен"""
|
||||
return _create_token(data, timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS), "refresh")
|
||||
|
||||
|
||||
def decode_token(
|
||||
token: str,
|
||||
expected_type: str = "access",
|
||||
allow_partial: bool = False,
|
||||
) -> TokenData:
|
||||
"""Декодирует и проверяет JWT токен"""
|
||||
token_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
try:
|
||||
payload = jwt.decode(token, jwt_key, algorithms=[ALGORITHM])
|
||||
username: str | None = payload.get("sub")
|
||||
user_id: int | None = payload.get("user_id")
|
||||
token_type: str | None = payload.get("type")
|
||||
is_partial: bool = payload.get("partial", False)
|
||||
|
||||
if token_type == "partial":
|
||||
if not allow_partial:
|
||||
token_error.detail = "2FA verification required"
|
||||
raise token_error
|
||||
elif token_type != expected_type:
|
||||
token_error.detail = f"Invalid token type. Expected {expected_type}"
|
||||
raise token_error
|
||||
|
||||
if username is None or user_id is None:
|
||||
token_error.detail = "Could not validate credentials"
|
||||
raise token_error
|
||||
|
||||
return TokenData(username=username, user_id=user_id, is_partial=is_partial)
|
||||
except ExpiredSignatureError:
|
||||
token_error.detail = "Token expired"
|
||||
raise token_error
|
||||
except JWTError:
|
||||
token_error.detail = "Could not validate credentials"
|
||||
raise token_error
|
||||
|
||||
|
||||
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
||||
"""Аутентифицирует пользователя по имени и паролю"""
|
||||
statement = select(User).where(User.username == username)
|
||||
user = session.exec(statement).first()
|
||||
if not user or not verify_password(password, user.hashed_password):
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
session: Session = Depends(get_session),
|
||||
) -> User:
|
||||
"""Возвращает текущего авторизованного пользователя"""
|
||||
token_data = decode_token(token)
|
||||
|
||||
user = session.get(User, token_data.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> User:
|
||||
"""Проверяет активность пользователя и возвращает его"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Inactive user"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def get_user_from_partial_token(
|
||||
token: Annotated[str, Depends(oauth2_scheme)],
|
||||
session: Session = Depends(get_session),
|
||||
) -> User:
|
||||
"""Возвращает пользователя из partial токена (для 2FA верификации)"""
|
||||
token_data = decode_token(token, expected_type="access", allow_partial=True)
|
||||
|
||||
if not token_data.is_partial:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Full token provided, 2FA not required",
|
||||
)
|
||||
|
||||
user = session.get(User, token_data.user_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
def require_role(role_name: 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 role_name not in user_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Role '{role_name}' required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
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
|
||||
RequireAuth = Annotated[User, Depends(get_current_active_user)]
|
||||
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
|
||||
RequireMember = Annotated[User, Depends(require_role("member"))]
|
||||
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))]
|
||||
RequirePartialAuth = Annotated[User, Depends(get_user_from_partial_token)]
|
||||
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
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Модуль резервных кодов восстановления пароля"""
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import argon2
|
||||
from sqlmodel import Session
|
||||
|
||||
from .core import (
|
||||
ARGON2_TIME_COST,
|
||||
ARGON2_MEMORY_COST,
|
||||
ARGON2_PARALLELISM,
|
||||
ARGON2_SALT_LENGTH,
|
||||
ARGON2_HASH_LENGTH,
|
||||
RECOVERY_CODES_COUNT,
|
||||
RECOVERY_CODE_SEGMENTS,
|
||||
RECOVERY_CODE_SEGMENT_BYTES,
|
||||
RECOVERY_MIN_REMAINING_WARNING,
|
||||
RECOVERY_MAX_AGE_DAYS,
|
||||
)
|
||||
from library_service.settings import get_logger
|
||||
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
# Argon2 для кодов
|
||||
_recovery_hasher = argon2.PasswordHasher(
|
||||
type=argon2.Type.ID,
|
||||
time_cost=ARGON2_TIME_COST,
|
||||
hash_len=ARGON2_HASH_LENGTH,
|
||||
salt_len=ARGON2_SALT_LENGTH,
|
||||
memory_cost=ARGON2_MEMORY_COST,
|
||||
parallelism=ARGON2_PARALLELISM,
|
||||
)
|
||||
|
||||
|
||||
def generate_code() -> str:
|
||||
"""Генерация кода в формате xxxx-xxxx-xxxx-xxxx"""
|
||||
segments = [
|
||||
secrets.token_hex(RECOVERY_CODE_SEGMENT_BYTES)
|
||||
for _ in range(RECOVERY_CODE_SEGMENTS)
|
||||
]
|
||||
return "-".join(segments)
|
||||
|
||||
|
||||
def normalize_code(code: str) -> str:
|
||||
"""Нормализация: убираем дефисы, lowercase"""
|
||||
return code.replace("-", "").lower().strip()
|
||||
|
||||
|
||||
def hash_code(code: str) -> str:
|
||||
"""Хеширование кода"""
|
||||
return _recovery_hasher.hash(normalize_code(code))
|
||||
|
||||
|
||||
def verify_code(plain_code: str, hashed: str) -> bool:
|
||||
"""Проверка кода"""
|
||||
if not hashed:
|
||||
return False
|
||||
try:
|
||||
_recovery_hasher.verify(hashed, normalize_code(plain_code))
|
||||
return True
|
||||
except argon2.exceptions.VerifyMismatchError:
|
||||
return False
|
||||
except argon2.exceptions.InvalidHashError:
|
||||
logger.warning("Invalid recovery code hash format")
|
||||
return False
|
||||
|
||||
|
||||
def generate_codes_for_user(session: Session, user) -> list[str]:
|
||||
"""Генерация новых резервных кодов для пользователя."""
|
||||
plain_codes: list[str] = []
|
||||
hashed_codes: list[str] = []
|
||||
|
||||
for _ in range(RECOVERY_CODES_COUNT):
|
||||
code = generate_code()
|
||||
plain_codes.append(code)
|
||||
hashed_codes.append(hash_code(code))
|
||||
|
||||
user.recovery_code_hashes = " ".join(hashed_codes)
|
||||
user.recovery_codes_generated_at = datetime.now(timezone.utc)
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
logger.info(f"Generated {RECOVERY_CODES_COUNT} recovery codes for user {user.id}")
|
||||
|
||||
return plain_codes
|
||||
|
||||
|
||||
def verify_and_use_code(session: Session, user, code: str) -> bool:
|
||||
"""Проверка и использование кода. При успехе хеш заменяется на пустую строку"""
|
||||
if not user.recovery_code_hashes:
|
||||
return False
|
||||
|
||||
hashes = user.recovery_code_hashes.split(" ")
|
||||
|
||||
for i, stored_hash in enumerate(hashes):
|
||||
if stored_hash and verify_code(code, stored_hash):
|
||||
hashes[i] = ""
|
||||
user.recovery_code_hashes = " ".join(hashes)
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Recovery code #{i + 1} used for user {user.id}, "
|
||||
f"remaining: {sum(1 for h in hashes if h)}"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.warning(f"Invalid recovery code attempt for user {user.id}")
|
||||
return False
|
||||
|
||||
|
||||
def get_codes_status(user) -> dict:
|
||||
"""Статус резервных кодов"""
|
||||
if not user.recovery_code_hashes:
|
||||
return {
|
||||
"total": 0,
|
||||
"remaining": 0,
|
||||
"used_codes": [],
|
||||
"generated_at": None,
|
||||
"should_regenerate": True,
|
||||
}
|
||||
|
||||
hashes = user.recovery_code_hashes.split(" ")
|
||||
used_codes = [h == "" for h in hashes]
|
||||
remaining = sum(1 for h in hashes if h)
|
||||
total = len(hashes)
|
||||
generated_at = user.recovery_codes_generated_at
|
||||
|
||||
should_regenerate = remaining <= RECOVERY_MIN_REMAINING_WARNING
|
||||
|
||||
if generated_at:
|
||||
generated_at = generated_at.replace(tzinfo=timezone.utc)
|
||||
age = datetime.now(timezone.utc) - generated_at
|
||||
if age > timedelta(days=RECOVERY_MAX_AGE_DAYS):
|
||||
should_regenerate = True
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"remaining": remaining,
|
||||
"used_codes": used_codes,
|
||||
"generated_at": generated_at,
|
||||
"should_regenerate": should_regenerate,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Модуль создания начальных ролей и администратора"""
|
||||
|
||||
import os
|
||||
|
||||
from sqlmodel import Session, select
|
||||
from library_service.models.db import Role, User
|
||||
|
||||
from .core import get_password_hash
|
||||
from library_service.settings import get_logger
|
||||
|
||||
# Получение логгера
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
def seed_roles(session: Session) -> dict[str, Role]:
|
||||
"""Создает роли по умолчанию, если их нет"""
|
||||
default_roles = [
|
||||
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
|
||||
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
|
||||
{"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
|
||||
]
|
||||
|
||||
roles = {}
|
||||
for role_data in default_roles:
|
||||
existing = session.exec(
|
||||
select(Role).where(Role.name == role_data["name"])
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
roles[role_data["name"]] = existing
|
||||
else:
|
||||
role = Role(**role_data)
|
||||
session.add(role)
|
||||
session.commit()
|
||||
session.refresh(role)
|
||||
roles[role_data["name"]] = role
|
||||
logger.info(f"[+] Created role: {role_data['name']}")
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
def seed_admin(session: Session, admin_role: Role) -> User | None:
|
||||
"""Создает администратора по умолчанию, если нет ни одного"""
|
||||
existing_admins = session.exec(
|
||||
select(User)
|
||||
.join(User.roles) # ty: ignore[invalid-argument-type]
|
||||
.where(Role.name == "admin")
|
||||
).all()
|
||||
|
||||
if existing_admins:
|
||||
logger.info(
|
||||
f"[=] Admin already exists: {existing_admins[0].username}, skipping creation"
|
||||
)
|
||||
return None
|
||||
|
||||
admin_username = os.getenv("DEFAULT_ADMIN_USERNAME", "admin")
|
||||
admin_email = os.getenv("DEFAULT_ADMIN_EMAIL", "admin@example.com")
|
||||
admin_password = os.getenv("DEFAULT_ADMIN_PASSWORD")
|
||||
|
||||
generated = False
|
||||
if not admin_password:
|
||||
import secrets
|
||||
|
||||
admin_password = secrets.token_urlsafe(16)
|
||||
generated = True
|
||||
|
||||
admin_user = User(
|
||||
username=admin_username,
|
||||
email=admin_email,
|
||||
full_name="Системный администратор",
|
||||
hashed_password=get_password_hash(admin_password),
|
||||
is_active=True,
|
||||
is_verified=True,
|
||||
)
|
||||
admin_user.roles.append(admin_role)
|
||||
|
||||
session.add(admin_user)
|
||||
session.commit()
|
||||
session.refresh(admin_user)
|
||||
|
||||
logger.info(f"[+] Created admin user: {admin_username}")
|
||||
|
||||
if generated:
|
||||
logger.warning("=" * 52)
|
||||
logger.warning(f"[!] GENERATED ADMIN PASSWORD: {admin_password}")
|
||||
logger.warning("[!] Save this password! It won't be shown again!")
|
||||
logger.warning("=" * 52)
|
||||
|
||||
return admin_user
|
||||
|
||||
|
||||
def run_seeds(session: Session) -> None:
|
||||
"""Запускает создание ролей и администратора"""
|
||||
roles = seed_roles(session)
|
||||
seed_admin(session, roles["admin"])
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Модуль TOTP 2FA"""
|
||||
|
||||
import base64
|
||||
import os
|
||||
|
||||
import pyotp
|
||||
import qrcode
|
||||
|
||||
|
||||
# Настройкт из переменных окружения
|
||||
TOTP_ISSUER = os.getenv("TOTP_ISSUER", "LiB")
|
||||
TOTP_VALID_WINDOW = int(os.getenv("TOTP_VALID_WINDOW", "1"))
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Генерация нового TOTP секрета"""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def get_provisioning_uri(secret: str, username: str) -> str:
|
||||
"""Получение URI для QR-кода"""
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.provisioning_uri(name=username, issuer_name=TOTP_ISSUER)
|
||||
|
||||
|
||||
def verify_totp_code(secret: str, code: str) -> bool:
|
||||
"""Проверка TOTP кода"""
|
||||
if not secret or not code:
|
||||
return False
|
||||
totp = pyotp.TOTP(secret)
|
||||
return totp.verify(code, valid_window=TOTP_VALID_WINDOW)
|
||||
|
||||
|
||||
def qr_to_bitmap_b64(data: str) -> dict:
|
||||
"""Конвертирует данные в QR-код и возвращает как base64 bitmap"""
|
||||
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}
|
||||
|
||||
|
||||
def generate_totp_setup(username: str) -> dict:
|
||||
"""Генерация данных для настройки TOTP"""
|
||||
secret = generate_secret()
|
||||
uri = get_provisioning_uri(secret, username)
|
||||
bitmap_data = qr_to_bitmap_b64(uri)
|
||||
|
||||
return {
|
||||
"secret": secret,
|
||||
"username": username,
|
||||
"issuer": TOTP_ISSUER,
|
||||
**bitmap_data,
|
||||
}
|
||||
+148
-8
@@ -1,22 +1,40 @@
|
||||
"""Основной модуль"""
|
||||
|
||||
import asyncio, sys, traceback
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
from uuid import uuid4
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Depends, Request, Response, status, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import JSONResponse
|
||||
from ollama import Client, ResponseError
|
||||
from sqlmodel import Session
|
||||
|
||||
from .auth import run_seeds
|
||||
from .routers import api_router
|
||||
from .settings import engine, get_app, get_logger
|
||||
from library_service.auth import run_seeds
|
||||
from library_service.routers import api_router
|
||||
from library_service.routers.misc import unknown
|
||||
from library_service.services.captcha import limiter, cleanup_task, require_captcha
|
||||
from library_service.settings import (
|
||||
LOGGING_CONFIG,
|
||||
engine,
|
||||
get_app,
|
||||
get_logger,
|
||||
OLLAMA_URL,
|
||||
)
|
||||
|
||||
|
||||
SKIP_LOGGING_PATHS = frozenset({"/favicon.ico", "/favicon.svg"})
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
async def lifespan(_):
|
||||
"""Жизненный цикл сервиса"""
|
||||
logger = get_logger("uvicorn")
|
||||
logger = get_logger()
|
||||
logger.info("[+] Initializing database...")
|
||||
|
||||
try:
|
||||
@@ -37,6 +55,14 @@ async def lifespan(app: FastAPI):
|
||||
except Exception as e:
|
||||
logger.error(f"[-] Seeding failed: {e}")
|
||||
|
||||
logger.info("[+] Loading ollama models...")
|
||||
ollama_client = Client(host=OLLAMA_URL)
|
||||
try:
|
||||
ollama_client.pull("mxbai-embed-large")
|
||||
ollama_client.pull("llama3.2")
|
||||
except ResponseError as e:
|
||||
logger.error(f"[-] Failed to pull models {e}")
|
||||
asyncio.create_task(cleanup_task())
|
||||
logger.info("[+] Starting application...")
|
||||
yield # Обработка запросов
|
||||
logger.info("[+] Application shutdown")
|
||||
@@ -45,7 +71,121 @@ async def lifespan(app: FastAPI):
|
||||
app = get_app(lifespan)
|
||||
|
||||
|
||||
@app.exception_handler(status.HTTP_404_NOT_FOUND)
|
||||
async def custom_not_found_handler(request: Request, exc: HTTPException):
|
||||
path = request.url.path
|
||||
|
||||
if path.startswith("/api"):
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={"detail": "API endpoint not found", "path": path},
|
||||
)
|
||||
|
||||
return await unknown(request, app)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def catch_exceptions_middleware(request: Request, call_next):
|
||||
"""Middleware для подробного json-описания Internal error"""
|
||||
try:
|
||||
return await call_next(request)
|
||||
except Exception as exc:
|
||||
exc_type, exc_value, exc_tb = sys.exc_info()
|
||||
logger = get_logger()
|
||||
logger.exception(exc)
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"message": str(exc),
|
||||
"type": exc_type.__name__ if exc_type else "Unknown",
|
||||
"path": str(request.url),
|
||||
"method": request.method,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@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=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content="Internal Server Error",
|
||||
)
|
||||
|
||||
|
||||
# Подключение маршрутов
|
||||
app.include_router(api_router)
|
||||
static_path = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=static_path), name="static")
|
||||
app.mount(
|
||||
"/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,
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
log_config=LOGGING_CONFIG,
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
"""Модуль моделей"""
|
||||
from .db import *
|
||||
from .dto import *
|
||||
from .enums import *
|
||||
@@ -7,6 +7,7 @@ from .user import User
|
||||
from .links import (
|
||||
AuthorBookLink,
|
||||
GenreBookLink,
|
||||
BookUserLink,
|
||||
UserRoleLink
|
||||
)
|
||||
|
||||
@@ -18,5 +19,6 @@ __all__ = [
|
||||
"User",
|
||||
"AuthorBookLink",
|
||||
"GenreBookLink",
|
||||
"BookUserLink",
|
||||
"UserRoleLink",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль DB-моделей авторов"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
|
||||
|
||||
class Author(AuthorBase, table=True):
|
||||
"""Модель автора в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="authors", link_model=AuthorBookLink
|
||||
)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Модуль DB-моделей книг"""
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
from uuid import UUID
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import Column, String
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from library_service.models.dto.book import BookBase
|
||||
from library_service.models.db.links import AuthorBookLink, GenreBookLink
|
||||
from library_service.models.enums import BookStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .author import Author
|
||||
@@ -13,10 +18,23 @@ if TYPE_CHECKING:
|
||||
|
||||
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, description="Идентификатор"
|
||||
)
|
||||
status: BookStatus = Field(
|
||||
default=BookStatus.ACTIVE,
|
||||
sa_column=Column(String, nullable=False, default="active"),
|
||||
description="Статус",
|
||||
)
|
||||
embedding: list[float] | None = Field(sa_column=Column(Vector(1024)), description="Эмбэдинг для векторного поиска")
|
||||
preview_id: UUID | None = Field(default=None, unique=True, index=True, description="UUID файла изображения")
|
||||
authors: List["Author"] = Relationship(
|
||||
back_populates="books", link_model=AuthorBookLink
|
||||
)
|
||||
genres: List["Genre"] = Relationship(
|
||||
back_populates="books", link_model=GenreBookLink
|
||||
)
|
||||
loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference]
|
||||
sa_relationship_kwargs={"cascade": "all, delete"}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль DB-моделей жанров"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
@@ -12,7 +13,10 @@ if TYPE_CHECKING:
|
||||
|
||||
class Genre(GenreBase, table=True):
|
||||
"""Модель жанра в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
books: List["Book"] = Relationship(
|
||||
back_populates="genres", link_model=GenreBookLink
|
||||
)
|
||||
|
||||
@@ -1,24 +1,81 @@
|
||||
"""Модуль связей между сущностями в БД"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class AuthorBookLink(SQLModel, table=True):
|
||||
"""Модель связи автора и книги"""
|
||||
|
||||
author_id: int | None = Field(
|
||||
default=None, foreign_key="author.id", primary_key=True
|
||||
)
|
||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||
book_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="book.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор книги",
|
||||
)
|
||||
|
||||
|
||||
class GenreBookLink(SQLModel, table=True):
|
||||
"""Модель связи жанра и книги"""
|
||||
genre_id: int | None = Field(default=None, foreign_key="genre.id", primary_key=True)
|
||||
book_id: int | None = Field(default=None, foreign_key="book.id", primary_key=True)
|
||||
|
||||
genre_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="genre.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор жанра",
|
||||
)
|
||||
book_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="book.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор книги",
|
||||
)
|
||||
|
||||
|
||||
class UserRoleLink(SQLModel, table=True):
|
||||
"""Модель связи роли и пользователя"""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
|
||||
user_id: int | None = Field(default=None, foreign_key="users.id", primary_key=True)
|
||||
role_id: int | None = Field(default=None, foreign_key="roles.id", primary_key=True)
|
||||
user_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="users.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор пользователя",
|
||||
)
|
||||
role_id: int | None = Field(
|
||||
default=None,
|
||||
foreign_key="roles.id",
|
||||
primary_key=True,
|
||||
description="Идентификатор роли",
|
||||
)
|
||||
|
||||
|
||||
class BookUserLink(SQLModel, table=True):
|
||||
"""
|
||||
Модель истории выдачи книг (Loan).
|
||||
Связывает книгу и пользователя с фиксацией времени.
|
||||
"""
|
||||
|
||||
__tablename__ = "loans"
|
||||
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
|
||||
book_id: int = Field(foreign_key="book.id", description="Идентификатор")
|
||||
user_id: int = Field(
|
||||
foreign_key="users.id", description="Идентификатор пользователя"
|
||||
)
|
||||
|
||||
borrowed_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
description="Дата и время выдачи",
|
||||
)
|
||||
due_date: datetime = Field(description="Дата и время запланированного возврата")
|
||||
returned_at: datetime | None = Field(
|
||||
default=None, description="Дата и время фактического возврата"
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль DB-моделей ролей"""
|
||||
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
@@ -12,9 +13,11 @@ if TYPE_CHECKING:
|
||||
|
||||
class Role(RoleBase, table=True):
|
||||
"""Модель роли в базе данных"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
|
||||
# Связи
|
||||
users: List["User"] = Relationship(back_populates="roles", link_model=UserRoleLink)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Модуль DB-моделей пользователей"""
|
||||
from datetime import datetime
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlmodel import Field, Relationship
|
||||
@@ -13,16 +14,73 @@ if TYPE_CHECKING:
|
||||
|
||||
class User(UserBase, table=True):
|
||||
"""Модель пользователя в базе данных"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
hashed_password: str = Field(nullable=False)
|
||||
is_active: bool = Field(default=True)
|
||||
is_verified: bool = Field(default=False)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
id: int | None = Field(
|
||||
default=None, primary_key=True, index=True, description="Идентификатор"
|
||||
)
|
||||
hashed_password: str = Field(nullable=False, description="Argon2id хэш пароля")
|
||||
is_2fa_enabled: bool = Field(default=False, description="Включен TOTP 2FA")
|
||||
totp_secret: str | None = Field(
|
||||
default=None, max_length=80, description="Зашифрованный секрет TOTP"
|
||||
)
|
||||
recovery_code_hashes: str | None = Field(
|
||||
default=None,
|
||||
max_length=1500,
|
||||
description="Argon2id хэши одноразовыхкодов восстановления",
|
||||
)
|
||||
recovery_codes_generated_at: datetime | None = Field(
|
||||
default=None, description="Дата и время создания кодов восстановления"
|
||||
)
|
||||
is_active: bool = Field(default=True, description="Не является ли заблокированым")
|
||||
is_verified: bool = Field(default=False, description="Является ли верифицированным")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
description="Дата и время создания",
|
||||
)
|
||||
updated_at: datetime | None = Field(
|
||||
default=None, sa_column_kwargs={"onupdate": datetime.utcnow}
|
||||
default=None,
|
||||
sa_column_kwargs={"onupdate": lambda: datetime.now(timezone.utc)},
|
||||
description="Дата и время последнего обновления",
|
||||
)
|
||||
|
||||
# Связи
|
||||
roles: List["Role"] = Relationship(back_populates="users", link_model=UserRoleLink)
|
||||
loans: List["BookUserLink"] = Relationship( # ty: ignore[unresolved-reference]
|
||||
sa_relationship_kwargs={"cascade": "all, delete"}
|
||||
)
|
||||
|
||||
@property
|
||||
def recovery_codes_list(self) -> list[str]:
|
||||
"""Список хешей"""
|
||||
if not self.recovery_code_hashes:
|
||||
return []
|
||||
return self.recovery_code_hashes.split(" ")
|
||||
|
||||
@property
|
||||
def recovery_codes_total(self) -> int:
|
||||
"""Общее количество слотов"""
|
||||
if not self.recovery_code_hashes:
|
||||
return 0
|
||||
return len(self.recovery_codes_list)
|
||||
|
||||
@property
|
||||
def recovery_codes_remaining(self) -> int:
|
||||
"""Количество неиспользованных кодов"""
|
||||
return sum(1 for h in self.recovery_codes_list if h)
|
||||
|
||||
@property
|
||||
def recovery_codes_used(self) -> int:
|
||||
"""Количество использованных кодов"""
|
||||
return self.recovery_codes_total - self.recovery_codes_remaining
|
||||
|
||||
def get_recovery_code_positions(self) -> dict[str, list[int]]:
|
||||
"""Возвращает позиции использованных и оставшихся кодов"""
|
||||
used = []
|
||||
remaining = []
|
||||
for i, h in enumerate(self.recovery_codes_list, start=1):
|
||||
if h:
|
||||
remaining.append(i)
|
||||
else:
|
||||
used.append(i)
|
||||
return {"used": used, "remaining": remaining}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
"""Модуль DTO-моделей"""
|
||||
|
||||
from .author import AuthorBase, AuthorCreate, AuthorList, AuthorRead, AuthorUpdate
|
||||
from .genre import GenreBase, GenreCreate, GenreList, GenreRead, GenreUpdate
|
||||
from .book import BookBase, BookCreate, BookList, BookRead, BookUpdate
|
||||
from .role import RoleBase, RoleCreate, RoleList, RoleRead, RoleUpdate
|
||||
from .user import UserBase, UserCreate, UserLogin, UserRead, UserUpdate
|
||||
from .token import Token, TokenData
|
||||
from .combined import (AuthorWithBooks, GenreWithBooks, BookWithAuthors, BookWithGenres,
|
||||
BookWithAuthorsAndGenres, BookFilteredList)
|
||||
from .user import UserBase, UserCreate, UserList, UserRead, UserUpdate, UserLogin
|
||||
from .loan import LoanBase, LoanCreate, LoanList, LoanRead, LoanUpdate
|
||||
from .recovery import RecoveryCodesResponse, RecoveryCodesStatus, RecoveryCodeUse
|
||||
from .token import TokenData
|
||||
from .misc import (
|
||||
AuthorWithBooks,
|
||||
GenreWithBooks,
|
||||
BookWithAuthors,
|
||||
BookWithGenres,
|
||||
BookWithAuthorsAndGenres,
|
||||
BookFilteredList,
|
||||
BookStatusUpdate,
|
||||
LoanWithBook,
|
||||
LoginResponse,
|
||||
RegisterResponse,
|
||||
UserCreateByAdmin,
|
||||
UserUpdateByAdmin,
|
||||
TOTPSetupResponse,
|
||||
TOTPVerifyRequest,
|
||||
TOTPDisableRequest,
|
||||
PasswordResetResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AuthorBase",
|
||||
@@ -20,21 +39,39 @@ __all__ = [
|
||||
"BookRead",
|
||||
"BookList",
|
||||
"BookFilteredList",
|
||||
"BookStatusUpdate",
|
||||
"GenreBase",
|
||||
"GenreCreate",
|
||||
"GenreUpdate",
|
||||
"GenreRead",
|
||||
"GenreList",
|
||||
"LoanBase",
|
||||
"LoanCreate",
|
||||
"LoanUpdate",
|
||||
"LoanRead",
|
||||
"LoanList",
|
||||
"LoanWithBook",
|
||||
"UserBase",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserRead",
|
||||
"UserList",
|
||||
"UserLogin",
|
||||
"RoleBase",
|
||||
"RoleCreate",
|
||||
"RoleUpdate",
|
||||
"RoleRead",
|
||||
"RoleList",
|
||||
"Token",
|
||||
"TokenData",
|
||||
"UserBase",
|
||||
"UserCreate",
|
||||
"UserRead",
|
||||
"UserUpdate",
|
||||
"UserLogin",
|
||||
"TOTPSetupResponse",
|
||||
"TOTPVerifyRequest",
|
||||
"TOTPDisableRequest",
|
||||
"RecoveryCodeUse",
|
||||
"UserCreateByAdmin",
|
||||
"UserUpdateByAdmin",
|
||||
"LoginResponse",
|
||||
"RegisterResponse",
|
||||
"RecoveryCodesStatus",
|
||||
"PasswordResetResponse",
|
||||
"RecoveryCodesResponse",
|
||||
]
|
||||
|
||||
@@ -1,35 +1,41 @@
|
||||
"""Модуль DTO-моделей авторов"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class AuthorBase(SQLModel):
|
||||
"""Базовая модель автора"""
|
||||
name: str
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={"example": {"name": "author_name"}}
|
||||
name: str = Field(description="Псевдоним")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={"example": {"name": "John Doe"}}
|
||||
)
|
||||
|
||||
|
||||
class AuthorCreate(AuthorBase):
|
||||
"""Модель автора для создания"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AuthorUpdate(SQLModel):
|
||||
"""Модель автора для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
name: str | None = Field(None, description="Псевдоним")
|
||||
|
||||
|
||||
class AuthorRead(AuthorBase):
|
||||
"""Модель автора для чтения"""
|
||||
id: int
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
|
||||
|
||||
class AuthorList(SQLModel):
|
||||
"""Список авторов"""
|
||||
authors: List[AuthorRead]
|
||||
total: int
|
||||
|
||||
authors: List[AuthorRead] = Field(description="Список авторов")
|
||||
total: int = Field(description="Количество авторов")
|
||||
|
||||
@@ -1,42 +1,56 @@
|
||||
"""Модуль DTO-моделей книг"""
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combined import BookWithAuthorsAndGenres
|
||||
from library_service.models.enums import BookStatus
|
||||
|
||||
|
||||
class BookBase(SQLModel):
|
||||
"""Базовая модель книги"""
|
||||
title: str
|
||||
description: str
|
||||
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(gt=0, description="Количество страниц")
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={
|
||||
"example": {"title": "book_title", "description": "book_description"}
|
||||
"example": {
|
||||
"title": "book_title",
|
||||
"description": "book_description",
|
||||
"page_count": 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BookCreate(BookBase):
|
||||
"""Модель книги для создания"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class BookUpdate(SQLModel):
|
||||
"""Модель книги для обновления"""
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
title: str | None = Field(None, description="Название")
|
||||
description: str | None = Field(None, description="Описание")
|
||||
page_count: int | None = Field(None, description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
|
||||
|
||||
class BookRead(BookBase):
|
||||
"""Модель книги для чтения"""
|
||||
id: int
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
status: BookStatus = Field(description="Статус")
|
||||
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
|
||||
|
||||
|
||||
class BookList(SQLModel):
|
||||
"""Список книг"""
|
||||
books: List[BookRead]
|
||||
total: int
|
||||
|
||||
books: List[BookRead] = Field(description="Список книг")
|
||||
total: int = Field(description="Количество книг")
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Модуль объединёных объектов"""
|
||||
from typing import List
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
from .author import AuthorRead
|
||||
from .genre import GenreRead
|
||||
from .book import BookRead
|
||||
|
||||
|
||||
class AuthorWithBooks(SQLModel):
|
||||
"""Модель автора с книгами"""
|
||||
id: int
|
||||
name: str
|
||||
bio: str
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class GenreWithBooks(SQLModel):
|
||||
"""Модель жанра с книгами"""
|
||||
id: int
|
||||
name: str
|
||||
books: List[BookRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithAuthors(SQLModel):
|
||||
"""Модель книги с авторами"""
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithGenres(SQLModel):
|
||||
"""Модель книги с жанрами"""
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookWithAuthorsAndGenres(SQLModel):
|
||||
"""Модель с авторами и жанрами"""
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BookFilteredList(SQLModel):
|
||||
"""Список книг с фильтрацией"""
|
||||
books: List[BookWithAuthorsAndGenres]
|
||||
total: int
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Модуль DTO-моделей жанров"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import ConfigDict
|
||||
from sqlmodel import SQLModel
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class GenreBase(SQLModel):
|
||||
"""Базовая модель жанра"""
|
||||
name: str
|
||||
|
||||
name: str = Field(description="Название")
|
||||
|
||||
model_config = ConfigDict( # pyright: ignore
|
||||
json_schema_extra={"example": {"name": "genre_name"}}
|
||||
@@ -16,20 +18,24 @@ class GenreBase(SQLModel):
|
||||
|
||||
class GenreCreate(GenreBase):
|
||||
"""Модель жанра для создания"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class GenreUpdate(SQLModel):
|
||||
"""Модель жанра для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
name: str | None = Field(None, description="Название")
|
||||
|
||||
|
||||
class GenreRead(GenreBase):
|
||||
"""Модель жанра для чтения"""
|
||||
id: int
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
|
||||
|
||||
class GenreList(SQLModel):
|
||||
"""Списко жанров"""
|
||||
genres: List[GenreRead]
|
||||
total: int
|
||||
|
||||
genres: List[GenreRead] = Field(description="Список жанров")
|
||||
total: int = Field(description="Количество жанров")
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Модуль DTO-моделей для выдачи книг"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from datetime import datetime
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class LoanBase(SQLModel):
|
||||
"""Базовая модель выдачи"""
|
||||
|
||||
book_id: int = Field(description="Идентификатор книги")
|
||||
user_id: int = Field(description="Идентификатор пользователя")
|
||||
due_date: datetime = Field(description="Дата и время планируемого возврата")
|
||||
|
||||
|
||||
class LoanCreate(LoanBase):
|
||||
"""Модель для создания записи о выдаче"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class LoanUpdate(SQLModel):
|
||||
"""Модель для обновления записи о выдаче"""
|
||||
|
||||
user_id: int | None = Field(None, description="Идентификатор пользователя")
|
||||
due_date: datetime | None = Field(
|
||||
None, description="дата и время планируемого возврата"
|
||||
)
|
||||
returned_at: datetime | None = Field(
|
||||
None, description="Дата и время фактического возврата"
|
||||
)
|
||||
|
||||
|
||||
class LoanRead(LoanBase):
|
||||
"""Модель чтения записи о выдаче"""
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
borrowed_at: datetime = Field(description="Дата и время выдачи")
|
||||
returned_at: datetime | None = Field(
|
||||
None, description="Дата и время фактического возврата"
|
||||
)
|
||||
|
||||
|
||||
class LoanList(SQLModel):
|
||||
"""Список выдач"""
|
||||
|
||||
loans: List[LoanRead] = Field(description="Список выдач")
|
||||
total: int = Field(description="Количество выдач")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Модуль разных моделей"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
from .author import AuthorRead
|
||||
from .genre import GenreRead
|
||||
from .book import BookRead
|
||||
from .loan import LoanRead
|
||||
from ..enums import BookStatus
|
||||
|
||||
from .user import UserCreate, UserRead, UserUpdate
|
||||
from .recovery import RecoveryCodesResponse
|
||||
|
||||
|
||||
class AuthorWithBooks(SQLModel):
|
||||
"""Модель автора с книгами"""
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
name: str = Field(description="Псевдоним")
|
||||
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||
|
||||
|
||||
class GenreWithBooks(SQLModel):
|
||||
"""Модель жанра с книгами"""
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
name: str = Field(description="Название")
|
||||
books: List[BookRead] = Field(default_factory=list, description="Список книг")
|
||||
|
||||
|
||||
class BookWithAuthors(SQLModel):
|
||||
"""Модель книги с авторами"""
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
preview_urls: dict[str, str] = Field(default_factory=dict, description="URL изображений")
|
||||
authors: List[AuthorRead] = Field(
|
||||
default_factory=list, description="Список авторов"
|
||||
)
|
||||
|
||||
|
||||
class BookWithGenres(SQLModel):
|
||||
"""Модель книги с жанрами"""
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
|
||||
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
|
||||
|
||||
|
||||
class BookWithAuthorsAndGenres(SQLModel):
|
||||
"""Модель с авторами и жанрами"""
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
title: str = Field(description="Название")
|
||||
description: str = Field(description="Описание")
|
||||
page_count: int = Field(description="Количество страниц")
|
||||
status: BookStatus | None = Field(None, description="Статус")
|
||||
preview_urls: dict[str, str] | None = Field(default=None, description="URL изображений")
|
||||
authors: List[AuthorRead] = Field(
|
||||
default_factory=list, description="Список авторов"
|
||||
)
|
||||
genres: List[GenreRead] = Field(default_factory=list, description="Список жанров")
|
||||
|
||||
|
||||
class BookFilteredList(SQLModel):
|
||||
"""Список книг с фильтрацией"""
|
||||
|
||||
books: List[BookWithAuthorsAndGenres] = Field(
|
||||
description="Список отфильтрованных книг"
|
||||
)
|
||||
total: int = Field(description="Количество книг")
|
||||
|
||||
|
||||
class LoanWithBook(LoanRead):
|
||||
"""Модель выдачи, включающая данные о книге"""
|
||||
|
||||
book: BookRead = Field(description="Книга")
|
||||
|
||||
|
||||
class BookStatusUpdate(SQLModel):
|
||||
"""Модель для ручного изменения статуса библиотекарем"""
|
||||
|
||||
status: str = Field(description="Статус книги")
|
||||
|
||||
|
||||
class UserCreateByAdmin(UserCreate):
|
||||
"""Создание пользователя администратором"""
|
||||
|
||||
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||
roles: list[str] | None = Field(None, description="Роли")
|
||||
|
||||
|
||||
class UserUpdateByAdmin(UserUpdate):
|
||||
"""Обновление пользователя администратором"""
|
||||
|
||||
is_active: bool = Field(True, description="Не является ли заблокированным")
|
||||
roles: list[str] | None = Field(None, description="Роли")
|
||||
|
||||
|
||||
class LoginResponse(SQLModel):
|
||||
"""Модель для авторизации пользователя"""
|
||||
|
||||
access_token: str | None = Field(None, description="Токен доступа")
|
||||
partial_token: str | None = Field(None, description="Частичный токен")
|
||||
refresh_token: str | None = Field(None, description="Токен обновления")
|
||||
token_type: str = Field("bearer", description="Тип токена")
|
||||
requires_2fa: bool = Field(False, description="Требуется ли TOTP=код")
|
||||
|
||||
|
||||
class RegisterResponse(SQLModel):
|
||||
"""Модель для регистрации пользователя"""
|
||||
|
||||
user: UserRead = Field(description="Пользователь")
|
||||
recovery_codes: RecoveryCodesResponse = Field(description="Коды восстановления")
|
||||
|
||||
|
||||
class PasswordResetResponse(SQLModel):
|
||||
"""Модель для сброса пароля"""
|
||||
|
||||
total: int = Field(description="Общее количество кодов")
|
||||
remaining: int = Field(description="Количество оставшихся кодов")
|
||||
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||
|
||||
|
||||
class TOTPSetupResponse(SQLModel):
|
||||
"""Модель для генерации данных для настройки TOTP"""
|
||||
|
||||
secret: str = Field(description="Секрет TOTP")
|
||||
username: str = Field(description="Имя пользователя")
|
||||
issuer: str = Field(description="Запрашивающий сервис")
|
||||
size: int = Field(description="Размер кода")
|
||||
padding: int = Field(description="Отступ")
|
||||
bitmap_b64: str = Field(description="QR-код")
|
||||
|
||||
|
||||
class TOTPVerifyRequest(SQLModel):
|
||||
"""Модель для проверки TOTP кода"""
|
||||
|
||||
code: str = Field(
|
||||
min_length=6,
|
||||
max_length=6,
|
||||
regex=r"^\d{6}$",
|
||||
description="Шестизначный TOTP-код",
|
||||
)
|
||||
|
||||
|
||||
class TOTPDisableRequest(SQLModel):
|
||||
"""Модель для отключения TOTP 2FA"""
|
||||
|
||||
password: str = Field(description="Пароль")
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Модуль DTO-моделей для резервных кодов восстановления"""
|
||||
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from pydantic import field_validator
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class RecoveryCodesResponse(SQLModel):
|
||||
"""Ответ при генерации резервных кодов"""
|
||||
|
||||
codes: list[str] = Field(description="Список кодов восстановления")
|
||||
generated_at: datetime = Field(description="Дата и время генерации")
|
||||
|
||||
|
||||
class RecoveryCodesStatus(SQLModel):
|
||||
"""Статус резервных кодов пользователя"""
|
||||
|
||||
total: int = Field(description="Общее количество кодов")
|
||||
remaining: int = Field(description="Количество оставшихся кодов")
|
||||
used_codes: list[bool] = Field(description="Количество использованых кодов")
|
||||
generated_at: datetime | None = Field(description="Дата и время генерации")
|
||||
should_regenerate: bool = Field(description="Нужно ли пересоздать коды")
|
||||
|
||||
|
||||
class RecoveryCodeUse(SQLModel):
|
||||
"""Запрос на сброс пароля через резервный код"""
|
||||
|
||||
username: str = Field(description="Имя пользователя")
|
||||
recovery_code: str = Field(
|
||||
min_length=19, max_length=19, description="Код восстановления"
|
||||
)
|
||||
new_password: str = Field(min_length=8, max_length=100, description="Новый пароль")
|
||||
|
||||
@field_validator("recovery_code")
|
||||
@classmethod
|
||||
def validate_recovery_code(cls, v: str) -> str:
|
||||
if not re.match(
|
||||
r"^[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}$", v
|
||||
):
|
||||
raise ValueError("Invalid recovery code format")
|
||||
return v.lower()
|
||||
|
||||
@field_validator("new_password")
|
||||
@classmethod
|
||||
def validate_password(cls, v: str) -> str:
|
||||
if not re.search(r"[A-Z]", v):
|
||||
raise ValueError("Password must contain uppercase")
|
||||
if not re.search(r"[a-z]", v):
|
||||
raise ValueError("Password must contain lowercase")
|
||||
if not re.search(r"\d", v):
|
||||
raise ValueError("Password must contain digit")
|
||||
return v
|
||||
@@ -1,31 +1,49 @@
|
||||
"""Модуль DTO-моделей ролей"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
from pydantic import ConfigDict
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class RoleBase(SQLModel):
|
||||
"""Базовая модель роли"""
|
||||
name: str
|
||||
description: str | None = None
|
||||
|
||||
name: str = Field(description="Название")
|
||||
description: str | None = Field(None, description="Описание")
|
||||
payroll: int = Field(0, description="Оплата")
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "admin",
|
||||
"description": "system administrator",
|
||||
"payroll": 500,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
"""Модель роли для создания"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RoleUpdate(SQLModel):
|
||||
"""Модель роли для обновления"""
|
||||
name: str | None = None
|
||||
|
||||
name: str | None = Field(None, description="Название")
|
||||
|
||||
|
||||
class RoleRead(RoleBase):
|
||||
"""Модель роли для чтения"""
|
||||
id: int
|
||||
|
||||
id: int = Field(description="Идентификатор")
|
||||
|
||||
|
||||
class RoleList(SQLModel):
|
||||
"""Список ролей"""
|
||||
roles: List[RoleRead]
|
||||
total: int
|
||||
|
||||
roles: List[RoleRead] = Field(description="Список ролей")
|
||||
total: int = Field(description="Количество ролей")
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
"""Модуль DTO-моделей токенов"""
|
||||
from sqlmodel import SQLModel
|
||||
"""Модуль DTO-модели токена"""
|
||||
|
||||
|
||||
class Token(SQLModel):
|
||||
"""Модель токена"""
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
refresh_token: str | None = None
|
||||
from sqlmodel import SQLModel, Field
|
||||
|
||||
|
||||
class TokenData(SQLModel):
|
||||
"""Модель содержимого токена"""
|
||||
username: str | None = None
|
||||
user_id: int | None = None
|
||||
|
||||
username: str | None = Field(None, description="Имя пользователя")
|
||||
user_id: int | None = Field(None, description="Идентификатор пользователя")
|
||||
is_partial: bool = Field(False, description="Является ли токен частичным")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Модуль DTO-моделей пользователей"""
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
@@ -8,9 +9,18 @@ from sqlmodel import Field, SQLModel
|
||||
|
||||
class UserBase(SQLModel):
|
||||
"""Базовая модель пользователя"""
|
||||
username: str = Field(min_length=3, max_length=50, index=True, unique=True)
|
||||
email: EmailStr = Field(index=True, unique=True)
|
||||
full_name: str | None = Field(default=None, max_length=100)
|
||||
|
||||
username: str = Field(
|
||||
min_length=3,
|
||||
max_length=50,
|
||||
index=True,
|
||||
unique=True,
|
||||
description="Имя пользователя",
|
||||
)
|
||||
email: EmailStr = Field(index=True, unique=True, description="Email")
|
||||
full_name: str | None = Field(
|
||||
default=None, max_length=100, description="Полное имя"
|
||||
)
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
@@ -25,7 +35,8 @@ class UserBase(SQLModel):
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""Модель пользователя для создания"""
|
||||
password: str = Field(min_length=8, max_length=100)
|
||||
|
||||
password: str = Field(min_length=8, max_length=100, description="Пароль")
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
@@ -42,20 +53,31 @@ class UserCreate(UserBase):
|
||||
|
||||
class UserLogin(SQLModel):
|
||||
"""Модель аутентификации для пользователя"""
|
||||
username: str
|
||||
password: str
|
||||
|
||||
username: str = Field(description="Имя пользователя")
|
||||
password: str = Field(description="Пароль")
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
"""Модель пользователя для чтения"""
|
||||
|
||||
id: int
|
||||
is_active: bool
|
||||
is_verified: bool
|
||||
roles: List[str] = []
|
||||
is_active: bool = Field(description="Не является ли заблокированым")
|
||||
is_verified: bool = Field(description="Является ли верифицированым")
|
||||
is_2fa_enabled: bool = Field(description="Включен ли TOTP 2FA")
|
||||
roles: List[str] = Field([], description="Роли")
|
||||
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
"""Модель пользователя для обновления"""
|
||||
email: EmailStr | None = None
|
||||
full_name: str | None = None
|
||||
password: str | None = None
|
||||
|
||||
email: EmailStr | None = Field(None, description="Email")
|
||||
full_name: str | None = Field(None, description="Полное имя")
|
||||
password: str | None = Field(None, description="Пароль")
|
||||
|
||||
|
||||
class UserList(SQLModel):
|
||||
"""Список пользователей"""
|
||||
|
||||
users: List[UserRead] = Field(description="Список пользователей")
|
||||
total: int = Field(description="Количество пользователей")
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Модуль перечислений (Enums)"""
|
||||
from enum import Enum
|
||||
|
||||
class BookStatus(str, Enum):
|
||||
"""Статусы книги"""
|
||||
ACTIVE = "active"
|
||||
BORROWED = "borrowed"
|
||||
RESERVED = "reserved"
|
||||
RESTORATION = "restoration"
|
||||
WRITTEN_OFF = "written_off"
|
||||
@@ -1,19 +1,28 @@
|
||||
"""Модуль объединения роутеров"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .authors import router as authors_router
|
||||
from .books import router as books_router
|
||||
from .genres import router as genres_router
|
||||
from .loans import router as loans_router
|
||||
from .relationships import router as relationships_router
|
||||
from .cap import router as cap_router
|
||||
from .users import router as users_router
|
||||
from .misc import router as misc_router
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
|
||||
# Подключение всех маршрутов
|
||||
api_router.include_router(misc_router)
|
||||
api_router.include_router(auth_router, prefix="/api")
|
||||
api_router.include_router(authors_router, prefix="/api")
|
||||
api_router.include_router(books_router, prefix="/api")
|
||||
api_router.include_router(genres_router, prefix="/api")
|
||||
api_router.include_router(loans_router, prefix="/api")
|
||||
api_router.include_router(cap_router, prefix="/api")
|
||||
api_router.include_router(users_router, prefix="/api")
|
||||
api_router.include_router(relationships_router, prefix="/api")
|
||||
|
||||
+344
-46
@@ -1,31 +1,75 @@
|
||||
"""Модуль работы с авторизацией и аутентификацией пользователей"""
|
||||
|
||||
import base64
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.services import require_captcha
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate
|
||||
from library_service.models.dto import (
|
||||
UserCreate,
|
||||
UserRead,
|
||||
UserUpdate,
|
||||
UserList,
|
||||
RoleRead,
|
||||
RoleList,
|
||||
LoginResponse,
|
||||
RecoveryCodeUse,
|
||||
RegisterResponse,
|
||||
RecoveryCodesStatus,
|
||||
RecoveryCodesResponse,
|
||||
PasswordResetResponse,
|
||||
TOTPSetupResponse,
|
||||
TOTPVerifyRequest,
|
||||
TOTPDisableRequest,
|
||||
)
|
||||
|
||||
from library_service.settings import get_session
|
||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
|
||||
RequireAuth, authenticate_user, get_password_hash,
|
||||
create_access_token, create_refresh_token)
|
||||
from library_service.auth import (
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
RequireAuth,
|
||||
RequireAdmin,
|
||||
RequireStaff,
|
||||
authenticate_user,
|
||||
get_password_hash,
|
||||
decode_token,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
generate_totp_setup,
|
||||
generate_codes_for_user,
|
||||
verify_and_use_code,
|
||||
get_codes_status,
|
||||
verify_totp_code,
|
||||
verify_password,
|
||||
qr_to_bitmap_b64,
|
||||
create_partial_token,
|
||||
RequirePartialAuth,
|
||||
verify_and_use_code,
|
||||
cipher,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=UserRead,
|
||||
response_model=RegisterResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Регистрация нового пользователя",
|
||||
description="Создает нового пользователя в системе",
|
||||
description="Создает нового пользователя и возвращает резервные коды",
|
||||
)
|
||||
def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт регистрации пользователя"""
|
||||
# Проверка если username существует
|
||||
def register(
|
||||
user_data: UserCreate,
|
||||
_=Depends(require_captcha),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Регистрирует нового пользователя в системе"""
|
||||
existing_user = session.exec(
|
||||
select(User).where(User.username == user_data.username)
|
||||
).first()
|
||||
@@ -35,23 +79,21 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
# Проверка если email существует
|
||||
existing_email = session.exec(
|
||||
select(User).where(User.email == user_data.email)
|
||||
).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered"
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
# Создание пользователя
|
||||
db_user = User(
|
||||
**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:
|
||||
db_user.roles.append(default_role)
|
||||
|
||||
@@ -59,20 +101,31 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
|
||||
return UserRead(**db_user.model_dump(), roles=[role.name for role in db_user.roles])
|
||||
recovery_codes = generate_codes_for_user(session, db_user)
|
||||
|
||||
return RegisterResponse(
|
||||
user=UserRead(
|
||||
**db_user.model_dump(),
|
||||
roles=[role.name for role in db_user.roles],
|
||||
),
|
||||
recovery_codes=RecoveryCodesResponse(
|
||||
codes=recovery_codes,
|
||||
generated_at=db_user.recovery_codes_generated_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/token",
|
||||
response_model=Token,
|
||||
response_model=LoginResponse,
|
||||
summary="Получение токена",
|
||||
description="Аутентификация и получение JWT токена",
|
||||
description="Аутентификация и получение токенов",
|
||||
)
|
||||
def login(
|
||||
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт аутентификации и получения JWT токена"""
|
||||
"""Аутентифицирует пользователя и возвращает JWT токены"""
|
||||
user = authenticate_user(session, form_data.username, form_data.password)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
@@ -81,17 +134,74 @@ def login(
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user.username, "user_id": user.id},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
data={"sub": user.username, "user_id": user.id}
|
||||
token_data = {"sub": user.username, "user_id": user.id}
|
||||
|
||||
if user.is_2fa_enabled:
|
||||
return LoginResponse(
|
||||
partial_token=create_partial_token(token_data),
|
||||
token_type="partial",
|
||||
requires_2fa=True,
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token, refresh_token=refresh_token, token_type="bearer"
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
new_access_token = create_access_token(
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data=token_data)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
requires_2fa=False,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=LoginResponse,
|
||||
summary="Обновление токена",
|
||||
description="Получение новой пары токенов, используя действующий 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",
|
||||
)
|
||||
|
||||
token_data = {"sub": user.username, "user_id": user.id}
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
new_access_token = create_access_token(
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data=token_data)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
requires_2fa=False,
|
||||
)
|
||||
|
||||
|
||||
@@ -101,8 +211,8 @@ def login(
|
||||
summary="Текущий пользователь",
|
||||
description="Получить информацию о текущем авторизованном пользователе",
|
||||
)
|
||||
def read_users_me(current_user: RequireAuth):
|
||||
"""Эндпоинт получения информации о себе"""
|
||||
def get_my_profile(current_user: RequireAuth):
|
||||
"""Возвращает информацию о текущем пользователе"""
|
||||
return UserRead(
|
||||
**current_user.model_dump(), roles=[role.name for role in current_user.roles]
|
||||
)
|
||||
@@ -119,7 +229,7 @@ def update_user_me(
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления пользователя"""
|
||||
"""Обновляет профиль текущего пользователя"""
|
||||
if user_update.email:
|
||||
current_user.email = user_update.email
|
||||
if user_update.full_name:
|
||||
@@ -137,20 +247,208 @@ def update_user_me(
|
||||
|
||||
|
||||
@router.get(
|
||||
"/users",
|
||||
response_model=list[UserRead],
|
||||
summary="Список пользователей",
|
||||
description="Получить список всех пользователей (только для админов)",
|
||||
"/2fa",
|
||||
response_model=TOTPSetupResponse,
|
||||
summary="Создание QR-кода TOTP 2FA",
|
||||
description="Генерирует секрет и QR-код для настройки TOTP",
|
||||
)
|
||||
def read_users(
|
||||
admin: RequireAdmin,
|
||||
def get_totp_qr_bitmap(
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
):
|
||||
"""Эндпоинт получения списка всех пользователей"""
|
||||
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
||||
return [
|
||||
UserRead(**user.model_dump(), roles=[role.name for role in user.roles])
|
||||
for user in users
|
||||
]
|
||||
"""Возвращает данные для настройки TOTP"""
|
||||
totp_data = generate_totp_setup(current_user.username)
|
||||
encrypted = cipher.encrypt(totp_data["secret"].encode())
|
||||
|
||||
current_user.totp_secret = base64.b64encode(encrypted).decode()
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
return TOTPSetupResponse(**totp_data)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/2fa/enable",
|
||||
summary="Включение TOTP 2FA",
|
||||
description="Подтверждает настройку и включает 2FA",
|
||||
)
|
||||
def enable_2fa(
|
||||
data: TOTPVerifyRequest,
|
||||
current_user: RequireAuth,
|
||||
secret: str = Body(..., embed=True),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Включает 2FA после проверки кода"""
|
||||
if current_user.is_2fa_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA already enabled",
|
||||
)
|
||||
|
||||
if not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Secret key not generated"
|
||||
)
|
||||
|
||||
if not verify_totp_code(secret, data.code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid TOTP code",
|
||||
)
|
||||
|
||||
decrypted = cipher.decrypt(base64.b64decode(current_user.totp_secret.encode()))
|
||||
if secret != decrypted.decode():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Incorret secret"
|
||||
)
|
||||
|
||||
current_user.is_2fa_enabled = True
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/2fa/disable",
|
||||
summary="Отключение TOTP 2FA",
|
||||
description="Отключает 2FA после проверки пароля и кода",
|
||||
)
|
||||
def disable_2fa(
|
||||
data: TOTPDisableRequest,
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Отключает 2FA"""
|
||||
if not current_user.is_2fa_enabled or not current_user.totp_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="2FA not enabled",
|
||||
)
|
||||
|
||||
if not verify_password(data.password, current_user.hashed_password):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid password",
|
||||
)
|
||||
|
||||
current_user.totp_secret = None
|
||||
current_user.is_2fa_enabled = False
|
||||
session.add(current_user)
|
||||
session.commit()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/2fa/verify",
|
||||
response_model=LoginResponse,
|
||||
summary="Верификация 2FA",
|
||||
description="Завершает аутентификацию с помощью TOTP кода или резервного кода",
|
||||
)
|
||||
def verify_2fa(
|
||||
data: TOTPVerifyRequest,
|
||||
user: RequirePartialAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Верифицирует 2FA и возвращает полный токен"""
|
||||
if not data.code:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Provide TOTP code",
|
||||
)
|
||||
|
||||
verified = False
|
||||
|
||||
if data.code and user.totp_secret:
|
||||
decrypted = cipher.decrypt(base64.b64decode(user.totp_secret.encode()))
|
||||
if verify_totp_code(decrypted.decode(), data.code):
|
||||
verified = True
|
||||
|
||||
if not verified:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid 2FA code",
|
||||
)
|
||||
|
||||
token_data = {"sub": user.username, "user_id": user.id}
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
new_access_token = create_access_token(
|
||||
data=token_data, expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data=token_data)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
requires_2fa=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/recovery-codes/status",
|
||||
response_model=RecoveryCodesStatus,
|
||||
summary="Статус резервных кодов",
|
||||
description="Показывает количество оставшихся кодов и какие использованы",
|
||||
)
|
||||
def get_recovery_codes_status(current_user: RequireAuth):
|
||||
"""Возвращает статус резервных кодов"""
|
||||
return RecoveryCodesStatus(**get_codes_status(current_user))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/recovery-codes/regenerate",
|
||||
response_model=RecoveryCodesResponse,
|
||||
summary="Перегенерация резервных кодов",
|
||||
description="Генерирует новые коды, старые аннулируются",
|
||||
)
|
||||
def regenerate_recovery_codes(
|
||||
current_user: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Генерирует новые резервные коды"""
|
||||
codes = generate_codes_for_user(session, current_user)
|
||||
|
||||
return RecoveryCodesResponse(
|
||||
codes=codes,
|
||||
generated_at=current_user.recovery_codes_generated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/password/reset",
|
||||
response_model=PasswordResetResponse,
|
||||
summary="Сброс пароля через резервный код",
|
||||
description="Устанавливает новый пароль используя резервный код",
|
||||
)
|
||||
def reset_password(
|
||||
data: RecoveryCodeUse,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Сброс пароля с использованием резервного кода"""
|
||||
user = session.exec(select(User).where(User.username == data.username)).first()
|
||||
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid username or recovery code",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account is deactivated",
|
||||
)
|
||||
|
||||
if not verify_and_use_code(session, user, data.recovery_code):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid username or recovery code",
|
||||
)
|
||||
|
||||
user.hashed_password = get_password_hash(data.new_password)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
return PasswordResetResponse(**get_codes_status(user))
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
"""Модуль работы с авторами"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, AuthorBookLink, Book
|
||||
from library_service.models.dto import (BookRead, AuthorWithBooks,
|
||||
AuthorCreate, AuthorList, AuthorRead, AuthorUpdate)
|
||||
from library_service.models.dto import (
|
||||
BookRead,
|
||||
AuthorWithBooks,
|
||||
AuthorCreate,
|
||||
AuthorList,
|
||||
AuthorRead,
|
||||
AuthorUpdate,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
|
||||
@@ -18,11 +26,11 @@ router = APIRouter(prefix="/authors", tags=["authors"])
|
||||
description="Добавляет автора в систему",
|
||||
)
|
||||
def create_author(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author: AuthorCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт создания автора"""
|
||||
"""Создает нового автора в системе"""
|
||||
db_author = Author(**author.model_dump())
|
||||
session.add(db_author)
|
||||
session.commit()
|
||||
@@ -37,7 +45,7 @@ def create_author(
|
||||
description="Возвращает список всех авторов в системе",
|
||||
)
|
||||
def read_authors(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт чтения списка авторов"""
|
||||
"""Возвращает список всех авторов"""
|
||||
authors = session.exec(select(Author)).all()
|
||||
return AuthorList(
|
||||
authors=[AuthorRead(**author.model_dump()) for author in authors],
|
||||
@@ -55,10 +63,12 @@ def get_author(
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт чтения конкретного автора"""
|
||||
"""Возвращает информацию об авторе и его книгах"""
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
|
||||
)
|
||||
|
||||
books = session.exec(
|
||||
select(Book).join(AuthorBookLink).where(AuthorBookLink.author_id == author_id)
|
||||
@@ -79,15 +89,17 @@ def get_author(
|
||||
description="Обновляет информацию об авторе в системе",
|
||||
)
|
||||
def update_author(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author: AuthorUpdate,
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления автора"""
|
||||
"""Обновляет информацию об авторе"""
|
||||
db_author = session.get(Author, author_id)
|
||||
if not db_author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
|
||||
)
|
||||
|
||||
update_data = author.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
@@ -105,14 +117,16 @@ def update_author(
|
||||
description="Удаляет автора из системы",
|
||||
)
|
||||
def delete_author(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author_id: int = Path(..., description="ID автора (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления автора"""
|
||||
"""Удаляет автора из системы"""
|
||||
author = session.get(Author, author_id)
|
||||
if not author:
|
||||
raise HTTPException(status_code=404, detail="Author not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Author not found"
|
||||
)
|
||||
|
||||
author_read = AuthorRead(**author.model_dump())
|
||||
session.delete(author)
|
||||
|
||||
@@ -1,85 +1,151 @@
|
||||
"""Модуль работы с книгами"""
|
||||
from library_service.services import transcode_image
|
||||
import shutil
|
||||
from uuid import uuid4
|
||||
|
||||
from pydantic import Field
|
||||
from typing_extensions import Annotated
|
||||
|
||||
from sqlalchemy.orm import selectinload, defer
|
||||
|
||||
from sqlalchemy import text, case, distinct
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, Query, status, UploadFile, File
|
||||
from ollama import Client
|
||||
from sqlmodel import Session, select, col, func
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.settings import get_session
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, GenreBookLink
|
||||
from library_service.models.dto import AuthorRead, BookCreate, BookList, BookRead, BookUpdate, GenreRead
|
||||
from library_service.models.dto.combined import (
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.settings import get_session, OLLAMA_URL, BOOKS_PREVIEW_DIR
|
||||
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.misc import (
|
||||
BookWithAuthorsAndGenres,
|
||||
BookFilteredList
|
||||
BookFilteredList,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/books", tags=["books"])
|
||||
ollama_client = Client(host=OLLAMA_URL)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/filter",
|
||||
response_model=BookFilteredList,
|
||||
summary="Фильтрация книг",
|
||||
description="Фильтрация списка книг по названию, авторам и жанрам с пагинацией"
|
||||
)
|
||||
def close_active_loan(session: Session, book_id: int) -> None:
|
||||
"""Закрывает активную выдачу книги при изменении статуса"""
|
||||
active_loan = session.exec(
|
||||
select(BookUserLink)
|
||||
.where(BookUserLink.book_id == book_id) # ty: ignore
|
||||
.where(BookUserLink.returned_at == None) # ty: ignore
|
||||
).first() # ty: ignore
|
||||
|
||||
if active_loan:
|
||||
active_loan.returned_at = datetime.now(timezone.utc)
|
||||
session.add(active_loan)
|
||||
|
||||
|
||||
from sqlalchemy import select, func, distinct, case, exists
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
|
||||
@router.get("/filter", response_model=BookFilteredList)
|
||||
def filter_books(
|
||||
session: Session = Depends(get_session),
|
||||
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
|
||||
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
||||
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||
size: int = Query(20, gt=0, lt=101, description="Количество элементов на странице"),
|
||||
q: str | None = Query(None, max_length=50, description="Поиск"),
|
||||
min_page_count: int | None = Query(None, ge=0),
|
||||
max_page_count: int | None = Query(None, ge=0),
|
||||
author_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None),
|
||||
genre_ids: List[Annotated[int, Field(gt=0)]] | None = Query(None),
|
||||
page: int = Query(1, gt=0),
|
||||
size: int = Query(20, gt=0, le=100),
|
||||
):
|
||||
"""Эндпоинт получения отфильтрованного списка книг"""
|
||||
statement = select(Book).distinct()
|
||||
|
||||
if q:
|
||||
statement = statement.where(
|
||||
(col(Book.title).ilike(f"%{q}%")) | (col(Book.description).ilike(f"%{q}%"))
|
||||
statement = select(Book).options(
|
||||
selectinload(Book.authors), selectinload(Book.genres), defer(Book.embedding) # ty: ignore
|
||||
)
|
||||
|
||||
if min_page_count:
|
||||
statement = statement.where(Book.page_count >= min_page_count) # ty: ignore
|
||||
if max_page_count:
|
||||
statement = statement.where(Book.page_count <= max_page_count) # ty: ignore
|
||||
|
||||
if author_ids:
|
||||
statement = statement.join(AuthorBookLink).where(AuthorBookLink.author_id.in_(author_ids))
|
||||
statement = statement.where(
|
||||
exists().where(
|
||||
AuthorBookLink.book_id == Book.id, # ty: ignore
|
||||
AuthorBookLink.author_id.in_(author_ids), # ty: ignore
|
||||
)
|
||||
)
|
||||
|
||||
if genre_ids:
|
||||
statement = statement.join(GenreBookLink).where(GenreBookLink.genre_id.in_(genre_ids))
|
||||
for genre_id in genre_ids:
|
||||
statement = statement.where(
|
||||
exists().where(
|
||||
GenreBookLink.book_id == Book.id, GenreBookLink.genre_id == genre_id # ty: ignore
|
||||
)
|
||||
)
|
||||
|
||||
total_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = session.exec(total_statement).one()
|
||||
count_statement = select(func.count()).select_from(statement.subquery())
|
||||
total = session.scalar(count_statement)
|
||||
|
||||
if q:
|
||||
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=q)["embedding"]
|
||||
distance_col = Book.embedding.cosine_distance(emb) # ty: ignore
|
||||
statement = statement.where(Book.embedding.is_not(None)) # ty: ignore
|
||||
|
||||
keyword_match = case((Book.title.ilike(f"%{q}%"), 0), else_=1) # ty: ignore
|
||||
statement = statement.order_by(keyword_match, distance_col)
|
||||
else:
|
||||
statement = statement.order_by(Book.id) # ty: ignore
|
||||
|
||||
offset = (page - 1) * size
|
||||
statement = statement.offset(offset).limit(size)
|
||||
results = session.exec(statement).all()
|
||||
results = session.scalars(statement).unique().all()
|
||||
|
||||
books_with_data = []
|
||||
for db_book in results:
|
||||
books_with_data.append(
|
||||
BookWithAuthorsAndGenres(
|
||||
**db_book.model_dump(),
|
||||
authors=[AuthorRead(**a.model_dump()) for a in db_book.authors],
|
||||
genres=[GenreRead(**g.model_dump()) for g in db_book.genres]
|
||||
)
|
||||
)
|
||||
|
||||
return BookFilteredList(books=books_with_data, total=total)
|
||||
return BookFilteredList(books=results, total=total)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=Book,
|
||||
response_model=BookRead,
|
||||
summary="Создать книгу",
|
||||
description="Добавляет книгу в систему",
|
||||
)
|
||||
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())
|
||||
"""Создает новую книгу в системе"""
|
||||
full_text = book.title + " " + book.description
|
||||
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
|
||||
db_book = Book(**book.model_dump(), embedding=emb["embedding"])
|
||||
|
||||
session.add(db_book)
|
||||
session.commit()
|
||||
session.refresh(db_book)
|
||||
return BookRead(**db_book.model_dump())
|
||||
|
||||
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{db_book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{db_book.preview_id}.webp",
|
||||
} if db_book.preview_id else {}
|
||||
return BookRead(**book_data)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -89,10 +155,22 @@ def create_book(
|
||||
description="Возвращает список всех книг в системе",
|
||||
)
|
||||
def read_books(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт чтения списка книг"""
|
||||
books = session.exec(select(Book)).all()
|
||||
"""Возвращает список всех книг"""
|
||||
books = session.exec(select(Book)).all() # ty: ignore
|
||||
|
||||
books_data = []
|
||||
for book in books:
|
||||
book_data = book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{book.preview_id}.webp",
|
||||
} if book.preview_id else {}
|
||||
books_data.append(book_data)
|
||||
|
||||
return BookList(
|
||||
books=[BookRead(**book.model_dump()) for book in books], total=len(books)
|
||||
books=[BookRead(**book_data) for book_data in books_data],
|
||||
total=len(books),
|
||||
)
|
||||
|
||||
|
||||
@@ -106,24 +184,31 @@ def get_book(
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт чтения конкретной книги"""
|
||||
"""Возвращает информацию о книге с авторами и жанрами"""
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
authors = session.exec(
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id)
|
||||
authors = session.scalars(
|
||||
select(Author).join(AuthorBookLink).where(AuthorBookLink.book_id == book_id) # ty: ignore
|
||||
).all()
|
||||
|
||||
author_reads = [AuthorRead(**author.model_dump()) for author in authors]
|
||||
|
||||
genres = session.exec(
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id)
|
||||
genres = session.scalars(
|
||||
select(Genre).join(GenreBookLink).where(GenreBookLink.book_id == book_id) # ty: ignore
|
||||
).all()
|
||||
|
||||
genre_reads = [GenreRead(**genre.model_dump()) for genre in genres]
|
||||
|
||||
book_data = book.model_dump()
|
||||
book_data = book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{book.preview_id}.webp",
|
||||
} if book.preview_id else {}
|
||||
book_data["authors"] = author_reads
|
||||
book_data["genres"] = genre_reads
|
||||
|
||||
@@ -132,26 +217,64 @@ def get_book(
|
||||
|
||||
@router.put(
|
||||
"/{book_id}",
|
||||
response_model=Book,
|
||||
response_model=BookRead,
|
||||
summary="Обновить информацию о книге",
|
||||
description="Обновляет информацию о книге в системе",
|
||||
)
|
||||
def update_book(
|
||||
current_user: RequireAuth,
|
||||
book: BookUpdate,
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
current_user: RequireStaff,
|
||||
book_update: BookUpdate,
|
||||
book_id: int = Path(..., gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления книги"""
|
||||
"""Обновляет информацию о книге"""
|
||||
db_book = session.get(Book, book_id)
|
||||
if not db_book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
db_book.title = book.title or db_book.title
|
||||
db_book.description = book.description or db_book.description
|
||||
if book_update.status is not None:
|
||||
if book_update.status == BookStatus.BORROWED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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
|
||||
|
||||
full_text = (
|
||||
(book_update.title or db_book.title)
|
||||
+ " "
|
||||
+ (book_update.description or db_book.description)
|
||||
)
|
||||
emb = ollama_client.embeddings(model="mxbai-embed-large", prompt=full_text)
|
||||
db_book.embedding = emb["embedding"]
|
||||
|
||||
if book_update.page_count is not None:
|
||||
db_book.page_count = book_update.page_count
|
||||
|
||||
session.add(db_book)
|
||||
session.commit()
|
||||
session.refresh(db_book)
|
||||
return db_book
|
||||
|
||||
book_data = db_book.model_dump(exclude={"embedding", "preview_id"})
|
||||
book_data["preview_urls"] = {
|
||||
"png": f"/static/books/{db_book.preview_id}.png",
|
||||
"jpeg": f"/static/books/{db_book.preview_id}.jpg",
|
||||
"webp": f"/static/books/{db_book.preview_id}.webp",
|
||||
} if db_book.preview_id else {}
|
||||
|
||||
return BookRead(**book_data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -161,17 +284,89 @@ def update_book(
|
||||
description="Удаляет книгу их системы",
|
||||
)
|
||||
def delete_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
book_id: int = Path(..., description="ID книги (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления книги"""
|
||||
"""Удаляет книгу из системы"""
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, detail="Book not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
book_read = BookRead(
|
||||
id=(book.id or 0), title=book.title, description=book.description
|
||||
id=(book.id or 0),
|
||||
title=book.title,
|
||||
description=book.description,
|
||||
page_count=book.page_count,
|
||||
status=book.status,
|
||||
)
|
||||
session.delete(book)
|
||||
session.commit()
|
||||
return book_read
|
||||
|
||||
@router.post("/{book_id}/preview")
|
||||
async def upload_book_preview(
|
||||
current_user: RequireStaff,
|
||||
file: UploadFile = File(...),
|
||||
book_id: int = Path(..., gt=0),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
if not (file.content_type or "").startswith("image/"):
|
||||
raise HTTPException(status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Image required")
|
||||
|
||||
if (file.size or 0) > 32 * 1024 * 1024:
|
||||
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "File larger than 10 MB")
|
||||
|
||||
file_uuid= uuid4()
|
||||
tmp_path = BOOKS_PREVIEW_DIR / f"{file_uuid}.upload"
|
||||
|
||||
with open(tmp_path, "wb") as f:
|
||||
shutil.copyfileobj(file.file, f)
|
||||
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
tmp_path.unlink()
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
|
||||
|
||||
transcode_image(tmp_path)
|
||||
tmp_path.unlink()
|
||||
|
||||
if book.preview_id:
|
||||
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
book.preview_id = file_uuid
|
||||
session.add(book)
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
"preview": {
|
||||
"png": f"/static/books/{file_uuid}.png",
|
||||
"jpeg": f"/static/books/{file_uuid}.jpg",
|
||||
"webp": f"/static/books/{file_uuid}.webp",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{book_id}/preview")
|
||||
async def remove_book_preview(
|
||||
current_user: RequireStaff,
|
||||
book_id: int = Path(..., gt=0),
|
||||
session: Session = Depends(get_session)
|
||||
):
|
||||
book = session.get(Book, book_id)
|
||||
if not book:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Book not found")
|
||||
|
||||
if book.preview_id:
|
||||
for path in BOOKS_PREVIEW_DIR.glob(f"{book.preview_id}.*"):
|
||||
if path.exists():
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
book.preview_id = None
|
||||
session.add(book)
|
||||
session.commit()
|
||||
|
||||
return {"preview_urls": []}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from library_service.services.captcha import (
|
||||
limiter,
|
||||
get_ip,
|
||||
active_challenges,
|
||||
challenges_by_ip,
|
||||
MAX_CHALLENGES_PER_IP,
|
||||
MAX_TOTAL_CHALLENGES,
|
||||
CHALLENGE_TTL,
|
||||
REDEEM_TTL,
|
||||
prng,
|
||||
now_ms,
|
||||
redeem_tokens,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/cap", tags=["captcha"])
|
||||
|
||||
|
||||
@router.post("/challenge", summary="Задача capjs")
|
||||
@limiter.limit("15/minute")
|
||||
async def challenge(request: Request, ip: str = Depends(get_ip)):
|
||||
"""Возвращает задачу capjs"""
|
||||
if challenges_by_ip[ip] >= MAX_CHALLENGES_PER_IP:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many challenges"
|
||||
)
|
||||
if len(active_challenges) >= MAX_TOTAL_CHALLENGES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Server busy"
|
||||
)
|
||||
|
||||
token = secrets.token_hex(25)
|
||||
redeem = secrets.token_hex(25)
|
||||
expires = now_ms() + CHALLENGE_TTL
|
||||
|
||||
active_challenges[token] = {
|
||||
"c": 50,
|
||||
"s": 32,
|
||||
"d": 4,
|
||||
"expires": expires,
|
||||
"redeem_token": redeem,
|
||||
"ip": ip,
|
||||
}
|
||||
challenges_by_ip[ip] += 1
|
||||
|
||||
return {"challenge": {"c": 50, "s": 32, "d": 4}, "token": token, "expires": expires}
|
||||
|
||||
|
||||
@router.post("/redeem", summary="Проверка задачи")
|
||||
@limiter.limit("30/minute")
|
||||
async def redeem(request: Request, payload: dict, ip: str = Depends(get_ip)):
|
||||
"""Возвращает capjs_token"""
|
||||
token = payload.get("token")
|
||||
solutions = payload.get("solutions", [])
|
||||
|
||||
if token not in active_challenges:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Invalid challenge"
|
||||
)
|
||||
|
||||
ch = active_challenges.pop(token)
|
||||
challenges_by_ip[ch["ip"]] -= 1
|
||||
|
||||
if now_ms() > ch["expires"]:
|
||||
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Expired")
|
||||
if len(solutions) < ch["c"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Bad solutions"
|
||||
)
|
||||
|
||||
def verify(i: int) -> bool:
|
||||
salt = prng(f"{token}{i+1}", ch["s"])
|
||||
target = prng(f"{token}{i+1}d", ch["d"])
|
||||
h = hashlib.sha256((salt + str(solutions[i])).encode()).hexdigest()
|
||||
return h.startswith(target)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(asyncio.to_thread(verify, i) for i in range(ch["c"]))
|
||||
)
|
||||
if not all(results):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid solution"
|
||||
)
|
||||
|
||||
r_token = ch["redeem_token"]
|
||||
redeem_tokens[r_token] = now_ms() + REDEEM_TTL
|
||||
|
||||
resp = JSONResponse(
|
||||
{"success": True, "token": r_token, "expires": redeem_tokens[r_token]}
|
||||
)
|
||||
resp.set_cookie(
|
||||
key="capjs_token",
|
||||
value=r_token,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
max_age=REDEEM_TTL // 1000,
|
||||
)
|
||||
return resp
|
||||
@@ -1,16 +1,24 @@
|
||||
"""Модуль работы с жанрами"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Path, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.models.db import Book, Genre, GenreBookLink
|
||||
from library_service.models.dto import BookRead, GenreCreate, GenreList, GenreRead, GenreUpdate, GenreWithBooks
|
||||
from library_service.models.dto import (
|
||||
BookRead,
|
||||
GenreCreate,
|
||||
GenreList,
|
||||
GenreRead,
|
||||
GenreUpdate,
|
||||
GenreWithBooks,
|
||||
)
|
||||
from library_service.settings import get_session
|
||||
|
||||
|
||||
router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
|
||||
|
||||
# Создание жанра
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=GenreRead,
|
||||
@@ -18,11 +26,11 @@ router = APIRouter(prefix="/genres", tags=["genres"])
|
||||
description="Добавляет жанр книг в систему",
|
||||
)
|
||||
def create_genre(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre: GenreCreate,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт создания жанра"""
|
||||
"""Создает новый жанр в системе"""
|
||||
db_genre = Genre(**genre.model_dump())
|
||||
session.add(db_genre)
|
||||
session.commit()
|
||||
@@ -30,7 +38,6 @@ def create_genre(
|
||||
return GenreRead(**db_genre.model_dump())
|
||||
|
||||
|
||||
# Чтение жанров
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=GenreList,
|
||||
@@ -38,14 +45,13 @@ def create_genre(
|
||||
description="Возвращает список всех жанров в системе",
|
||||
)
|
||||
def read_genres(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт чтения списка жанров"""
|
||||
"""Возвращает список всех жанров"""
|
||||
genres = session.exec(select(Genre)).all()
|
||||
return GenreList(
|
||||
genres=[GenreRead(**genre.model_dump()) for genre in genres], total=len(genres)
|
||||
)
|
||||
|
||||
|
||||
# Чтение жанра с его книгами
|
||||
@router.get(
|
||||
"/{genre_id}",
|
||||
response_model=GenreWithBooks,
|
||||
@@ -56,10 +62,12 @@ def get_genre(
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт чтения конкретного жанра"""
|
||||
"""Возвращает информацию о жанре и книгах с ним"""
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
|
||||
)
|
||||
|
||||
books = session.exec(
|
||||
select(Book).join(GenreBookLink).where(GenreBookLink.genre_id == genre_id)
|
||||
@@ -73,23 +81,24 @@ def get_genre(
|
||||
return GenreWithBooks(**genre_data)
|
||||
|
||||
|
||||
# Обновление жанра
|
||||
@router.put(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
summary="Обновляет информацию о жанре",
|
||||
summary="Обновить информацию о жанре",
|
||||
description="Обновляет информацию о жанре в системе",
|
||||
)
|
||||
def update_genre(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre: GenreUpdate,
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт обновления жанра"""
|
||||
"""Обновляет информацию о жанре"""
|
||||
db_genre = session.get(Genre, genre_id)
|
||||
if not db_genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
|
||||
)
|
||||
|
||||
update_data = genre.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
@@ -100,22 +109,23 @@ def update_genre(
|
||||
return GenreRead(**db_genre.model_dump())
|
||||
|
||||
|
||||
# Удаление жанра
|
||||
@router.delete(
|
||||
"/{genre_id}",
|
||||
response_model=GenreRead,
|
||||
summary="Удалить жанр",
|
||||
description="Удаляет автора из системы",
|
||||
description="Удаляет жанр из системы",
|
||||
)
|
||||
def delete_genre(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre_id: int = Path(..., description="ID жанра (целое число, > 0)", gt=0),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления жанра"""
|
||||
"""Удаляет жанр из системы"""
|
||||
genre = session.get(Genre, genre_id)
|
||||
if not genre:
|
||||
raise HTTPException(status_code=404, detail="Genre not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Genre not found"
|
||||
)
|
||||
|
||||
genre_read = GenreRead(**genre.model_dump())
|
||||
session.delete(genre)
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"""Модуль работы с выдачей и бронированием книг"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
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=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Book is not available for loan (status: {book.status})",
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, 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.now(timezone.utc),
|
||||
)
|
||||
|
||||
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.now(timezone.utc)
|
||||
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 # ty: ignore[unsupported-operator]
|
||||
)
|
||||
.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=status.HTTP_404_NOT_FOUND, 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=status.HTTP_404_NOT_FOUND, 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=status.HTTP_404_NOT_FOUND, 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=status.HTTP_404_NOT_FOUND, detail="User not found"
|
||||
)
|
||||
db_loan.user_id = loan_update.user_id
|
||||
|
||||
if loan_update.due_date is not None:
|
||||
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=status.HTTP_400_BAD_REQUEST,
|
||||
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=status.HTTP_404_NOT_FOUND, detail="Loan not found"
|
||||
)
|
||||
|
||||
if loan.returned_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
|
||||
)
|
||||
|
||||
book = session.get(Book, loan.book_id)
|
||||
if not book:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if book.status not in [BookStatus.RESERVED, BookStatus.ACTIVE]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
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=status.HTTP_404_NOT_FOUND, detail="Loan not found"
|
||||
)
|
||||
|
||||
if loan.returned_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="Loan is already returned"
|
||||
)
|
||||
|
||||
loan.returned_at = datetime.now(timezone.utc)
|
||||
|
||||
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=status.HTTP_404_NOT_FOUND, 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=status.HTTP_400_BAD_REQUEST,
|
||||
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=status.HTTP_404_NOT_FOUND, detail="Book not found"
|
||||
)
|
||||
|
||||
if book.status != BookStatus.ACTIVE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Book is not available (status: {book.status})",
|
||||
)
|
||||
|
||||
target_user = session.get(User, loan.user_id)
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, 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.now(timezone.utc),
|
||||
)
|
||||
|
||||
book.status = BookStatus.BORROWED
|
||||
|
||||
session.add(db_loan)
|
||||
session.add(book)
|
||||
session.commit()
|
||||
session.refresh(db_loan)
|
||||
|
||||
return LoanRead(**db_loan.model_dump())
|
||||
+129
-20
@@ -1,4 +1,7 @@
|
||||
"""Модуль прочих эндпоинтов"""
|
||||
"""Модуль прочих эндпоинтов и веб-страниц"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict
|
||||
@@ -11,88 +14,194 @@ from sqlmodel import Session, select, func
|
||||
|
||||
from library_service.settings import get_app, get_session
|
||||
from library_service.models.db import Author, Book, Genre, User
|
||||
from library_service.services import SchemaGenerator
|
||||
from library_service import models
|
||||
|
||||
|
||||
router = APIRouter(tags=["misc"])
|
||||
generator = SchemaGenerator(models.db, models.dto)
|
||||
templates = Jinja2Templates(directory=Path(__file__).parent.parent / "templates")
|
||||
|
||||
|
||||
def get_info(app) -> Dict:
|
||||
"""Форматированная информация о приложении"""
|
||||
"""Возвращает информацию о приложении"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"app_info": {
|
||||
"title": app.title,
|
||||
"version": app.version,
|
||||
"description": app.description.rsplit('|', 1)[0],
|
||||
"description": app.description.rsplit("|", 1)[0],
|
||||
},
|
||||
"server_time": datetime.now().isoformat(),
|
||||
"domain": os.getenv("DOMAIN", ""),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/", include_in_schema=False)
|
||||
async def root(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Эндпоинт главной страницы"""
|
||||
return templates.TemplateResponse(request, "index.html", get_info(app))
|
||||
"""Рендерит главную страницу"""
|
||||
return templates.TemplateResponse(request, "index.html", get_info(app) | {"request": request, "title": "LiB - Библиотека"})
|
||||
|
||||
|
||||
@router.get("/unknown", include_in_schema=False)
|
||||
async def unknown(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу 404 ошибки"""
|
||||
return templates.TemplateResponse(request, "unknown.html", get_info(app) | {"request": request, "title": "LiB - Страница не найдена"})
|
||||
|
||||
|
||||
@router.get("/genre/create", include_in_schema=False)
|
||||
async def create_genre(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу создания жанра"""
|
||||
return templates.TemplateResponse(request, "create_genre.html", get_info(app) | {"request": request, "title": "LiB - Создать жанр"})
|
||||
|
||||
|
||||
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
|
||||
async def edit_genre(request: Request, genre_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу редактирования жанра"""
|
||||
return templates.TemplateResponse(request, "edit_genre.html", get_info(app) | {"request": request, "title": "LiB - Редактировать жанр", "id": genre_id})
|
||||
|
||||
|
||||
@router.get("/authors", include_in_schema=False)
|
||||
async def authors(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу списка авторов"""
|
||||
return templates.TemplateResponse(request, "authors.html", get_info(app) | {"request": request, "title": "LiB - Авторы"})
|
||||
|
||||
|
||||
@router.get("/author/create", include_in_schema=False)
|
||||
async def create_author(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу создания автора"""
|
||||
return templates.TemplateResponse(request, "create_author.html", get_info(app) | {"request": request, "title": "LiB - Создать автора"})
|
||||
|
||||
|
||||
@router.get("/author/{author_id}/edit", include_in_schema=False)
|
||||
async def edit_author(request: Request, author_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу редактирования автора"""
|
||||
return templates.TemplateResponse(request, "edit_author.html", get_info(app) | {"request": request, "title": "LiB - Редактировать автора", "id": author_id})
|
||||
|
||||
|
||||
@router.get("/author/{author_id}", include_in_schema=False)
|
||||
async def author(request: Request, author_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу просмотра автора"""
|
||||
return templates.TemplateResponse(request, "author.html", get_info(app) | {"request": request, "title": "LiB - Автор", "id": author_id})
|
||||
|
||||
|
||||
@router.get("/books", include_in_schema=False)
|
||||
async def books(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Эндпоинт страницы выбора книг"""
|
||||
return templates.TemplateResponse(request, "books.html", get_info(app))
|
||||
"""Рендерит страницу списка книг"""
|
||||
return templates.TemplateResponse(request, "books.html", get_info(app) | {"request": request, "title": "LiB - Книги"})
|
||||
|
||||
|
||||
@router.get("/book/create", include_in_schema=False)
|
||||
async def create_book(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу создания книги"""
|
||||
return templates.TemplateResponse(request, "create_book.html", get_info(app) | {"request": request, "title": "LiB - Создать книгу"})
|
||||
|
||||
|
||||
@router.get("/book/{book_id}/edit", include_in_schema=False)
|
||||
async def edit_book(request: Request, book_id: int, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу редактирования книги"""
|
||||
return templates.TemplateResponse(request, "edit_book.html", get_info(app) | {"request": request, "title": "LiB - Редактировать книгу", "id": book_id})
|
||||
|
||||
|
||||
@router.get("/book/{book_id}", include_in_schema=False)
|
||||
async def book(request: Request, book_id: int, app=Depends(lambda: get_app()), session=Depends(get_session)):
|
||||
"""Рендерит страницу просмотра книги"""
|
||||
book = session.get(Book, book_id)
|
||||
return templates.TemplateResponse(request, "book.html", get_info(app) | {"request": request, "title": "LiB - Книга", "id": book_id, "img": book.preview_id})
|
||||
|
||||
|
||||
@router.get("/auth", include_in_schema=False)
|
||||
async def auth(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Эндпоинт страницы авторизации"""
|
||||
return templates.TemplateResponse(request, "auth.html", get_info(app))
|
||||
"""Рендерит страницу авторизации"""
|
||||
return templates.TemplateResponse(request, "auth.html", get_info(app) | {"request": request, "title": "LiB - Авторизация"})
|
||||
|
||||
|
||||
@router.get("/2fa", include_in_schema=False)
|
||||
async def set2fa(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу установки двухфакторной аутентификации"""
|
||||
return templates.TemplateResponse(request, "2fa.html", get_info(app) | {"request": request, "title": "LiB - Двухфакторная аутентификация"})
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def api(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Страница с сылками на документацию API"""
|
||||
return templates.TemplateResponse(request, "api.html", get_info(app))
|
||||
|
||||
@router.get("/profile", include_in_schema=False)
|
||||
async def profile(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу профиля пользователя"""
|
||||
return templates.TemplateResponse(request, "profile.html", get_info(app) | {"request": request, "title": "LiB - Профиль"})
|
||||
|
||||
|
||||
@router.get("/users", include_in_schema=False)
|
||||
async def users(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу управления пользователями"""
|
||||
return templates.TemplateResponse(request, "users.html", get_info(app) | {"request": request, "title": "LiB - Пользователи"})
|
||||
|
||||
|
||||
@router.get("/my-books", include_in_schema=False)
|
||||
async def my_books(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу моих книг пользователя"""
|
||||
return templates.TemplateResponse(request, "my_books.html", get_info(app) | {"request": request, "title": "LiB - Мои книги"})
|
||||
|
||||
|
||||
@router.get("/analytics", include_in_schema=False)
|
||||
async def analytics(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу аналитики выдач"""
|
||||
return templates.TemplateResponse(request, "analytics.html", get_info(app) | {"request": request, "title": "LiB - Аналитика"})
|
||||
|
||||
|
||||
@router.get("/favicon.ico", include_in_schema=False)
|
||||
def redirect_favicon():
|
||||
"""Редирект иконки вкладки"""
|
||||
"""Редиректит на favicon.svg"""
|
||||
return RedirectResponse("/favicon.svg")
|
||||
|
||||
|
||||
@router.get("/favicon.svg", include_in_schema=False)
|
||||
async def favicon():
|
||||
"""Эндпоинт иконки вкладки"""
|
||||
"""Возвращает иконку сайта"""
|
||||
return FileResponse(
|
||||
"library_service/static/favicon.svg", media_type="image/svg+xml"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def api(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Рендерит страницу с ссылками на документацию API"""
|
||||
return templates.TemplateResponse(request, "api.html", get_info(app))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/info",
|
||||
summary="Информация о сервисе",
|
||||
description="Возвращает общую информацию о системе",
|
||||
)
|
||||
async def api_info(app=Depends(lambda: get_app())):
|
||||
"""Эндпоинт информации об API"""
|
||||
"""Возвращает информацию о сервисе"""
|
||||
return JSONResponse(content=get_info(app))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/schema",
|
||||
summary="Информация о таблицах и связях",
|
||||
description="Возвращает схему базы данных с описаниями полей",
|
||||
)
|
||||
async def api_schema():
|
||||
"""Возвращает информацию для создания er-диаграммы"""
|
||||
return generator.generate()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/stats",
|
||||
summary="Статистика сервиса",
|
||||
description="Возвращает статистическую информацию о системе",
|
||||
)
|
||||
async def api_stats(session: Session = Depends(get_session)):
|
||||
"""Эндпоинт стстистики системы"""
|
||||
async def api_stats(session=Depends(get_session)):
|
||||
"""Возвращает статистику системы"""
|
||||
authors = select(func.count()).select_from(Author)
|
||||
books = select(func.count()).select_from(Book)
|
||||
genres = select(func.count()).select_from(Genre)
|
||||
users = select(func.count()).select_from(User)
|
||||
return JSONResponse(content={
|
||||
return JSONResponse(
|
||||
content={
|
||||
"authors": session.exec(authors).one(),
|
||||
"books": session.exec(books).one(),
|
||||
"genres": session.exec(genres).one(),
|
||||
"users": session.exec(users).one(),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Модуль работы со связями"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.auth import RequireAuth
|
||||
from library_service.auth import RequireStaff
|
||||
from library_service.models.db import Author, AuthorBookLink, Book, Genre, GenreBookLink
|
||||
from library_service.models.dto import AuthorRead, BookRead, GenreRead
|
||||
from library_service.settings import get_session
|
||||
@@ -14,15 +15,17 @@ router = APIRouter(tags=["relations"])
|
||||
|
||||
|
||||
def check_entity_exists(session, model, entity_id, entity_name):
|
||||
"""Проверка существования связи между сущностями в БД"""
|
||||
"""Проверяет существование сущности в базе данных"""
|
||||
entity = session.get(model, entity_id)
|
||||
if not entity:
|
||||
raise HTTPException(status_code=404, detail=f"{entity_name} not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"{entity_name} not found"
|
||||
)
|
||||
return entity
|
||||
|
||||
|
||||
def add_relationship(session, link_model, id1, field1, id2, field2, detail):
|
||||
"""Создание связи между сущностями в БД"""
|
||||
"""Создает связь между сущностями в базе данных"""
|
||||
existing_link = session.exec(
|
||||
select(link_model)
|
||||
.where(getattr(link_model, field1) == id1)
|
||||
@@ -30,7 +33,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
|
||||
).first()
|
||||
|
||||
if existing_link:
|
||||
raise HTTPException(status_code=400, detail=detail)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
|
||||
|
||||
link = link_model(**{field1: id1, field2: id2})
|
||||
session.add(link)
|
||||
@@ -40,7 +43,7 @@ def add_relationship(session, link_model, id1, field1, id2, field2, detail):
|
||||
|
||||
|
||||
def remove_relationship(session, link_model, id1, field1, id2, field2):
|
||||
"""Удаление связи между сущностями в БД"""
|
||||
"""Удаляет связь между сущностями в базе данных"""
|
||||
link = session.exec(
|
||||
select(link_model)
|
||||
.where(getattr(link_model, field1) == id1)
|
||||
@@ -48,7 +51,9 @@ def remove_relationship(session, link_model, id1, field1, id2, field2):
|
||||
).first()
|
||||
|
||||
if not link:
|
||||
raise HTTPException(status_code=404, detail="Relationship not found")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Relationship not found"
|
||||
)
|
||||
|
||||
session.delete(link)
|
||||
session.commit()
|
||||
@@ -64,13 +69,14 @@ def get_related(
|
||||
link_model,
|
||||
link_main_field,
|
||||
link_related_field,
|
||||
read_model
|
||||
read_model,
|
||||
):
|
||||
"""Получение связанных в БД сущностей"""
|
||||
"""Возвращает список связанных сущностей"""
|
||||
check_entity_exists(session, main_model, main_id, main_name)
|
||||
|
||||
related = session.exec(
|
||||
select(related_model).join(link_model)
|
||||
select(related_model)
|
||||
.join(link_model)
|
||||
.where(getattr(link_model, link_main_field) == main_id)
|
||||
).all()
|
||||
|
||||
@@ -84,17 +90,24 @@ def get_related(
|
||||
description="Добавляет связь между автором и книгой в систему",
|
||||
)
|
||||
def add_author_to_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт добавления автора к книге"""
|
||||
"""Добавляет связь между автором и книгой"""
|
||||
check_entity_exists(session, Author, author_id, "Author")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
return add_relationship(session, AuthorBookLink,
|
||||
author_id, "author_id", book_id, "book_id", "Relationship already exists")
|
||||
return add_relationship(
|
||||
session,
|
||||
AuthorBookLink,
|
||||
author_id,
|
||||
"author_id",
|
||||
book_id,
|
||||
"book_id",
|
||||
"Relationship already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -104,14 +117,15 @@ def add_author_to_book(
|
||||
description="Удаляет связь между автором и книгой в системе",
|
||||
)
|
||||
def remove_author_from_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
author_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления автора из книги"""
|
||||
return remove_relationship(session, AuthorBookLink,
|
||||
author_id, "author_id", book_id, "book_id")
|
||||
"""Удаляет связь между автором и книгой"""
|
||||
return remove_relationship(
|
||||
session, AuthorBookLink, author_id, "author_id", book_id, "book_id"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -121,10 +135,18 @@ def remove_author_from_book(
|
||||
description="Возвращает все книги в системе, написанные автором",
|
||||
)
|
||||
def get_books_for_author(author_id: int, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт получения книг, написанных автором"""
|
||||
return get_related(session,
|
||||
Author, author_id, "Author", Book,
|
||||
AuthorBookLink, "author_id", "book_id", BookRead)
|
||||
"""Возвращает список книг автора"""
|
||||
return get_related(
|
||||
session,
|
||||
Author,
|
||||
author_id,
|
||||
"Author",
|
||||
Book,
|
||||
AuthorBookLink,
|
||||
"author_id",
|
||||
"book_id",
|
||||
BookRead,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -134,10 +156,18 @@ def get_books_for_author(author_id: int, session: Session = Depends(get_session)
|
||||
description="Возвращает всех авторов книги в системе",
|
||||
)
|
||||
def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт получения авторов книги"""
|
||||
return get_related(session,
|
||||
Book, book_id, "Book", Author,
|
||||
AuthorBookLink, "book_id", "author_id", AuthorRead)
|
||||
"""Возвращает список авторов книги"""
|
||||
return get_related(
|
||||
session,
|
||||
Book,
|
||||
book_id,
|
||||
"Book",
|
||||
Author,
|
||||
AuthorBookLink,
|
||||
"book_id",
|
||||
"author_id",
|
||||
AuthorRead,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
@@ -147,17 +177,24 @@ def get_authors_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
description="Добавляет связь между книгой и жанром в систему",
|
||||
)
|
||||
def add_genre_to_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт добавления жанра к книге"""
|
||||
"""Добавляет связь между жанром и книгой"""
|
||||
check_entity_exists(session, Genre, genre_id, "Genre")
|
||||
check_entity_exists(session, Book, book_id, "Book")
|
||||
|
||||
return add_relationship(session, GenreBookLink,
|
||||
genre_id, "genre_id", book_id, "book_id", "Relationship already exists")
|
||||
return add_relationship(
|
||||
session,
|
||||
GenreBookLink,
|
||||
genre_id,
|
||||
"genre_id",
|
||||
book_id,
|
||||
"book_id",
|
||||
"Relationship already exists",
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
@@ -167,14 +204,15 @@ def add_genre_to_book(
|
||||
description="Удаляет связь между жанром и книгой в системе",
|
||||
)
|
||||
def remove_genre_from_book(
|
||||
current_user: RequireAuth,
|
||||
current_user: RequireStaff,
|
||||
genre_id: int,
|
||||
book_id: int,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт удаления жанра из книги"""
|
||||
return remove_relationship(session, GenreBookLink,
|
||||
genre_id, "genre_id", book_id, "book_id")
|
||||
"""Удаляет связь между жанром и книгой"""
|
||||
return remove_relationship(
|
||||
session, GenreBookLink, genre_id, "genre_id", book_id, "book_id"
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -184,10 +222,18 @@ def remove_genre_from_book(
|
||||
description="Возвращает все книги в системе в этом жанре",
|
||||
)
|
||||
def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт получения книг с жанром"""
|
||||
return get_related(session,
|
||||
Genre, genre_id, "Genre", Book,
|
||||
GenreBookLink, "genre_id", "book_id", BookRead)
|
||||
"""Возвращает список книг в жанре"""
|
||||
return get_related(
|
||||
session,
|
||||
Genre,
|
||||
genre_id,
|
||||
"Genre",
|
||||
Book,
|
||||
GenreBookLink,
|
||||
"genre_id",
|
||||
"book_id",
|
||||
BookRead,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -197,7 +243,15 @@ def get_books_for_genre(genre_id: int, session: Session = Depends(get_session)):
|
||||
description="Возвращает все жанры книги в системе",
|
||||
)
|
||||
def get_genres_for_book(book_id: int, session: Session = Depends(get_session)):
|
||||
"""Эндпоинт получения жанров книги"""
|
||||
return get_related(session,
|
||||
Book, book_id, "Book", Genre,
|
||||
GenreBookLink, "book_id", "genre_id", GenreRead)
|
||||
"""Возвращает список жанров книги"""
|
||||
return get_related(
|
||||
session,
|
||||
Book,
|
||||
book_id,
|
||||
"Book",
|
||||
Genre,
|
||||
GenreBookLink,
|
||||
"book_id",
|
||||
"genre_id",
|
||||
GenreRead,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Модуль управления пользователями (для администраторов)"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from library_service.models.db import Role, User
|
||||
from library_service.models.dto import (
|
||||
RoleRead,
|
||||
RoleList,
|
||||
UserRead,
|
||||
UserList,
|
||||
UserCreateByAdmin,
|
||||
UserUpdateByAdmin,
|
||||
)
|
||||
from library_service.settings import get_session
|
||||
from library_service.auth import (
|
||||
RequireAuth,
|
||||
RequireAdmin,
|
||||
RequireStaff,
|
||||
get_password_hash,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/roles",
|
||||
response_model=RoleList,
|
||||
summary="Список ролей",
|
||||
)
|
||||
def get_roles(
|
||||
auth: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список ролей в системе"""
|
||||
user_roles = [role.name for role in auth.roles]
|
||||
exclude = {"payroll"} if "admin" not in user_roles else set()
|
||||
roles = session.exec(select(Role)).all()
|
||||
|
||||
return RoleList(
|
||||
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
|
||||
total=len(roles),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=UserList,
|
||||
summary="Список пользователей",
|
||||
)
|
||||
def list_users(
|
||||
current_user: RequireStaff,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает список всех пользователей"""
|
||||
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
||||
total = session.exec(select(User)).all()
|
||||
|
||||
return UserList(
|
||||
users=[
|
||||
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
for user in users
|
||||
],
|
||||
total=len(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=UserRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Создать пользователя",
|
||||
)
|
||||
def create_user(
|
||||
user_data: UserCreateByAdmin,
|
||||
current_user: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Создает пользователя (без резервных кодов)"""
|
||||
if session.exec(select(User).where(User.username == user_data.username)).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Username already registered",
|
||||
)
|
||||
|
||||
if session.exec(select(User).where(User.email == user_data.email)).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
|
||||
db_user = User(
|
||||
username=user_data.username,
|
||||
email=user_data.email,
|
||||
full_name=user_data.full_name,
|
||||
hashed_password=get_password_hash(user_data.password),
|
||||
is_active=user_data.is_active,
|
||||
)
|
||||
|
||||
if user_data.roles:
|
||||
for role_name in user_data.roles:
|
||||
role = session.exec(select(Role).where(Role.name == role_name)).first()
|
||||
if role:
|
||||
db_user.roles.append(role)
|
||||
else:
|
||||
default_role = session.exec(select(Role).where(Role.name == "member")).first()
|
||||
if default_role:
|
||||
db_user.roles.append(default_role)
|
||||
|
||||
session.add(db_user)
|
||||
session.commit()
|
||||
session.refresh(db_user)
|
||||
|
||||
return UserRead(**db_user.model_dump(), roles=[r.name for r in db_user.roles])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{user_id}",
|
||||
response_model=UserRead,
|
||||
summary="Получить пользователя",
|
||||
)
|
||||
def get_user(
|
||||
user_id: int,
|
||||
current_user: RequireStaff,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Возвращает пользователя по ID"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{user_id}",
|
||||
response_model=UserRead,
|
||||
summary="Обновить пользователя",
|
||||
)
|
||||
def update_user(
|
||||
user_id: int,
|
||||
user_data: UserUpdateByAdmin,
|
||||
current_user: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Обновляет данные пользователя"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
if user_data.email and user_data.email != user.email:
|
||||
existing = session.exec(
|
||||
select(User).where(User.email == user_data.email)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered",
|
||||
)
|
||||
user.email = user_data.email
|
||||
|
||||
if user_data.full_name is not None:
|
||||
user.full_name = user_data.full_name
|
||||
|
||||
if user_data.password:
|
||||
user.hashed_password = get_password_hash(user_data.password)
|
||||
|
||||
if user_data.is_active is not None:
|
||||
user.is_active = user_data.is_active
|
||||
|
||||
if user_data.roles is not None:
|
||||
user.roles.clear()
|
||||
for role_name in user_data.roles:
|
||||
role = session.exec(select(Role).where(Role.name == role_name)).first()
|
||||
if role:
|
||||
user.roles.append(role)
|
||||
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}",
|
||||
response_model=UserRead,
|
||||
summary="Удалить пользователя",
|
||||
)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
current_user: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Деактивирует пользователя, при повторном вызове — удаляет физически"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
if user.is_active:
|
||||
user.is_active = False
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
else:
|
||||
user_read = UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
session.delete(user)
|
||||
session.commit()
|
||||
return user_read
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{user_id}/roles/{role_name}",
|
||||
response_model=UserRead,
|
||||
summary="Назначить роль пользователю",
|
||||
)
|
||||
def add_role_to_user(
|
||||
user_id: int,
|
||||
role_name: str,
|
||||
current_user: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Добавляет роль пользователю"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
role = session.exec(select(Role).where(Role.name == role_name)).first()
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role '{role_name}' not found",
|
||||
)
|
||||
|
||||
if role in user.roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User already has this role",
|
||||
)
|
||||
|
||||
user.roles.append(role)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}/roles/{role_name}",
|
||||
response_model=UserRead,
|
||||
summary="Удалить роль у пользователя",
|
||||
)
|
||||
def remove_role_from_user(
|
||||
user_id: int,
|
||||
role_name: str,
|
||||
current_user: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Удаляет роль у пользователя"""
|
||||
user = session.get(User, user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
role = session.exec(select(Role).where(Role.name == role_name)).first()
|
||||
if not role:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Role '{role_name}' not found",
|
||||
)
|
||||
|
||||
if role not in user.roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="User does not have this role",
|
||||
)
|
||||
|
||||
user.roles.remove(role)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
|
||||
return UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
@@ -0,0 +1,33 @@
|
||||
from .captcha import (
|
||||
limiter,
|
||||
cleanup_task,
|
||||
get_ip,
|
||||
require_captcha,
|
||||
active_challenges,
|
||||
redeem_tokens,
|
||||
challenges_by_ip,
|
||||
MAX_CHALLENGES_PER_IP,
|
||||
MAX_TOTAL_CHALLENGES,
|
||||
CHALLENGE_TTL,
|
||||
REDEEM_TTL,
|
||||
prng,
|
||||
)
|
||||
from .describe_er import SchemaGenerator
|
||||
from .image_processing import transcode_image
|
||||
|
||||
__all__ = [
|
||||
"limiter",
|
||||
"cleanup_task",
|
||||
"get_ip",
|
||||
"require_captcha",
|
||||
"active_challenges",
|
||||
"redeem_tokens",
|
||||
"challenges_by_ip",
|
||||
"MAX_CHALLENGES_PER_IP",
|
||||
"MAX_TOTAL_CHALLENGES",
|
||||
"CHALLENGE_TTL",
|
||||
"REDEEM_TTL",
|
||||
"prng",
|
||||
"SchemaGenerator",
|
||||
"transcode_image",
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Модуль создания и проверки capjs"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from fastapi import Request, HTTPException, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
CLEANUP_INTERVAL = int(os.getenv("CAP_CLEANUP_INTERVAL", "10"))
|
||||
REDEEM_TTL = int(os.getenv("CAP_REDEEM_TTL_SECONDS", "180")) * 1000
|
||||
CHALLENGE_TTL = int(os.getenv("CAP_CHALLENGE_TTL_SECONDS", "120")) * 1000
|
||||
MAX_CHALLENGES_PER_IP = int(os.getenv("CAP_MAX_CHALLENGES_PER_IP", "12"))
|
||||
MAX_TOTAL_CHALLENGES = int(os.getenv("CAP_MAX_TOTAL_CHALLENGES", "1000"))
|
||||
|
||||
active_challenges: dict[str, dict] = {}
|
||||
redeem_tokens: dict[str, int] = {}
|
||||
challenges_by_ip: defaultdict[str, int] = defaultdict(int)
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
def now_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def fnv1a_utf16(seed: str) -> int:
|
||||
h = 2166136261
|
||||
data = seed.encode("utf-16le")
|
||||
i = 0
|
||||
while i < len(data):
|
||||
unit = data[i] + (data[i + 1] << 8)
|
||||
h ^= unit
|
||||
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & 0xFFFFFFFF
|
||||
i += 2
|
||||
return h
|
||||
|
||||
|
||||
def prng(seed: str, length: int) -> str:
|
||||
state = fnv1a_utf16(seed)
|
||||
out = ""
|
||||
while len(out) < length:
|
||||
state ^= (state << 13) & 0xFFFFFFFF
|
||||
state ^= state >> 17
|
||||
state ^= (state << 5) & 0xFFFFFFFF
|
||||
out += f"{state & 0xFFFFFFFF:08x}"
|
||||
return out[:length]
|
||||
|
||||
|
||||
async def cleanup_task():
|
||||
while True:
|
||||
now = now_ms()
|
||||
for token, data in list(active_challenges.items()):
|
||||
if data["expires"] < now:
|
||||
challenges_by_ip[data["ip"]] -= 1
|
||||
del active_challenges[token]
|
||||
for token, exp in list(redeem_tokens.items()):
|
||||
if exp < now:
|
||||
del redeem_tokens[token]
|
||||
await asyncio.sleep(CLEANUP_INTERVAL)
|
||||
|
||||
|
||||
def get_ip(request: Request) -> str:
|
||||
return get_remote_address(request)
|
||||
|
||||
|
||||
async def require_captcha(request: Request):
|
||||
token = request.cookies.get("capjs_token")
|
||||
if not token or token not in redeem_tokens or redeem_tokens[token] < now_ms():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail={"error": "captcha_required"}
|
||||
)
|
||||
del redeem_tokens[token]
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Модуль генерации описания схемы БД"""
|
||||
|
||||
import enum
|
||||
import inspect
|
||||
from typing import (
|
||||
List,
|
||||
Dict,
|
||||
Any,
|
||||
Set,
|
||||
Type,
|
||||
Tuple,
|
||||
Optional,
|
||||
Union,
|
||||
get_origin,
|
||||
get_args,
|
||||
)
|
||||
|
||||
from sqlalchemy import Enum as SAEnum
|
||||
from sqlalchemy.inspection import inspect as sa_inspect
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class SchemaGenerator:
|
||||
"""Сервис генерации json описания схемы БД"""
|
||||
|
||||
def __init__(self, db_module, dto_module=None):
|
||||
self.db_models = self._get_classes(db_module, is_table=True)
|
||||
self.dto_models = (
|
||||
self._get_classes(dto_module, is_table=False) if dto_module else []
|
||||
)
|
||||
self.link_table_names = self._identify_link_tables()
|
||||
self.field_descriptions = self._collect_all_descriptions()
|
||||
self._table_to_model = {m.__tablename__: m for m in self.db_models}
|
||||
|
||||
def _get_classes(
|
||||
self, module, is_table: bool | None = None
|
||||
) -> List[Type[SQLModel]]:
|
||||
if module is None:
|
||||
return []
|
||||
|
||||
classes = []
|
||||
for name, obj in inspect.getmembers(module):
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and issubclass(obj, SQLModel)
|
||||
and obj is not SQLModel
|
||||
):
|
||||
if is_table is True and hasattr(obj, "__table__"):
|
||||
classes.append(obj)
|
||||
elif is_table is False and not hasattr(obj, "__table__"):
|
||||
classes.append(obj)
|
||||
return classes
|
||||
|
||||
def _normalize_model_name(self, name: str) -> str:
|
||||
suffixes = [
|
||||
"Create",
|
||||
"Read",
|
||||
"Update",
|
||||
"DTO",
|
||||
"Base",
|
||||
"List",
|
||||
"Detail",
|
||||
"Response",
|
||||
"Request",
|
||||
]
|
||||
result = name
|
||||
for suffix in suffixes:
|
||||
if result.endswith(suffix) and len(result) > len(suffix):
|
||||
result = result[: -len(suffix)]
|
||||
return result
|
||||
|
||||
def _get_field_descriptions_from_class(self, cls: Type) -> Dict[str, str]:
|
||||
descriptions = {}
|
||||
|
||||
for parent in cls.__mro__:
|
||||
if parent is SQLModel or parent is object:
|
||||
continue
|
||||
|
||||
fields = getattr(parent, "model_fields", {})
|
||||
for field_name, field_info in fields.items():
|
||||
if field_name in descriptions:
|
||||
continue
|
||||
|
||||
desc = getattr(field_info, "description", None) or getattr(
|
||||
field_info, "title", None
|
||||
)
|
||||
if desc:
|
||||
descriptions[field_name] = desc
|
||||
|
||||
return descriptions
|
||||
|
||||
def _collect_all_descriptions(self) -> Dict[str, Dict[str, str]]:
|
||||
result = {}
|
||||
|
||||
dto_map = {}
|
||||
for dto in self.dto_models:
|
||||
base_name = self._normalize_model_name(dto.__name__)
|
||||
if base_name not in dto_map:
|
||||
dto_map[base_name] = {}
|
||||
|
||||
for field, desc in self._get_field_descriptions_from_class(dto).items():
|
||||
if field not in dto_map[base_name]:
|
||||
dto_map[base_name][field] = desc
|
||||
|
||||
for model in self.db_models:
|
||||
model_name = model.__name__
|
||||
result[model_name] = {
|
||||
**dto_map.get(model_name, {}),
|
||||
**self._get_field_descriptions_from_class(model),
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
def _identify_link_tables(self) -> Set[str]:
|
||||
link_tables = set()
|
||||
for model in self.db_models:
|
||||
try:
|
||||
for rel in sa_inspect(model).relationships:
|
||||
if rel.secondary is not None:
|
||||
link_tables.add(rel.secondary.name)
|
||||
except Exception:
|
||||
continue
|
||||
return link_tables
|
||||
|
||||
def _collect_fk_relations(self) -> List[Dict[str, Any]]:
|
||||
relations = []
|
||||
processed: Set[Tuple[str, str, str, str]] = set()
|
||||
|
||||
for model in self.db_models:
|
||||
if model.__tablename__ in self.link_table_names:
|
||||
continue
|
||||
|
||||
for col in sa_inspect(model).columns:
|
||||
for fk in col.foreign_keys:
|
||||
target_table = fk.column.table.name
|
||||
if target_table in self.link_table_names:
|
||||
continue
|
||||
|
||||
target_model = self._table_to_model.get(target_table)
|
||||
if not target_model:
|
||||
continue
|
||||
|
||||
key = (
|
||||
model.__name__,
|
||||
col.name,
|
||||
target_model.__name__,
|
||||
fk.column.name,
|
||||
)
|
||||
|
||||
if key not in processed:
|
||||
relations.append(
|
||||
{
|
||||
"fromEntity": model.__name__,
|
||||
"fromField": col.name,
|
||||
"toEntity": target_model.__name__,
|
||||
"toField": fk.column.name,
|
||||
"fromMultiplicity": "N",
|
||||
"toMultiplicity": "1",
|
||||
}
|
||||
)
|
||||
processed.add(key)
|
||||
return relations
|
||||
|
||||
def _collect_m2m_relations(self) -> List[Dict[str, Any]]:
|
||||
relations = []
|
||||
processed: Set[Tuple[str, str]] = set()
|
||||
|
||||
for model in self.db_models:
|
||||
if model.__tablename__ in self.link_table_names:
|
||||
continue
|
||||
|
||||
try:
|
||||
for rel in sa_inspect(model).relationships:
|
||||
if rel.direction.name != "MANYTOMANY":
|
||||
continue
|
||||
|
||||
target_model = rel.mapper.class_
|
||||
if target_model.__tablename__ in self.link_table_names:
|
||||
continue
|
||||
|
||||
pair = tuple(sorted([model.__name__, target_model.__name__]))
|
||||
if pair not in processed:
|
||||
relations.append(
|
||||
{
|
||||
"fromEntity": pair[0],
|
||||
"fromField": "id",
|
||||
"toEntity": pair[1],
|
||||
"toField": "id",
|
||||
"fromMultiplicity": "N",
|
||||
"toMultiplicity": "N",
|
||||
}
|
||||
)
|
||||
processed.add(pair)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return relations
|
||||
|
||||
def _extract_enum_from_annotation(self, annotation) -> Optional[Type[enum.Enum]]:
|
||||
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
||||
return annotation
|
||||
|
||||
origin = get_origin(annotation)
|
||||
if origin is Union:
|
||||
for arg in get_args(annotation):
|
||||
if isinstance(arg, type) and issubclass(arg, enum.Enum):
|
||||
return arg
|
||||
|
||||
return None
|
||||
|
||||
def _get_enum_values(self, model: Type[SQLModel], col) -> Optional[List[str]]:
|
||||
if isinstance(col.type, SAEnum):
|
||||
if col.type.enum_class is not None:
|
||||
return [e.value for e in col.type.enum_class]
|
||||
if col.type.enums:
|
||||
return list(col.type.enums)
|
||||
|
||||
try:
|
||||
annotations = {}
|
||||
for cls in model.__mro__:
|
||||
if hasattr(cls, "__annotations__"):
|
||||
annotations.update(cls.__annotations__)
|
||||
|
||||
if col.name in annotations:
|
||||
annotation = annotations[col.name]
|
||||
enum_class = self._extract_enum_from_annotation(annotation)
|
||||
if enum_class:
|
||||
return [e.value for e in enum_class]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
def generate(self) -> Dict[str, Any]:
|
||||
entities = []
|
||||
|
||||
for model in self.db_models:
|
||||
table_name = model.__tablename__
|
||||
if table_name in self.link_table_names:
|
||||
continue
|
||||
|
||||
columns = sorted(
|
||||
sa_inspect(model).columns,
|
||||
key=lambda c: (
|
||||
0 if c.primary_key else (1 if c.foreign_keys else 2),
|
||||
c.name,
|
||||
),
|
||||
)
|
||||
|
||||
entity_fields = []
|
||||
descriptions = self.field_descriptions.get(model.__name__, {})
|
||||
|
||||
for col in columns:
|
||||
label = col.name
|
||||
if col.primary_key:
|
||||
label += " (PK)"
|
||||
if col.foreign_keys:
|
||||
label += " (FK)"
|
||||
|
||||
field_obj = {"id": col.name, "label": label}
|
||||
|
||||
tooltip_parts = []
|
||||
|
||||
if col.name in descriptions:
|
||||
tooltip_parts.append(descriptions[col.name])
|
||||
|
||||
enum_values = self._get_enum_values(model, col)
|
||||
if enum_values:
|
||||
tooltip_parts.append(
|
||||
"Варианты:\n" + "\n".join(f"• {v}" for v in enum_values)
|
||||
)
|
||||
|
||||
if tooltip_parts:
|
||||
field_obj["tooltip"] = "\n\n".join(tooltip_parts)
|
||||
|
||||
entity_fields.append(field_obj)
|
||||
|
||||
entities.append(
|
||||
{"id": model.__name__, "title": table_name, "fields": entity_fields}
|
||||
)
|
||||
|
||||
relations = self._collect_fk_relations() + self._collect_m2m_relations()
|
||||
return {"entities": entities, "relations": relations}
|
||||
@@ -0,0 +1,81 @@
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
|
||||
TARGET_RATIO = 5 / 7
|
||||
|
||||
|
||||
def crop_image(img: Image.Image, target_ratio: float = TARGET_RATIO) -> Image.Image:
|
||||
w, h = img.size
|
||||
current_ratio = w / h
|
||||
|
||||
if current_ratio > target_ratio:
|
||||
new_w = int(h * target_ratio)
|
||||
left = (w - new_w) // 2
|
||||
right = left + new_w
|
||||
top = 0
|
||||
bottom = h
|
||||
else:
|
||||
new_h = int(w / target_ratio)
|
||||
top = (h - new_h) // 2
|
||||
bottom = top + new_h
|
||||
left = 0
|
||||
right = w
|
||||
|
||||
return img.crop((left, top, right, bottom))
|
||||
|
||||
|
||||
def transcode_image(
|
||||
src_path: str | Path,
|
||||
*,
|
||||
jpeg_quality: int = 85,
|
||||
webp_quality: int = 80,
|
||||
webp_lossless: bool = False,
|
||||
resize_to: tuple[int, int] | None = None,
|
||||
):
|
||||
src_path = Path(src_path)
|
||||
|
||||
if not src_path.exists():
|
||||
raise FileNotFoundError(src_path)
|
||||
|
||||
stem = src_path.stem
|
||||
folder = src_path.parent
|
||||
|
||||
img = Image.open(src_path).convert("RGBA")
|
||||
img = crop_image(img)
|
||||
|
||||
if resize_to:
|
||||
img = img.resize(resize_to, Image.LANCZOS)
|
||||
|
||||
png_path = folder / f"{stem}.png"
|
||||
img.save(
|
||||
png_path,
|
||||
format="PNG",
|
||||
optimize=True,
|
||||
interlace=1,
|
||||
)
|
||||
|
||||
jpg_path = folder / f"{stem}.jpg"
|
||||
img.convert("RGB").save(
|
||||
jpg_path,
|
||||
format="JPEG",
|
||||
quality=jpeg_quality,
|
||||
progressive=True,
|
||||
optimize=True,
|
||||
subsampling="4:2:0",
|
||||
)
|
||||
|
||||
webp_path = folder / f"{stem}.webp"
|
||||
img.save(
|
||||
webp_path,
|
||||
format="WEBP",
|
||||
quality=webp_quality,
|
||||
lossless=webp_lossless,
|
||||
method=6,
|
||||
)
|
||||
|
||||
return {
|
||||
"png": png_path,
|
||||
"jpeg": jpg_path,
|
||||
"webp": webp_path,
|
||||
}
|
||||
+81
-48
@@ -1,5 +1,7 @@
|
||||
"""Модуль настроек проекта"""
|
||||
|
||||
import os, logging
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import FastAPI
|
||||
@@ -8,46 +10,86 @@ from toml import load
|
||||
|
||||
load_dotenv()
|
||||
|
||||
with open("pyproject.toml", 'r', encoding='utf-8') as f:
|
||||
config = load(f)
|
||||
BOOKS_PREVIEW_DIR = Path(__file__).parent / "static" / "books"
|
||||
BOOKS_PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open("pyproject.toml", "r", encoding="utf-8") as 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": "users", "description": "Действия с пользователями."},
|
||||
{"name": "captcha", "description": "Создание и проверка cap.js каптчи."},
|
||||
{"name": "misc", "description": "Прочие."},
|
||||
]
|
||||
|
||||
|
||||
def get_app(lifespan=None, /) -> FastAPI:
|
||||
"""Dependency для получения экземпляра FastAPI application"""
|
||||
if not hasattr(get_app, 'instance'):
|
||||
get_app.instance = FastAPI(
|
||||
title=config["tool"]["poetry"]["name"],
|
||||
description=config["tool"]["poetry"]["description"] + " | [Вернутьсяна главную](/)",
|
||||
version=config["tool"]["poetry"]["version"],
|
||||
"""Возвращает экземпляр FastAPI приложения"""
|
||||
project_cfg = _pyproject["project"]
|
||||
return FastAPI(
|
||||
title=project_cfg["name"],
|
||||
description=f"{project_cfg['description']} | [Вернуться на главную](/)",
|
||||
version=project_cfg["version"],
|
||||
lifespan=lifespan,
|
||||
openapi_tags=[
|
||||
{
|
||||
"name": "authentication",
|
||||
"description": "Авторизация пользователя."
|
||||
},
|
||||
{
|
||||
"name": "authors",
|
||||
"description": "Действия с авторами.",
|
||||
},
|
||||
{
|
||||
"name": "books",
|
||||
"description": "Действия с книгами.",
|
||||
},
|
||||
{
|
||||
"name": "genres",
|
||||
"description": "Действия с жанрами.",
|
||||
},
|
||||
{
|
||||
"name": "relations",
|
||||
"description": "Действия с связями.",
|
||||
},
|
||||
{
|
||||
"name": "misc",
|
||||
"description": "Прочие.",
|
||||
},
|
||||
],
|
||||
openapi_tags=OPENAPI_TAGS,
|
||||
)
|
||||
return get_app.instance
|
||||
|
||||
|
||||
def get_logger(name: str = _APP_NAME) -> logging.Logger:
|
||||
"""Возвращает логгер с указанным именем"""
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Возвращает сессию базы данных"""
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
HOST = os.getenv("POSTGRES_HOST")
|
||||
@@ -56,19 +98,10 @@ USER = os.getenv("POSTGRES_USER")
|
||||
PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||
DATABASE = os.getenv("POSTGRES_DB")
|
||||
|
||||
if not USER or not PASSWORD or not DATABASE or not HOST:
|
||||
raise ValueError("Missing environment variables")
|
||||
OLLAMA_URL = os.getenv("OLLAMA_URL")
|
||||
|
||||
if not all([HOST, PORT, USER, PASSWORD, DATABASE, OLLAMA_URL]):
|
||||
raise ValueError("Missing required POSTGRES environment variables")
|
||||
|
||||
POSTGRES_DATABASE_URL = f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}"
|
||||
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)
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
$(function () {
|
||||
const $loginTab = $("#login-tab");
|
||||
const $registerTab = $("#register-tab");
|
||||
const $loginForm = $("#login-form");
|
||||
const $registerForm = $("#register-form");
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
const $menuContainer = $("#user-menu-container");
|
||||
|
||||
function switchToLogin() {
|
||||
$loginTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
||||
$registerTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
||||
$loginForm.removeClass("hidden"); $registerForm.addClass("hidden");
|
||||
history.replaceState(null, "", "/auth#login");
|
||||
}
|
||||
|
||||
function switchToRegister() {
|
||||
$registerTab.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").removeClass("text-gray-400");
|
||||
$loginTab.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500").addClass("text-gray-400");
|
||||
$registerForm.removeClass("hidden"); $loginForm.addClass("hidden");
|
||||
history.replaceState(null, "", "/auth#register");
|
||||
}
|
||||
|
||||
$loginTab.on("click", switchToLogin);
|
||||
$registerTab.on("click", switchToRegister);
|
||||
|
||||
$("body").on("click", ".toggle-password", function () {
|
||||
const $btn = $(this);
|
||||
const $input = $btn.siblings("input");
|
||||
const $eyeOpen = $btn.find(".eye-open");
|
||||
const $eyeClosed = $btn.find(".eye-closed");
|
||||
|
||||
if ($input.attr("type") === "password") {
|
||||
$input.attr("type", "text");
|
||||
$eyeOpen.addClass("hidden");
|
||||
$eyeClosed.removeClass("hidden");
|
||||
} else {
|
||||
$input.attr("type", "password");
|
||||
$eyeOpen.removeClass("hidden");
|
||||
$eyeClosed.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#register-password").on("input", function () {
|
||||
const password = $(this).val();
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||
if (/\d/.test(password)) strength++;
|
||||
if (/[^a-zA-Z0-9]/.test(password)) strength++;
|
||||
|
||||
const levels = [
|
||||
{ width: "0%", color: "", text: "" },
|
||||
{ width: "20%", color: "bg-red-500", text: "Очень слабый" },
|
||||
{ width: "40%", color: "bg-orange-500", text: "Слабый" },
|
||||
{ width: "60%", color: "bg-yellow-500", text: "Средний" },
|
||||
{ width: "80%", color: "bg-lime-500", text: "Хороший" },
|
||||
{ width: "100%", color: "bg-green-500", text: "Отличный" },
|
||||
];
|
||||
|
||||
const level = levels[strength];
|
||||
const $bar = $("#password-strength-bar");
|
||||
|
||||
$bar.css("width", level.width);
|
||||
$bar.attr("class", "h-full transition-all duration-300 " + level.color);
|
||||
$("#password-strength-text").text(level.text);
|
||||
|
||||
checkPasswordMatch();
|
||||
});
|
||||
|
||||
function checkPasswordMatch() {
|
||||
const password = $("#register-password").val();
|
||||
const confirm = $("#register-password-confirm").val();
|
||||
const $error = $("#password-match-error");
|
||||
|
||||
if (confirm && password !== confirm) {
|
||||
$error.removeClass("hidden");
|
||||
return false;
|
||||
} else {
|
||||
$error.addClass("hidden");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$("#register-password-confirm").on("input", checkPasswordMatch);
|
||||
|
||||
$loginForm.on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const $errorDiv = $("#login-error");
|
||||
const $submitBtn = $("#login-submit");
|
||||
const username = $("#login-username").val();
|
||||
const password = $("#login-password").val();
|
||||
|
||||
$errorDiv.addClass("hidden");
|
||||
$submitBtn.prop("disabled", true).text("Вход...");
|
||||
|
||||
try {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
|
||||
const response = await fetch("/api/auth/token", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
}
|
||||
window.location.href = "/";
|
||||
} else {
|
||||
$errorDiv.text(data.detail || "Неверное имя пользователя или пароль");
|
||||
$errorDiv.removeClass("hidden");
|
||||
$submitBtn.prop("disabled", false).text("Войти");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Login error:", error);
|
||||
$errorDiv.text("Ошибка соединения с сервером");
|
||||
$errorDiv.removeClass("hidden");
|
||||
$submitBtn.prop("disabled", false).text("Войти");
|
||||
}
|
||||
});
|
||||
|
||||
$registerForm.on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const $errorDiv = $("#register-error");
|
||||
const $successDiv = $("#register-success");
|
||||
const $submitBtn = $("#register-submit");
|
||||
|
||||
if (!checkPasswordMatch()) {
|
||||
$errorDiv.text("Пароли не совпадают").removeClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const userData = {
|
||||
username: $("#register-username").val(),
|
||||
email: $("#register-email").val(),
|
||||
full_name: $("#register-fullname").val() || null,
|
||||
password: $("#register-password").val(),
|
||||
};
|
||||
|
||||
$errorDiv.addClass("hidden");
|
||||
$successDiv.addClass("hidden");
|
||||
$submitBtn.prop("disabled", true).text("Регистрация...");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(userData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
$successDiv.text("Регистрация успешна! Переключаемся на вход...").removeClass("hidden");
|
||||
setTimeout(() => {
|
||||
$("#login-username").val(userData.username);
|
||||
switchToLogin();
|
||||
}, 2000);
|
||||
} else {
|
||||
let errorMessage = data.detail;
|
||||
if (Array.isArray(data.detail)) {
|
||||
errorMessage = data.detail.map((err) => err.msg).join(". ");
|
||||
}
|
||||
$errorDiv.text(errorMessage || "Ошибка регистрации").removeClass("hidden");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Register error:", error);
|
||||
$errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
||||
}
|
||||
});
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/";
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById('user-avatar');
|
||||
if (avatarImg) { avatarImg.src = avatarUrl; }
|
||||
}
|
||||
|
||||
if (window.location.hash === "#register") { switchToRegister(); }
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById('user-btn').classList.remove('hidden');
|
||||
document.getElementById('guest-link').classList.add('hidden');
|
||||
if (window.location.pathname === "/auth") { window.location.href = "/"; }
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,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 |
@@ -1,560 +0,0 @@
|
||||
$(document).ready(function () {
|
||||
let selectedAuthors = new Map(); // Map<id, name>
|
||||
let selectedGenres = new Map(); // Map<id, name>
|
||||
let currentPage = 1;
|
||||
let pageSize = 20;
|
||||
let totalBooks = 0;
|
||||
|
||||
Promise.all([
|
||||
fetch("/api/authors").then((response) => response.json()),
|
||||
fetch("/api/genres").then((response) => response.json()),
|
||||
])
|
||||
.then(([authorsData, genresData]) => {
|
||||
const $dropdown = $("#author-dropdown");
|
||||
authorsData.authors.forEach((author) => {
|
||||
$("<div>")
|
||||
.addClass("p-2 hover:bg-gray-100 cursor-pointer author-item")
|
||||
.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();
|
||||
})
|
||||
.catch((error) => console.error("Error loading data:", error));
|
||||
|
||||
// === Функция загрузки книг ===
|
||||
function loadBooks() {
|
||||
const searchQuery = $("#book-search-input").val().trim();
|
||||
|
||||
// Формируем URL с параметрами
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Добавляем поиск (минимум 3 символа)
|
||||
if (searchQuery.length >= 3) {
|
||||
params.append("q", searchQuery);
|
||||
}
|
||||
|
||||
// Добавляем авторов
|
||||
selectedAuthors.forEach((name, id) => {
|
||||
params.append("author_ids", id);
|
||||
});
|
||||
|
||||
// Добавляем жанры
|
||||
selectedGenres.forEach((name, id) => {
|
||||
params.append("genre_ids", id);
|
||||
});
|
||||
|
||||
// Пагинация
|
||||
params.append("page", currentPage);
|
||||
params.append("size", pageSize);
|
||||
|
||||
const url = `/api/books/filter?${params.toString()}`;
|
||||
|
||||
// Показываем индикатор загрузки
|
||||
showLoadingState();
|
||||
|
||||
fetch(url)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
totalBooks = data.total;
|
||||
renderBooks(data.books);
|
||||
renderPagination();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading books:", error);
|
||||
showErrorState();
|
||||
});
|
||||
}
|
||||
|
||||
// === Отображение книг ===
|
||||
function renderBooks(books) {
|
||||
const $container = $("#books-container");
|
||||
$container.empty();
|
||||
|
||||
if (books.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="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;
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
const authorsText =
|
||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||
const genresText =
|
||||
book.genres.map((g) => g.name).join(", ") || "Без жанра";
|
||||
|
||||
const $bookCard = $(`
|
||||
<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}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<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);
|
||||
});
|
||||
|
||||
// Обработчик клика на карточку книги
|
||||
$container.on("click", ".book-card", function () {
|
||||
const bookId = $(this).data("id");
|
||||
window.location.href = `/books/${bookId}`;
|
||||
});
|
||||
}
|
||||
|
||||
// === Пагинация ===
|
||||
function renderPagination() {
|
||||
// Удаляем старую пагинацию
|
||||
$("#pagination-container").remove();
|
||||
|
||||
const totalPages = Math.ceil(totalBooks / pageSize);
|
||||
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const $pagination = $(`
|
||||
<div id="pagination-container" 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>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
$("#books-container").after($pagination);
|
||||
|
||||
// Обработчики пагинации
|
||||
$("#prev-page").on("click", function () {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$("#next-page").on("click", function () {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$(".page-btn").on("click", function () {
|
||||
const page = parseInt($(this).data("page"));
|
||||
if (page !== currentPage) {
|
||||
currentPage = page;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = $("#books-container");
|
||||
$container.html(`
|
||||
<div class="space-y-4">
|
||||
${Array(3)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
<div 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>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState() {
|
||||
const $container = $("#books-container");
|
||||
$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", loadBooks);
|
||||
}
|
||||
|
||||
// === Экранирование HTML ===
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// === Dropdown авторов ===
|
||||
function initializeAuthorDropdown() {
|
||||
const $input = $("#author-search-input");
|
||||
const $dropdown = $("#author-dropdown");
|
||||
const $container = $("#selected-authors-container");
|
||||
|
||||
function updateHighlights() {
|
||||
$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");
|
||||
});
|
||||
|
||||
$(document).on("click", (e) => {
|
||||
if (
|
||||
!$(e.target).closest("#selected-authors-container, #author-dropdown")
|
||||
.length
|
||||
) {
|
||||
$dropdown.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$dropdown.on("click", ".author-item", function (e) {
|
||||
e.stopPropagation();
|
||||
toggleAuthor($(this).attr("data-id"), $(this).attr("data-name"));
|
||||
$input.focus();
|
||||
});
|
||||
|
||||
$container.on("click", ".remove-author", function (e) {
|
||||
e.stopPropagation();
|
||||
selectedAuthors.delete(parseInt($(this).attr("data-id")));
|
||||
renderChips();
|
||||
$input.focus();
|
||||
});
|
||||
|
||||
$container.on("click", (e) => {
|
||||
if (!$(e.target).closest(".author-chip").length) {
|
||||
$input.focus();
|
||||
}
|
||||
});
|
||||
|
||||
window.renderAuthorChips = renderChips;
|
||||
window.updateAuthorHighlights = updateHighlights;
|
||||
}
|
||||
|
||||
// === Инициализация фильтров ===
|
||||
function initializeFilters() {
|
||||
const $bookSearch = $("#book-search-input");
|
||||
const $applyBtn = $("#apply-filters-btn");
|
||||
const $resetBtn = $("#reset-filters-btn");
|
||||
|
||||
// Обработка жанров
|
||||
$("#genres-list").on("change", "input[type='checkbox']", function () {
|
||||
const id = parseInt($(this).attr("data-id"));
|
||||
const name = $(this).attr("data-name");
|
||||
if ($(this).is(":checked")) {
|
||||
selectedGenres.set(id, name);
|
||||
} else {
|
||||
selectedGenres.delete(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Применить фильтры
|
||||
$applyBtn.on("click", function () {
|
||||
currentPage = 1; // Сбрасываем на первую страницу
|
||||
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();
|
||||
|
||||
// Автопоиск только если >= 3 символов или пусто
|
||||
if (query.length >= 3 || query.length === 0) {
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
// Поиск по Enter
|
||||
$bookSearch.on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
clearTimeout(searchTimeout);
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// === Остальной код (пользователь/авторизация) ===
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,463 @@
|
||||
$(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/enable", {
|
||||
data: {
|
||||
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);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
$(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);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,598 @@
|
||||
$(() => {
|
||||
const SELECTORS = {
|
||||
loginForm: "#login-form",
|
||||
registerForm: "#register-form",
|
||||
resetForm: "#reset-password-form",
|
||||
authTabs: "#auth-tabs",
|
||||
loginTab: "#login-tab",
|
||||
registerTab: "#register-tab",
|
||||
forgotBtn: "#forgot-password-btn",
|
||||
backToLoginBtn: "#back-to-login-btn",
|
||||
backToCredentialsBtn: "#back-to-credentials-btn",
|
||||
submitLogin: "#login-submit",
|
||||
submitRegister: "#register-submit",
|
||||
submitReset: "#reset-submit",
|
||||
usernameLogin: "#login-username",
|
||||
passwordLogin: "#login-password",
|
||||
totpInput: "#login-totp",
|
||||
rememberMe: "#remember-me",
|
||||
credentialsSection: "#credentials-section",
|
||||
totpSection: "#totp-section",
|
||||
registerUsername: "#register-username",
|
||||
registerEmail: "#register-email",
|
||||
registerFullname: "#register-fullname",
|
||||
registerPassword: "#register-password",
|
||||
registerConfirm: "#register-password-confirm",
|
||||
passwordStrengthBar: "#password-strength-bar",
|
||||
passwordStrengthText: "#password-strength-text",
|
||||
passwordMatchError: "#password-match-error",
|
||||
resetUsername: "#reset-username",
|
||||
resetCode: "#reset-recovery-code",
|
||||
resetNewPassword: "#reset-new-password",
|
||||
resetConfirmPassword: "#reset-confirm-password",
|
||||
resetMatchError: "#reset-password-match-error",
|
||||
recoveryModal: "#recovery-codes-modal",
|
||||
recoveryList: "#recovery-codes-list",
|
||||
codesSavedCheckbox: "#codes-saved-checkbox",
|
||||
closeRecoveryBtn: "#close-recovery-modal-btn",
|
||||
copyCodesBtn: "#copy-codes-btn",
|
||||
downloadCodesBtn: "#download-codes-btn",
|
||||
gotoLoginAfterReset: "#goto-login-after-reset",
|
||||
capWidget: "#cap",
|
||||
lockProgressCircle: "#lock-progress-circle",
|
||||
};
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
partialToken: "partial_token",
|
||||
partialUsername: "partial_username",
|
||||
};
|
||||
|
||||
const TEXTS = {
|
||||
login: "Войти",
|
||||
confirm: "Подтвердить",
|
||||
checking: "Проверка...",
|
||||
registering: "Регистрация...",
|
||||
resetting: "Сброс...",
|
||||
enterTotp: "Введите код из приложения аутентификатора",
|
||||
sessionExpired: "Время сессии истекло. Пожалуйста, войдите заново.",
|
||||
invalidCode: "Неверный код",
|
||||
passwordsNotMatch: "Пароли не совпадают",
|
||||
captchaRequired: "Пожалуйста, пройдите проверку Captcha",
|
||||
registrationSuccess: "Регистрация успешна! Войдите в систему.",
|
||||
codesCopied: "Коды скопированы в буфер обмена",
|
||||
codesDownloaded: "Файл с кодами скачан",
|
||||
passwordResetSuccess: "Пароль успешно изменён!",
|
||||
invalidRecoveryCode: "Неверный формат резервного кода",
|
||||
passwordTooShort: "Пароль должен содержать минимум 8 символов",
|
||||
};
|
||||
|
||||
const TOTP_PERIOD = 30;
|
||||
const CIRCLE_CIRCUMFERENCE = 2 * Math.PI * 38;
|
||||
|
||||
let loginState = {
|
||||
step: "credentials",
|
||||
partialToken: null,
|
||||
username: "",
|
||||
rememberMe: false,
|
||||
};
|
||||
|
||||
let registeredRecoveryCodes = [];
|
||||
let totpAnimationFrame = null;
|
||||
|
||||
const getTotpProgress = () => {
|
||||
const now = Date.now() / 1000;
|
||||
const elapsed = now % TOTP_PERIOD;
|
||||
return elapsed / TOTP_PERIOD;
|
||||
};
|
||||
|
||||
const updateTotpTimer = () => {
|
||||
const circle = $(SELECTORS.lockProgressCircle).get(0);
|
||||
if (!circle) return;
|
||||
const progress = getTotpProgress();
|
||||
const offset = CIRCLE_CIRCUMFERENCE * (1 - progress);
|
||||
circle.style.strokeDashoffset = offset;
|
||||
totpAnimationFrame = requestAnimationFrame(updateTotpTimer);
|
||||
};
|
||||
|
||||
const startTotpTimer = () => {
|
||||
stopTotpTimer();
|
||||
updateTotpTimer();
|
||||
};
|
||||
|
||||
const stopTotpTimer = () => {
|
||||
if (totpAnimationFrame) {
|
||||
cancelAnimationFrame(totpAnimationFrame);
|
||||
totpAnimationFrame = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCircle = () => {
|
||||
const circle = $(SELECTORS.lockProgressCircle).get(0);
|
||||
if (circle) circle.style.strokeDashoffset = CIRCLE_CIRCUMFERENCE;
|
||||
};
|
||||
|
||||
const savePartialToken = (token, username) => {
|
||||
sessionStorage.setItem(STORAGE_KEYS.partialToken, token);
|
||||
sessionStorage.setItem(STORAGE_KEYS.partialUsername, username);
|
||||
};
|
||||
|
||||
const clearPartialToken = () => {
|
||||
sessionStorage.removeItem(STORAGE_KEYS.partialToken);
|
||||
sessionStorage.removeItem(STORAGE_KEYS.partialUsername);
|
||||
};
|
||||
|
||||
const showForm = (formId) => {
|
||||
let newHash = "";
|
||||
if (formId === SELECTORS.loginForm) newHash = "login";
|
||||
else if (formId === SELECTORS.registerForm) newHash = "register";
|
||||
else if (formId === SELECTORS.resetForm) newHash = "reset";
|
||||
if (newHash && window.location.hash !== "#" + newHash) {
|
||||
window.history.pushState(null, null, "#" + newHash);
|
||||
}
|
||||
$(
|
||||
`${SELECTORS.loginForm}, ${SELECTORS.registerForm}, ${SELECTORS.resetForm}`,
|
||||
).addClass("hidden");
|
||||
$(formId).removeClass("hidden");
|
||||
|
||||
$(`${SELECTORS.loginTab}, ${SELECTORS.registerTab}`)
|
||||
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
|
||||
.addClass("text-gray-400 hover:text-gray-600");
|
||||
|
||||
if (formId === SELECTORS.loginForm) {
|
||||
$(SELECTORS.loginTab)
|
||||
.removeClass("text-gray-400 hover:text-gray-600")
|
||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||
resetLoginState();
|
||||
} else if (formId === SELECTORS.registerForm) {
|
||||
$(SELECTORS.registerTab)
|
||||
.removeClass("text-gray-400 hover:text-gray-600")
|
||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||
}
|
||||
};
|
||||
|
||||
const handleHash = () => {
|
||||
const hash = window.location.hash.toLowerCase();
|
||||
if (hash === "#register" || hash === "#signup") {
|
||||
showForm(SELECTORS.registerForm);
|
||||
$(SELECTORS.registerTab).trigger("click");
|
||||
} else if (hash === "#login" || hash === "#signin") {
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.loginTab).trigger("click");
|
||||
}
|
||||
};
|
||||
|
||||
const resetLoginState = () => {
|
||||
clearPartialToken();
|
||||
stopTotpTimer();
|
||||
loginState = {
|
||||
step: "credentials",
|
||||
partialToken: null,
|
||||
username: "",
|
||||
rememberMe: false,
|
||||
};
|
||||
$(SELECTORS.authTabs).removeClass("hide-animated");
|
||||
$(SELECTORS.totpSection).addClass("hidden");
|
||||
$(SELECTORS.totpInput).val("");
|
||||
$(SELECTORS.credentialsSection).removeClass("hidden");
|
||||
$(SELECTORS.submitLogin).text(TEXTS.login);
|
||||
resetCircle();
|
||||
};
|
||||
|
||||
const checkPasswordMatch = (passwordId, confirmId, errorId) => {
|
||||
const password = $(passwordId).val();
|
||||
const confirm = $(confirmId).val();
|
||||
const $error = $(errorId);
|
||||
if (confirm && password !== confirm) {
|
||||
$error.removeClass("hidden");
|
||||
return false;
|
||||
}
|
||||
$error.addClass("hidden");
|
||||
return true;
|
||||
};
|
||||
|
||||
const saveTokensAndRedirect = (data, rememberMe) => {
|
||||
const storage = rememberMe ? localStorage : sessionStorage;
|
||||
const otherStorage = rememberMe ? sessionStorage : localStorage;
|
||||
storage.setItem("access_token", data.access_token);
|
||||
if (data.refresh_token)
|
||||
storage.setItem("refresh_token", data.refresh_token);
|
||||
otherStorage.removeItem("access_token");
|
||||
otherStorage.removeItem("refresh_token");
|
||||
window.location.href = "/";
|
||||
};
|
||||
|
||||
const initLoginState = () => {
|
||||
const savedToken = sessionStorage.getItem(STORAGE_KEYS.partialToken);
|
||||
const savedUsername = sessionStorage.getItem(STORAGE_KEYS.partialUsername);
|
||||
if (savedToken && savedUsername) {
|
||||
$(SELECTORS.authTabs).addClass("hide-animated");
|
||||
loginState.partialToken = savedToken;
|
||||
loginState.username = savedUsername;
|
||||
loginState.step = "2fa";
|
||||
$(SELECTORS.usernameLogin).val(savedUsername);
|
||||
$(SELECTORS.credentialsSection).addClass("hidden");
|
||||
$(SELECTORS.totpSection).removeClass("hidden");
|
||||
$(SELECTORS.submitLogin).text(TEXTS.confirm);
|
||||
startTotpTimer();
|
||||
setTimeout(() => $(SELECTORS.totpInput).get(0)?.focus(), 100);
|
||||
}
|
||||
};
|
||||
|
||||
$(SELECTORS.loginTab).on("click", () => showForm(SELECTORS.loginForm));
|
||||
$(SELECTORS.registerTab).on("click", () => showForm(SELECTORS.registerForm));
|
||||
$(SELECTORS.forgotBtn).on("click", () => showForm(SELECTORS.resetForm));
|
||||
$(SELECTORS.backToLoginBtn).on("click", () => showForm(SELECTORS.loginForm));
|
||||
$(SELECTORS.backToCredentialsBtn).on("click", resetLoginState);
|
||||
|
||||
$("body").on("click", ".toggle-password", function () {
|
||||
const $input = $(this).siblings("input");
|
||||
const isPassword = $input.attr("type") === "password";
|
||||
$input.attr("type", isPassword ? "text" : "password");
|
||||
$(this).find("svg").toggleClass("hidden");
|
||||
});
|
||||
|
||||
$(SELECTORS.registerPassword).on("input", function () {
|
||||
const password = $(this).val();
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (password.length >= 12) strength++;
|
||||
if (/[a-z]/.test(password) && /[A-Z]/.test(password)) strength++;
|
||||
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];
|
||||
$(SELECTORS.passwordStrengthBar)
|
||||
.css("width", level.width)
|
||||
.attr("class", `h-full transition-all duration-300 ${level.color}`);
|
||||
$(SELECTORS.passwordStrengthText).text(level.text);
|
||||
checkPasswordMatch(
|
||||
SELECTORS.registerPassword,
|
||||
SELECTORS.registerConfirm,
|
||||
SELECTORS.passwordMatchError,
|
||||
);
|
||||
});
|
||||
|
||||
$(SELECTORS.registerConfirm).on("input", () =>
|
||||
checkPasswordMatch(
|
||||
SELECTORS.registerPassword,
|
||||
SELECTORS.registerConfirm,
|
||||
SELECTORS.passwordMatchError,
|
||||
),
|
||||
);
|
||||
|
||||
$(SELECTORS.resetCode).on("input", function () {
|
||||
let value = this.value.toUpperCase().replace(/[^0-9A-F]/g, "");
|
||||
let formatted = "";
|
||||
for (let i = 0; i < value.length && i < 16; i++) {
|
||||
if (i > 0 && i % 4 === 0) formatted += "-";
|
||||
formatted += value[i];
|
||||
}
|
||||
this.value = formatted;
|
||||
});
|
||||
|
||||
$(SELECTORS.totpInput).on("input", function () {
|
||||
this.value = this.value.replace(/\D/g, "").slice(0, 6);
|
||||
if (this.value.length === 6) $(SELECTORS.loginForm).trigger("submit");
|
||||
});
|
||||
|
||||
$(SELECTORS.loginForm).on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
const $submitBtn = $(SELECTORS.submitLogin);
|
||||
if (loginState.step === "credentials") {
|
||||
const username = $(SELECTORS.usernameLogin).val();
|
||||
const password = $(SELECTORS.passwordLogin).val();
|
||||
const rememberMe = $(SELECTORS.rememberMe).prop("checked");
|
||||
loginState.username = username;
|
||||
loginState.rememberMe = rememberMe;
|
||||
$submitBtn.prop("disabled", true).text("Вход...");
|
||||
try {
|
||||
const formData = new URLSearchParams({ username, password });
|
||||
const data = await Api.postForm("/api/auth/token", formData);
|
||||
if (data.requires_2fa && data.partial_token) {
|
||||
loginState.partialToken = data.partial_token;
|
||||
loginState.step = "2fa";
|
||||
savePartialToken(data.partial_token, username);
|
||||
$(SELECTORS.authTabs).addClass("hide-animated");
|
||||
$(SELECTORS.credentialsSection).addClass("hidden");
|
||||
$(SELECTORS.totpSection).removeClass("hidden");
|
||||
startTotpTimer();
|
||||
$(SELECTORS.totpInput).get(0)?.focus();
|
||||
$submitBtn.text(TEXTS.confirm);
|
||||
Utils.showToast(TEXTS.enterTotp, "info");
|
||||
} else if (data.access_token) {
|
||||
clearPartialToken();
|
||||
saveTokensAndRedirect(data, rememberMe);
|
||||
}
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || "Ошибка входа", "error");
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false);
|
||||
if (loginState.step === "credentials") $submitBtn.text(TEXTS.login);
|
||||
}
|
||||
} else if (loginState.step === "2fa") {
|
||||
const totpCode = $(SELECTORS.totpInput).val();
|
||||
if (!totpCode || totpCode.length !== 6) {
|
||||
Utils.showToast("Введите 6-значный код", "error");
|
||||
return;
|
||||
}
|
||||
$submitBtn.prop("disabled", true).text(TEXTS.checking);
|
||||
try {
|
||||
const response = await fetch("/api/auth/2fa/verify", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${loginState.partialToken}`,
|
||||
},
|
||||
body: JSON.stringify({ code: totpCode }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
if (response.status === 401) {
|
||||
resetLoginState();
|
||||
throw new Error(TEXTS.sessionExpired);
|
||||
}
|
||||
throw new Error(errorData.detail || TEXTS.invalidCode);
|
||||
}
|
||||
const data = await response.json();
|
||||
clearPartialToken();
|
||||
stopTotpTimer();
|
||||
saveTokensAndRedirect(data, loginState.rememberMe);
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || TEXTS.invalidCode, "error");
|
||||
$(SELECTORS.totpInput).val("");
|
||||
$(SELECTORS.totpInput).get(0)?.focus();
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false).text(TEXTS.confirm);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(SELECTORS.registerForm).on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
const $submitBtn = $(SELECTORS.submitRegister);
|
||||
const pass = $(SELECTORS.registerPassword).val();
|
||||
const confirm = $(SELECTORS.registerConfirm).val();
|
||||
if (pass !== confirm) {
|
||||
Utils.showToast(TEXTS.passwordsNotMatch, "error");
|
||||
return;
|
||||
}
|
||||
const userData = {
|
||||
username: $(SELECTORS.registerUsername).val(),
|
||||
email: $(SELECTORS.registerEmail).val(),
|
||||
full_name: $(SELECTORS.registerFullname).val() || null,
|
||||
password: pass,
|
||||
};
|
||||
$submitBtn.prop("disabled", true).text(TEXTS.registering);
|
||||
try {
|
||||
const response = await Api.post("/api/auth/register", userData);
|
||||
if (response.recovery_codes && response.recovery_codes.codes) {
|
||||
registeredRecoveryCodes = response.recovery_codes.codes;
|
||||
showRecoveryCodesModal(registeredRecoveryCodes, userData.username);
|
||||
} else {
|
||||
Utils.showToast(TEXTS.registrationSuccess, "success");
|
||||
setTimeout(() => {
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.usernameLogin).val(userData.username);
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Debug error object:", error);
|
||||
|
||||
const cleanMsg = (text) => {
|
||||
if (!text) return "";
|
||||
if (text.includes("value is not a valid email address")) {
|
||||
return "Некорректный адрес электронной почты";
|
||||
}
|
||||
|
||||
text = text.replace(/^Value error,\s*/i, "");
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
};
|
||||
|
||||
let msg = "Ошибка регистрации";
|
||||
if (error.detail && error.detail.error === "captcha_required") {
|
||||
Utils.showToast(TEXTS.captchaRequired, "error");
|
||||
const $capElement = $(SELECTORS.capWidget);
|
||||
const $parent = $capElement.parent();
|
||||
$capElement.remove();
|
||||
$parent.append(
|
||||
`<cap-widget id="cap" data-cap-api-endpoint="/api/cap/" style="--cap-widget-width: 100%;"></cap-widget>`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.detail && Array.isArray(error.detail)) {
|
||||
msg = error.detail.map((e) => cleanMsg(e.msg)).join(". ");
|
||||
} else if (Array.isArray(error)) {
|
||||
msg = error.map((e) => cleanMsg(e.msg || e.message)).join(". ");
|
||||
} else if (typeof error.detail === "string") {
|
||||
msg = cleanMsg(error.detail);
|
||||
} else if (error.message && !error.message.includes("[object Object]")) {
|
||||
msg = cleanMsg(error.message);
|
||||
}
|
||||
|
||||
console.log("Resulting msg:", msg);
|
||||
Utils.showToast(msg, "error");
|
||||
} finally {
|
||||
$submitBtn
|
||||
.prop("disabled", false)
|
||||
.text(TEXTS.registering.replace("...", ""));
|
||||
}
|
||||
});
|
||||
|
||||
const showRecoveryCodesModal = (codes, username) => {
|
||||
const $list = $(SELECTORS.recoveryList);
|
||||
$list.empty();
|
||||
codes.forEach((code, index) => {
|
||||
$list.append(
|
||||
`<div class="py-1 px-2 bg-white rounded border select-all font-mono">${index + 1}. ${Utils.escapeHtml(code)}</div>`,
|
||||
);
|
||||
});
|
||||
$(SELECTORS.codesSavedCheckbox).prop("checked", false);
|
||||
$(SELECTORS.closeRecoveryBtn).prop("disabled", true);
|
||||
$(SELECTORS.recoveryModal).data("username", username).removeClass("hidden");
|
||||
};
|
||||
|
||||
const renderRecoveryCodesStatus = (usedCodes) => {
|
||||
return usedCodes
|
||||
.map((used, index) => {
|
||||
const codeDisplay = "████-████-████-████";
|
||||
const statusClass = used
|
||||
? "text-gray-300 line-through"
|
||||
: "text-green-600";
|
||||
const statusIcon = used ? "✗" : "✓";
|
||||
return `<div class="flex items-center justify-between py-1 px-2 rounded ${used ? "bg-gray-50" : "bg-green-50"}"><span class="font-mono ${statusClass}">${index + 1}. ${codeDisplay}</span><span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span></div>`;
|
||||
})
|
||||
.join("");
|
||||
};
|
||||
|
||||
$(SELECTORS.codesSavedCheckbox).on("change", function () {
|
||||
$(SELECTORS.closeRecoveryBtn).prop("disabled", !this.checked);
|
||||
});
|
||||
|
||||
$(SELECTORS.copyCodesBtn).on("click", function () {
|
||||
const codesText = registeredRecoveryCodes.join("\n");
|
||||
navigator.clipboard
|
||||
.writeText(codesText)
|
||||
.then(() => Utils.showToast(TEXTS.codesCopied, "success"));
|
||||
});
|
||||
|
||||
$(SELECTORS.downloadCodesBtn).on("click", function () {
|
||||
const username = $(SELECTORS.recoveryModal).data("username") || "user";
|
||||
const codesText = `Резервные коды для аккаунта: ${username}\nДата: ${new Date().toLocaleString()}\n\n${registeredRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}\n\nХраните эти коды в надёжном месте!`;
|
||||
const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `recovery-codes-${username}.txt`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
Utils.showToast(TEXTS.codesDownloaded, "success");
|
||||
});
|
||||
|
||||
$(SELECTORS.closeRecoveryBtn).on("click", function () {
|
||||
const username = $(SELECTORS.recoveryModal).data("username");
|
||||
$(SELECTORS.recoveryModal).addClass("hidden");
|
||||
Utils.showToast(TEXTS.registrationSuccess, "success");
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.usernameLogin).val(username);
|
||||
});
|
||||
|
||||
$(SELECTORS.resetConfirmPassword).on("input", () =>
|
||||
checkPasswordMatch(
|
||||
SELECTORS.resetNewPassword,
|
||||
SELECTORS.resetConfirmPassword,
|
||||
SELECTORS.resetMatchError,
|
||||
),
|
||||
);
|
||||
|
||||
$(SELECTORS.resetForm).on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
const $submitBtn = $(SELECTORS.submitReset);
|
||||
const newPassword = $(SELECTORS.resetNewPassword).val();
|
||||
const confirmPassword = $(SELECTORS.resetConfirmPassword).val();
|
||||
if (newPassword !== confirmPassword) {
|
||||
Utils.showToast(TEXTS.passwordsNotMatch, "error");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
Utils.showToast(TEXTS.passwordTooShort, "error");
|
||||
return;
|
||||
}
|
||||
const data = {
|
||||
username: $(SELECTORS.resetUsername).val(),
|
||||
recovery_code: $(SELECTORS.resetCode).val().toUpperCase(),
|
||||
new_password: newPassword,
|
||||
};
|
||||
if (
|
||||
!/^[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}$/.test(
|
||||
data.recovery_code,
|
||||
)
|
||||
) {
|
||||
Utils.showToast(TEXTS.invalidRecoveryCode, "error");
|
||||
return;
|
||||
}
|
||||
$submitBtn.prop("disabled", true).text(TEXTS.resetting);
|
||||
try {
|
||||
const response = await Api.post("/api/auth/password/reset", data);
|
||||
showPasswordResetResult(response, data.username);
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || "Ошибка сброса пароля", "error");
|
||||
$submitBtn.prop("disabled", false).text("Сбросить пароль");
|
||||
}
|
||||
});
|
||||
|
||||
const showPasswordResetResult = (response, username) => {
|
||||
const $form = $(SELECTORS.resetForm);
|
||||
$form.html(`
|
||||
<div class="text-center mb-4">
|
||||
<div class="w-16 h-16 mx-auto bg-green-100 rounded-full flex items-center justify-center mb-3">
|
||||
<svg class="w-8 h-8 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 font-semibold text-gray-800">${TEXTS.passwordResetSuccess}</h3>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<p class="text-sm text-gray-600 mb-2 text-center">
|
||||
Осталось резервных кодов: <strong class="${response.remaining <= 2 ? "text-red-600" : "text-green-600"}">${response.remaining}</strong> из ${response.total}
|
||||
</p>
|
||||
${
|
||||
response.should_regenerate
|
||||
? `
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-3">
|
||||
<p class="text-sm text-yellow-800 flex items-center gap-2">
|
||||
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
</svg>
|
||||
Рекомендуем сгенерировать новые коды в профиле
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
<div class="bg-gray-50 rounded-lg p-3 space-y-1 max-h-48 overflow-y-auto">
|
||||
<p class="text-xs text-gray-500 mb-2 text-center">Статус резервных кодов:</p>
|
||||
${renderRecoveryCodesStatus(response.used_codes)}
|
||||
</div>
|
||||
${
|
||||
response.generated_at
|
||||
? `
|
||||
<p class="text-xs text-gray-400 mt-2 text-center">
|
||||
Сгенерированы: ${new Date(response.generated_at).toLocaleString()}
|
||||
</p>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
<button type="button" id="goto-login-after-reset" class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
|
||||
Перейти к входу
|
||||
</button>
|
||||
`);
|
||||
$form.off("submit");
|
||||
$("#goto-login-after-reset").on("click", function () {
|
||||
location.reload();
|
||||
setTimeout(() => {
|
||||
showForm(SELECTORS.loginForm);
|
||||
$(SELECTORS.usernameLogin).val(username);
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
initLoginState();
|
||||
handleHash();
|
||||
|
||||
const widget = $(SELECTORS.capWidget).get(0);
|
||||
if (widget && widget.shadowRoot) {
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `.credits { right: 20px !important; }`;
|
||||
$(widget.shadowRoot).append(style);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
$(document).ready(() => {
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const authorId = pathParts[pathParts.length - 1];
|
||||
|
||||
if (!authorId || isNaN(authorId)) {
|
||||
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;
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
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-desc").textContent =
|
||||
book.description || "Описание отсутствует";
|
||||
|
||||
$container.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
$("#books-container").on("click", ".book-card", function () {
|
||||
window.location.href = `/book/${$(this).data("id")}`;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
$(document).ready(() => {
|
||||
let allAuthors = [];
|
||||
let filteredAuthors = [];
|
||||
let currentPage = 1;
|
||||
let pageSize = 24;
|
||||
let currentSort = "name_asc";
|
||||
|
||||
loadAuthors();
|
||||
const USER_CAN_MANAGE =
|
||||
typeof window.canManage === "function" && window.canManage();
|
||||
if (USER_CAN_MANAGE) {
|
||||
$("#add-author-btn").removeClass("hidden");
|
||||
}
|
||||
|
||||
function loadAuthors() {
|
||||
showLoadingState();
|
||||
|
||||
Api.get("/api/authors")
|
||||
.then((data) => {
|
||||
allAuthors = data.authors;
|
||||
applyFiltersAndSort();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Не удалось загрузить авторов", "error");
|
||||
$("#authors-container").empty();
|
||||
});
|
||||
}
|
||||
|
||||
function applyFiltersAndSort() {
|
||||
const searchQuery = $("#author-search-input").val().trim().toLowerCase();
|
||||
|
||||
filteredAuthors = allAuthors.filter((author) =>
|
||||
author.name.toLowerCase().includes(searchQuery),
|
||||
);
|
||||
|
||||
filteredAuthors.sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase();
|
||||
const nameB = b.name.toLowerCase();
|
||||
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;
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const pageAuthors = filteredAuthors.slice(
|
||||
startIndex,
|
||||
startIndex + pageSize,
|
||||
);
|
||||
|
||||
pageAuthors.forEach((author) => {
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
const card = clone.querySelector(".author-card");
|
||||
|
||||
card.dataset.id = author.id;
|
||||
clone.querySelector(".author-name").textContent = author.name;
|
||||
clone.querySelector(".author-id").textContent = `ID: ${author.id}`;
|
||||
clone.querySelector(".author-avatar").textContent = author.name
|
||||
.charAt(0)
|
||||
.toUpperCase();
|
||||
|
||||
$container.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
$("#pagination-container").empty();
|
||||
const totalPages = Math.ceil(filteredAuthors.length / pageSize);
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const $pagination = $(`
|
||||
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
|
||||
<button id="prev-page" class="px-3 py-2 bg-white border rounded-lg hover:bg-gray-50" ${currentPage === 1 ? "disabled" : ""}>←</button>
|
||||
<div id="page-numbers" class="flex gap-1"></div>
|
||||
<button id="next-page" class="px-3 py-2 bg-white border rounded-lg hover:bg-gray-50" ${currentPage === totalPages ? "disabled" : ""}>→</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const $pageNumbers = $pagination.find("#page-numbers");
|
||||
const pages = [];
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (
|
||||
i === 1 ||
|
||||
i === totalPages ||
|
||||
(i >= currentPage - 2 && i <= currentPage + 2)
|
||||
) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== "...") {
|
||||
pages.push("...");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
renderPagination();
|
||||
scrollToTop();
|
||||
});
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
$("#authors-container").html(`
|
||||
${Array(6)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse flex items-center">
|
||||
<div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
`);
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||
}
|
||||
|
||||
$("#author-search-input").on("input", function () {
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
|
||||
$('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")}`;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,848 @@
|
||||
$(document).ready(() => {
|
||||
const STATUS_CONFIG = {
|
||||
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 isDraggingOver = false;
|
||||
let currentBook = null;
|
||||
let cachedUsers = null;
|
||||
let selectedLoanUserId = null;
|
||||
let activeLoan = null;
|
||||
|
||||
init();
|
||||
|
||||
function init() {
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
Utils.showToast("Некорректный ID книги", "error");
|
||||
return;
|
||||
}
|
||||
loadBookData();
|
||||
setupEventHandlers();
|
||||
setupCoverUpload();
|
||||
}
|
||||
|
||||
function getPreviewUrl(book) {
|
||||
if (!book.preview_urls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const priorities = ["webp", "jpeg", "jpg", "png"];
|
||||
|
||||
for (const format of priorities) {
|
||||
if (book.preview_urls[format]) {
|
||||
return book.preview_urls[format];
|
||||
}
|
||||
}
|
||||
|
||||
const availableFormats = Object.keys(book.preview_urls);
|
||||
if (availableFormats.length > 0) {
|
||||
return book.preview_urls[availableFormats[0]];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 setupCoverUpload() {
|
||||
const $container = $("#book-cover-container");
|
||||
const $fileInput = $("#cover-file-input");
|
||||
|
||||
$fileInput.on("change", function (e) {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
uploadCover(file);
|
||||
}
|
||||
$(this).val("");
|
||||
});
|
||||
|
||||
$container.on("dragenter", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
isDraggingOver = true;
|
||||
showDropOverlay();
|
||||
});
|
||||
|
||||
$container.on("dragover", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
isDraggingOver = true;
|
||||
});
|
||||
|
||||
$container.on("dragleave", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
|
||||
const rect = this.getBoundingClientRect();
|
||||
const x = e.clientX;
|
||||
const y = e.clientY;
|
||||
|
||||
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||
isDraggingOver = false;
|
||||
hideDropOverlay();
|
||||
}
|
||||
});
|
||||
|
||||
$container.on("drop", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!window.canManage()) return;
|
||||
|
||||
isDraggingOver = false;
|
||||
hideDropOverlay();
|
||||
|
||||
const files = e.dataTransfer?.files || [];
|
||||
if (files.length > 0) {
|
||||
const file = files[0];
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
Utils.showToast("Пожалуйста, загрузите изображение", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadCover(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showDropOverlay() {
|
||||
const $container = $("#book-cover-container");
|
||||
$container.find(".drop-overlay").remove();
|
||||
|
||||
const $overlay = $(`
|
||||
<div class="drop-overlay absolute inset-0 flex flex-col items-center justify-center z-20 pointer-events-none">
|
||||
<div class="absolute inset-2 border-2 border-dashed border-gray-600 rounded-lg"></div>
|
||||
<svg class="w-10 h-10 text-gray-600 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-gray-700 text-sm font-medium text-center px-4">Отпустите для загрузки</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.append($overlay);
|
||||
}
|
||||
|
||||
function hideDropOverlay() {
|
||||
$("#book-cover-container .drop-overlay").remove();
|
||||
}
|
||||
|
||||
async function uploadCover(file) {
|
||||
const $container = $("#book-cover-container");
|
||||
|
||||
const maxSize = 32 * 1024 * 1024;
|
||||
if (file.size > maxSize) {
|
||||
Utils.showToast("Файл слишком большой. Максимум 32 MB", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
Utils.showToast("Пожалуйста, загрузите изображение", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const $loader = $(`
|
||||
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex flex-col items-center justify-center z-20">
|
||||
<svg class="animate-spin w-8 h-8 text-white mb-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>
|
||||
<span class="text-white text-sm">Загрузка...</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.find(".upload-loader").remove();
|
||||
$container.append($loader);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
const response = await Api.uploadFile(
|
||||
`/api/books/${bookId}/preview`,
|
||||
formData,
|
||||
);
|
||||
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.preview) {
|
||||
currentBook.preview_urls = response.preview;
|
||||
} else if (response.preview_urls) {
|
||||
currentBook.preview_urls = response.preview_urls;
|
||||
} else {
|
||||
currentBook = response;
|
||||
}
|
||||
|
||||
Utils.showToast("Обложка успешно загружена", "success");
|
||||
renderBookCover(currentBook);
|
||||
} catch (error) {
|
||||
console.error("Upload error:", error);
|
||||
Utils.showToast(error.message || "Ошибка загрузки обложки", "error");
|
||||
} finally {
|
||||
$container.find(".upload-loader").remove();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCover() {
|
||||
if (!confirm("Удалить обложку книги?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $container = $("#book-cover-container");
|
||||
|
||||
const $loader = $(`
|
||||
<div class="upload-loader absolute inset-0 bg-black bg-opacity-50 flex items-center justify-center z-20">
|
||||
<svg class="animate-spin w-8 h-8 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>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$container.find(".upload-loader").remove();
|
||||
$container.append($loader);
|
||||
|
||||
try {
|
||||
await Api.delete(`/api/books/${bookId}/preview`);
|
||||
|
||||
currentBook.preview_urls = null;
|
||||
Utils.showToast("Обложка удалена", "success");
|
||||
renderBookCover(currentBook);
|
||||
} catch (error) {
|
||||
console.error("Delete error:", error);
|
||||
Utils.showToast(error.message || "Ошибка удаления обложки", "error");
|
||||
} finally {
|
||||
$container.find(".upload-loader").remove();
|
||||
}
|
||||
}
|
||||
|
||||
function renderBookCover(book) {
|
||||
const $container = $("#book-cover-container");
|
||||
const canManage = window.canManage();
|
||||
const previewUrl = getPreviewUrl(book);
|
||||
|
||||
if (previewUrl) {
|
||||
$container.html(`
|
||||
<img
|
||||
src="${Utils.escapeHtml(previewUrl)}"
|
||||
alt="Обложка книги ${Utils.escapeHtml(book.title)}"
|
||||
class="w-full h-full object-cover"
|
||||
onerror="this.onerror=null; this.parentElement.querySelector('.cover-fallback').classList.remove('hidden'); this.classList.add('hidden');"
|
||||
/>
|
||||
<div class="cover-fallback hidden w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center absolute inset-0">
|
||||
<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>
|
||||
${
|
||||
canManage
|
||||
? `
|
||||
<button
|
||||
id="delete-cover-btn"
|
||||
class="absolute top-2 right-2 w-7 h-7 bg-red-500 hover:bg-red-600 text-white rounded-full flex items-center justify-center shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-10"
|
||||
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="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-30 transition-all flex flex-col items-center justify-center cursor-pointer z-0" id="cover-replace-overlay">
|
||||
<svg class="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity mb-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-white text-center opacity-0 group-hover:opacity-100 transition-opacity text-xs font-medium pointer-events-none px-2">
|
||||
Заменить
|
||||
</span>
|
||||
</div>
|
||||
`
|
||||
: ""
|
||||
}
|
||||
`);
|
||||
|
||||
if (canManage) {
|
||||
$("#delete-cover-btn").on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
deleteCover();
|
||||
});
|
||||
|
||||
$("#cover-replace-overlay").on("click", function () {
|
||||
$("#cover-file-input").trigger("click");
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (canManage) {
|
||||
$container.html(`
|
||||
<div
|
||||
id="cover-upload-zone"
|
||||
class="w-full h-full bg-gray-100 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-200 transition-all text-center relative"
|
||||
>
|
||||
<div class="absolute inset-2 border-2 border-dashed border-gray-300 rounded-lg pointer-events-none"></div>
|
||||
<svg class="w-8 h-8 text-gray-400 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"></path>
|
||||
</svg>
|
||||
<span class="text-gray-500 text-xs font-medium px-2">
|
||||
Добавить обложку
|
||||
</span>
|
||||
<span class="text-gray-400 text-xs mt-1 px-2">
|
||||
или перетащите
|
||||
</span>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#cover-upload-zone").on("click", function () {
|
||||
$("#cover-file-input").trigger("click");
|
||||
});
|
||||
} else {
|
||||
$container.html(`
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<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>
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const $loanCard = $(`
|
||||
<div class="border border-gray-200 rounded-lg p-4 ${
|
||||
isOverdue ? "bg-red-50 border-red-300" : "bg-gray-50"
|
||||
}">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="font-medium text-gray-900">ID выдачи: ${loan.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 class="flex flex-col gap-2">
|
||||
${
|
||||
!loan.returned_at && currentBook.status === "reserved"
|
||||
? `<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}">
|
||||
Подтвердить
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
!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}">
|
||||
Вернуть
|
||||
</button>`
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$loanCard.find(".confirm-loan-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
confirmLoan(loanId);
|
||||
});
|
||||
|
||||
$loanCard.find(".return-loan-btn").on("click", function () {
|
||||
const loanId = $(this).data("loan-id");
|
||||
returnLoan(loanId);
|
||||
});
|
||||
|
||||
$container.append($loanCard);
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmLoan(loanId) {
|
||||
try {
|
||||
await Api.post(`/api/loans/${loanId}/confirm`);
|
||||
Utils.showToast("Бронь подтверждена", "success");
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка подтверждения брони", "error");
|
||||
}
|
||||
}
|
||||
|
||||
async function returnLoan(loanId) {
|
||||
if (!confirm("Вы уверены, что хотите вернуть эту книгу?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Api.post(`/api/loans/${loanId}/return`);
|
||||
Utils.showToast("Книга возвращена", "success");
|
||||
loadBookData();
|
||||
loadLoans();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка возврата книги", "error");
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusConfig(status) {
|
||||
return (
|
||||
STATUS_CONFIG[status] || {
|
||||
label: status || "Неизвестно",
|
||||
bgClass: "bg-gray-100",
|
||||
textClass: "text-gray-800",
|
||||
icon: "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function renderBook(book) {
|
||||
$("#book-title").text(book.title);
|
||||
$("#book-id").text(`ID: ${book.id}`);
|
||||
|
||||
renderBookCover(book);
|
||||
|
||||
if (book.page_count && book.page_count > 0) {
|
||||
$("#book-page-count-value").text(book.page_count);
|
||||
$("#book-page-count-text").removeClass("hidden");
|
||||
} else {
|
||||
$("#book-page-count-text").addClass("hidden");
|
||||
}
|
||||
|
||||
$("#book-authors-text").text(
|
||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
|
||||
);
|
||||
$("#book-description").text(book.description || "Описание отсутствует");
|
||||
|
||||
renderStatusWidget(book);
|
||||
|
||||
if (!window.canManage() && book.status === "active") {
|
||||
renderReserveButton();
|
||||
} else {
|
||||
$("#book-actions-container").empty();
|
||||
}
|
||||
|
||||
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/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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,519 @@
|
||||
$(() => {
|
||||
const SELECTORS = {
|
||||
booksContainer: "#books-container",
|
||||
paginationContainer: "#pagination-container",
|
||||
bookSearchInput: "#book-search-input",
|
||||
authorSearchInput: "#author-search-input",
|
||||
authorDropdown: "#author-dropdown",
|
||||
selectedAuthorsContainer: "#selected-authors-container",
|
||||
genresList: "#genres-list",
|
||||
applyFiltersBtn: "#apply-filters-btn",
|
||||
resetFiltersBtn: "#reset-filters-btn",
|
||||
adminActions: "#admin-actions",
|
||||
pagesMin: "#pages-min",
|
||||
pagesMax: "#pages-max",
|
||||
};
|
||||
|
||||
const TEMPLATES = {
|
||||
bookCard: document.getElementById("book-card-template"),
|
||||
genreBadge: document.getElementById("genre-badge-template"),
|
||||
emptyState: document.getElementById("empty-state-template"),
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: {
|
||||
label: "Доступна",
|
||||
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",
|
||||
},
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
const STATE = {
|
||||
selectedAuthors: new Map(),
|
||||
selectedGenres: new Map(),
|
||||
currentPage: 1,
|
||||
totalBooks: 0,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const INITIAL_FILTERS = {
|
||||
search: urlParams.get("q") || "",
|
||||
authorIds: new Set(urlParams.getAll("author_id")),
|
||||
genreIds: new Set(urlParams.getAll("genre_id")),
|
||||
};
|
||||
|
||||
if (INITIAL_FILTERS.search) {
|
||||
$(SELECTORS.bookSearchInput).val(INITIAL_FILTERS.search);
|
||||
}
|
||||
|
||||
const LOADING_SKELETON_HTML = `<div class="space-y-4">${Array.from(
|
||||
{ length: 3 },
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||
<div class="h-6 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full mb-2"></div>
|
||||
</div>
|
||||
`,
|
||||
).join("")}</div>`;
|
||||
|
||||
const USER_CAN_MANAGE =
|
||||
typeof window.canManage === "function" && window.canManage();
|
||||
|
||||
function getStatusConfig(status) {
|
||||
return (
|
||||
STATUS_CONFIG[status] || {
|
||||
label: status || "Неизвестно",
|
||||
bgClass: "bg-gray-100",
|
||||
textClass: "text-gray-800",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function initAuthors(authors) {
|
||||
const $dropdown = $(SELECTORS.authorDropdown);
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
authors.forEach((author) => {
|
||||
const item = document.createElement("div");
|
||||
item.className =
|
||||
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors";
|
||||
item.dataset.id = author.id;
|
||||
item.dataset.name = author.name;
|
||||
item.textContent = author.name;
|
||||
fragment.appendChild(item);
|
||||
|
||||
if (INITIAL_FILTERS.authorIds.has(String(author.id))) {
|
||||
STATE.selectedAuthors.set(author.id, author.name);
|
||||
}
|
||||
});
|
||||
|
||||
$dropdown.empty().append(fragment);
|
||||
}
|
||||
|
||||
function initGenres(genres) {
|
||||
const $list = $(SELECTORS.genresList);
|
||||
const canManage = USER_CAN_MANAGE;
|
||||
let html = "";
|
||||
|
||||
genres.forEach((genre) => {
|
||||
const isChecked = INITIAL_FILTERS.genreIds.has(String(genre.id));
|
||||
if (isChecked) {
|
||||
STATE.selectedGenres.set(genre.id, genre.name);
|
||||
}
|
||||
const safeName = Utils.escapeHtml(genre.name);
|
||||
const editButton = canManage
|
||||
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||
</svg>
|
||||
</a>`
|
||||
: "";
|
||||
html += `
|
||||
<li class="mb-1">
|
||||
<div class="flex items-center">
|
||||
<label class="custom-checkbox flex items-center flex-1">
|
||||
<input type="checkbox" data-id="${genre.id}" data-name="${safeName}" ${
|
||||
isChecked ? "checked" : ""
|
||||
} />
|
||||
<span class="checkmark"></span> ${safeName}
|
||||
</label>
|
||||
${editButton}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
});
|
||||
|
||||
$list.html(html);
|
||||
|
||||
$list.on("change", "input", function () {
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
const name = $(this).data("name");
|
||||
if (this.checked) {
|
||||
STATE.selectedGenres.set(id, name);
|
||||
} else {
|
||||
STATE.selectedGenres.delete(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getTotalPages() {
|
||||
return Math.max(1, Math.ceil(STATE.totalBooks / PAGE_SIZE));
|
||||
}
|
||||
|
||||
function loadBooks() {
|
||||
const searchQuery = $(SELECTORS.bookSearchInput).val().trim();
|
||||
const $minPages = $(SELECTORS.pagesMin);
|
||||
const $maxPages = $(SELECTORS.pagesMax);
|
||||
const minPages = $minPages.length ? $minPages.val() : "";
|
||||
const maxPages = $maxPages.length ? $maxPages.val() : "";
|
||||
|
||||
const apiParams = new URLSearchParams();
|
||||
const browserParams = new URLSearchParams();
|
||||
|
||||
if (searchQuery) {
|
||||
apiParams.append("q", searchQuery);
|
||||
browserParams.append("q", searchQuery);
|
||||
}
|
||||
|
||||
if (minPages && minPages > 0) {
|
||||
apiParams.append("min_page_count", minPages);
|
||||
browserParams.append("min_page_count", minPages);
|
||||
}
|
||||
|
||||
if (maxPages && maxPages < 2000) {
|
||||
apiParams.append("max_page_count", maxPages);
|
||||
browserParams.append("max_page_count", maxPages);
|
||||
}
|
||||
|
||||
STATE.selectedAuthors.forEach((_, id) => {
|
||||
apiParams.append("author_ids", id);
|
||||
browserParams.append("author_id", id);
|
||||
});
|
||||
|
||||
STATE.selectedGenres.forEach((_, id) => {
|
||||
apiParams.append("genre_ids", id);
|
||||
browserParams.append("genre_id", id);
|
||||
});
|
||||
|
||||
apiParams.append("page", STATE.currentPage);
|
||||
apiParams.append("size", PAGE_SIZE);
|
||||
|
||||
const newUrl =
|
||||
window.location.pathname +
|
||||
(browserParams.toString() ? `?${browserParams.toString()}` : "");
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
|
||||
showLoadingState();
|
||||
|
||||
Api.get(`/api/books/filter?${apiParams.toString()}`)
|
||||
.then((data) => {
|
||||
STATE.totalBooks = data.total || 0;
|
||||
renderBooks(data.books || []);
|
||||
renderPagination();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Не удалось загрузить книги", "error");
|
||||
$(SELECTORS.booksContainer).html(
|
||||
TEMPLATES.emptyState.content.cloneNode(true),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBooks(books) {
|
||||
const $container = $(SELECTORS.booksContainer);
|
||||
$container.empty();
|
||||
|
||||
if (!books.length) {
|
||||
$container.append(TEMPLATES.emptyState.content.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
books.forEach((book) => {
|
||||
const clone = TEMPLATES.bookCard.content.cloneNode(true);
|
||||
const card = clone.querySelector(".book-card");
|
||||
card.dataset.id = book.id;
|
||||
|
||||
const titleEl = clone.querySelector(".book-title");
|
||||
const authorsEl = clone.querySelector(".book-authors");
|
||||
const pageCountWrapper = clone.querySelector(".book-page-count");
|
||||
const pageCountValue =
|
||||
pageCountWrapper.querySelector(".page-count-value");
|
||||
const descEl = clone.querySelector(".book-desc");
|
||||
const statusEl = clone.querySelector(".book-status");
|
||||
const genresContainer = clone.querySelector(".book-genres");
|
||||
|
||||
titleEl.textContent = book.title;
|
||||
authorsEl.textContent =
|
||||
(book.authors && book.authors.map((a) => a.name).join(", ")) ||
|
||||
"Автор неизвестен";
|
||||
|
||||
if (book.page_count && book.page_count > 0) {
|
||||
pageCountValue.textContent = book.page_count;
|
||||
pageCountWrapper.classList.remove("hidden");
|
||||
}
|
||||
|
||||
descEl.textContent = book.description || "";
|
||||
|
||||
const statusConfig = getStatusConfig(book.status);
|
||||
statusEl.textContent = statusConfig.label;
|
||||
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
|
||||
|
||||
if (Array.isArray(book.genres)) {
|
||||
book.genres.forEach((g) => {
|
||||
const badge = TEMPLATES.genreBadge.content.cloneNode(true);
|
||||
const span = badge.querySelector("span");
|
||||
span.textContent = g.name;
|
||||
genresContainer.appendChild(badge);
|
||||
});
|
||||
}
|
||||
|
||||
fragment.appendChild(clone);
|
||||
});
|
||||
|
||||
$container.append(fragment);
|
||||
}
|
||||
|
||||
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 renderPagination() {
|
||||
const totalPages = getTotalPages();
|
||||
const $container = $(SELECTORS.paginationContainer);
|
||||
$container.empty();
|
||||
|
||||
if (totalPages <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pages = generatePageNumbers(STATE.currentPage, totalPages);
|
||||
let pagesHtml = "";
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page === "...") {
|
||||
pagesHtml += `<span class="px-3 py-2 text-gray-500">...</span>`;
|
||||
} else {
|
||||
const isActive = page === STATE.currentPage;
|
||||
pagesHtml += `<button class="page-btn px-3 py-2 rounded-lg transition-colors ${
|
||||
isActive
|
||||
? "bg-gray-600 text-white"
|
||||
: "bg-white border border-gray-300 hover:bg-gray-50"
|
||||
}" data-page="${page}">${page}</button>`;
|
||||
}
|
||||
});
|
||||
|
||||
const html = `
|
||||
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
|
||||
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
|
||||
STATE.currentPage === 1 ? "disabled" : ""
|
||||
}>←</button>
|
||||
<div id="page-numbers" class="flex gap-1">${pagesHtml}</div>
|
||||
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${
|
||||
STATE.currentPage === totalPages ? "disabled" : ""
|
||||
}>→</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$container.html(html);
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
$(SELECTORS.booksContainer).html(LOADING_SKELETON_HTML);
|
||||
}
|
||||
|
||||
function renderSelectedAuthors() {
|
||||
const $container = $(SELECTORS.selectedAuthorsContainer);
|
||||
const $dropdown = $(SELECTORS.authorDropdown);
|
||||
$container.empty();
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
STATE.selectedAuthors.forEach((name, id) => {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.className =
|
||||
"author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full";
|
||||
wrapper.innerHTML = `
|
||||
${Utils.escapeHtml(name)}
|
||||
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
`;
|
||||
fragment.appendChild(wrapper);
|
||||
});
|
||||
|
||||
$container.append(fragment);
|
||||
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
if (STATE.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 initializeAuthorDropdownListeners() {
|
||||
const $input = $(SELECTORS.authorSearchInput);
|
||||
const $dropdown = $(SELECTORS.authorDropdown);
|
||||
const $container = $(SELECTORS.selectedAuthorsContainer);
|
||||
|
||||
$input.on("focus", () => {
|
||||
$dropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$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 (
|
||||
!$(e.target).closest(
|
||||
`${SELECTORS.authorSearchInput}, ${SELECTORS.authorDropdown}, ${SELECTORS.selectedAuthorsContainer}`,
|
||||
).length
|
||||
) {
|
||||
$dropdown.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$dropdown.on("click", ".author-item", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (STATE.selectedAuthors.has(id)) {
|
||||
STATE.selectedAuthors.delete(id);
|
||||
} else {
|
||||
STATE.selectedAuthors.set(id, name);
|
||||
}
|
||||
|
||||
$input.val("");
|
||||
$dropdown.find(".author-item").show();
|
||||
renderSelectedAuthors();
|
||||
$input[0].focus();
|
||||
});
|
||||
|
||||
$container.on("click", ".remove-author", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"), 10);
|
||||
STATE.selectedAuthors.delete(id);
|
||||
renderSelectedAuthors();
|
||||
});
|
||||
}
|
||||
|
||||
$(SELECTORS.booksContainer).on("click", ".book-card", function () {
|
||||
const id = $(this).data("id");
|
||||
if (id) {
|
||||
window.location.href = `/book/${id}`;
|
||||
}
|
||||
});
|
||||
|
||||
$(SELECTORS.applyFiltersBtn).on("click", function () {
|
||||
STATE.currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
$(SELECTORS.resetFiltersBtn).on("click", function () {
|
||||
$(SELECTORS.bookSearchInput).val("");
|
||||
STATE.selectedAuthors.clear();
|
||||
STATE.selectedGenres.clear();
|
||||
$(`${SELECTORS.genresList} input`).prop("checked", false);
|
||||
|
||||
const $min = $(SELECTORS.pagesMin);
|
||||
const $max = $(SELECTORS.pagesMax);
|
||||
if ($min.length && $max.length) {
|
||||
const minDefault = $min.attr("min");
|
||||
const maxDefault = $max.attr("max");
|
||||
if (minDefault !== undefined) $min.val(minDefault).trigger("input");
|
||||
if (maxDefault !== undefined) $max.val(maxDefault).trigger("input");
|
||||
}
|
||||
|
||||
renderSelectedAuthors();
|
||||
STATE.currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
$(SELECTORS.bookSearchInput).on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
STATE.currentPage = 1;
|
||||
loadBooks();
|
||||
}
|
||||
});
|
||||
|
||||
$(SELECTORS.paginationContainer).on("click", "#prev-page", function () {
|
||||
if (STATE.currentPage > 1) {
|
||||
STATE.currentPage -= 1;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$(SELECTORS.paginationContainer).on("click", "#next-page", function () {
|
||||
const totalPages = getTotalPages();
|
||||
if (STATE.currentPage < totalPages) {
|
||||
STATE.currentPage += 1;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$(SELECTORS.paginationContainer).on("click", ".page-btn", function () {
|
||||
const page = parseInt($(this).data("page"), 10);
|
||||
if (page && page !== STATE.currentPage) {
|
||||
STATE.currentPage = page;
|
||||
loadBooks();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
if (USER_CAN_MANAGE) {
|
||||
$(SELECTORS.adminActions).removeClass("hidden");
|
||||
}
|
||||
|
||||
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
|
||||
.then(([authorsData, genresData]) => {
|
||||
initAuthors(authorsData.authors || []);
|
||||
initGenres(genresData.genres || []);
|
||||
initializeAuthorDropdownListeners();
|
||||
renderSelectedAuthors();
|
||||
loadBooks();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка загрузки данных", "error");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
$(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();
|
||||
const pageCount = parseInt($("#book-page-count").val()) || null;
|
||||
|
||||
if (!title) {
|
||||
Utils.showToast("Введите название книги", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pageCount) {
|
||||
Utils.showToast("Введите количество страниц", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const bookPayload = {
|
||||
title: title,
|
||||
description: description || null,
|
||||
page_count: pageCount,
|
||||
};
|
||||
|
||||
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";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,461 @@
|
||||
$(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 $pagesInput = $("#book-page-count");
|
||||
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 || "");
|
||||
$pagesInput.val(book.page_count);
|
||||
$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 pages = parseInt($("#book-page-count").val()) || null;
|
||||
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 (pages !== originalBook.page_count) payload.page_count = pages;
|
||||
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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
$(document).ready(() => {
|
||||
if (!window.canManage()) {
|
||||
Utils.showToast("У вас недостаточно прав", "error");
|
||||
setTimeout(() => (window.location.href = "/"), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const genreId = parseInt(pathParts[pathParts.length - 2]);
|
||||
|
||||
if (!genreId || isNaN(genreId)) {
|
||||
Utils.showToast("Некорректный ID жанра", "error");
|
||||
setTimeout(() => (window.location.href = "/"), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
let originalGenre = null;
|
||||
let genreBooks = [];
|
||||
|
||||
const $form = $("#edit-genre-form");
|
||||
const $loader = $("#loader");
|
||||
const $dangerZone = $("#danger-zone");
|
||||
const $nameInput = $("#genre-name");
|
||||
const $submitBtn = $("#submit-btn");
|
||||
const $submitText = $("#submit-text");
|
||||
const $loadingSpinner = $("#loading-spinner");
|
||||
const $deleteModal = $("#delete-modal");
|
||||
const $successModal = $("#success-modal");
|
||||
|
||||
Promise.all([
|
||||
Api.get(`/api/genres/${genreId}`),
|
||||
Api.get(`/api/genres/${genreId}/books`),
|
||||
])
|
||||
.then(([genre, booksData]) => {
|
||||
originalGenre = genre;
|
||||
genreBooks = booksData.books || booksData || [];
|
||||
|
||||
document.title = `Редактирование: ${genre.name} | LiB`;
|
||||
populateForm(genre);
|
||||
renderGenreBooks(genreBooks);
|
||||
|
||||
$loader.addClass("hidden");
|
||||
$form.removeClass("hidden");
|
||||
$dangerZone.removeClass("hidden");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Жанр не найден", "error");
|
||||
setTimeout(() => (window.location.href = "/"), 1500);
|
||||
});
|
||||
|
||||
function populateForm(genre) {
|
||||
$nameInput.val(genre.name);
|
||||
updateCounter();
|
||||
}
|
||||
|
||||
function updateCounter() {
|
||||
$("#name-counter").text(`${$nameInput.val().length}/100`);
|
||||
}
|
||||
|
||||
$nameInput.on("input", updateCounter);
|
||||
|
||||
function renderGenreBooks(books) {
|
||||
const $container = $("#genre-books-container");
|
||||
$container.empty();
|
||||
|
||||
$("#books-count").text(books.length > 0 ? `(${books.length})` : "");
|
||||
|
||||
if (books.length === 0) {
|
||||
$container.html(`
|
||||
<div class="text-sm text-gray-500 text-center py-4">
|
||||
<svg class="w-8 h-8 mx-auto text-gray-300 mb-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
|
||||
</svg>
|
||||
В этом жанре пока нет книг
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
$container.append(`
|
||||
<a href="/book/${book.id}" class="flex items-center justify-between p-3 bg-gray-50 hover:bg-gray-100 rounded-lg border transition-colors group">
|
||||
<div class="flex items-center min-w-0">
|
||||
<div class="w-8 h-10 bg-gradient-to-br from-gray-400 to-gray-500 rounded flex items-center justify-center flex-shrink-0 mr-3">
|
||||
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="text-sm font-medium text-gray-900 truncate block">${Utils.escapeHtml(book.title)}</span>
|
||||
${book.authors && book.authors.length > 0 ? `<span class="text-xs text-gray-500 truncate block">${Utils.escapeHtml(book.authors.map((a) => a.name).join(", "))}</span>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<svg class="w-4 h-4 text-gray-400 group-hover:text-gray-600 flex-shrink-0 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
`);
|
||||
});
|
||||
}
|
||||
|
||||
$form.on("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const name = $nameInput.val().trim();
|
||||
|
||||
if (!name) {
|
||||
Utils.showToast("Введите название жанра", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === originalGenre.name) {
|
||||
Utils.showToast("Нет изменений для сохранения", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const updatedGenre = await Api.put(`/api/genres/${genreId}`, { name });
|
||||
originalGenre = updatedGenre;
|
||||
showSuccessModal(updatedGenre);
|
||||
} catch (error) {
|
||||
console.error("Ошибка обновления:", error);
|
||||
|
||||
let errorMsg = "Произошла ошибка при обновлении жанра";
|
||||
if (error.responseJSON && error.responseJSON.detail) {
|
||||
errorMsg = error.responseJSON.detail;
|
||||
} else if (error.status === 401) {
|
||||
errorMsg = "Вы не авторизованы";
|
||||
} else if (error.status === 403) {
|
||||
errorMsg = "У вас недостаточно прав";
|
||||
} else if (error.status === 404) {
|
||||
errorMsg = "Жанр не найден";
|
||||
} else if (error.status === 409) {
|
||||
errorMsg = "Жанр с таким названием уже существует";
|
||||
}
|
||||
|
||||
Utils.showToast(errorMsg, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
function setLoading(isLoading) {
|
||||
$submitBtn.prop("disabled", isLoading);
|
||||
if (isLoading) {
|
||||
$submitText.text("Сохранение...");
|
||||
$loadingSpinner.removeClass("hidden");
|
||||
} else {
|
||||
$submitText.text("Сохранить изменения");
|
||||
$loadingSpinner.addClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessModal(genre) {
|
||||
$("#success-genre-name").text(genre.name);
|
||||
$successModal.removeClass("hidden");
|
||||
}
|
||||
|
||||
$("#success-close-btn").on("click", function () {
|
||||
$successModal.addClass("hidden");
|
||||
});
|
||||
|
||||
$successModal.on("click", function (e) {
|
||||
if (e.target === this) {
|
||||
$successModal.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#delete-btn").on("click", function () {
|
||||
$("#modal-genre-name").text(originalGenre.name);
|
||||
|
||||
if (genreBooks.length > 0) {
|
||||
$("#modal-books-warning").removeClass("hidden");
|
||||
} else {
|
||||
$("#modal-books-warning").addClass("hidden");
|
||||
}
|
||||
|
||||
$deleteModal.removeClass("hidden");
|
||||
});
|
||||
|
||||
$("#cancel-delete-btn").on("click", function () {
|
||||
$deleteModal.addClass("hidden");
|
||||
});
|
||||
|
||||
$deleteModal.on("click", function (e) {
|
||||
if (e.target === this) {
|
||||
$deleteModal.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#confirm-delete-btn").on("click", async function () {
|
||||
const $btn = $(this);
|
||||
const $spinner = $("#delete-spinner");
|
||||
|
||||
$btn.prop("disabled", true);
|
||||
$spinner.removeClass("hidden");
|
||||
|
||||
try {
|
||||
await Api.delete(`/api/genres/${genreId}`);
|
||||
Utils.showToast("Жанр успешно удалён", "success");
|
||||
setTimeout(() => (window.location.href = "/"), 1000);
|
||||
} catch (error) {
|
||||
console.error("Ошибка удаления:", error);
|
||||
|
||||
let errorMsg = "Произошла ошибка при удалении жанра";
|
||||
if (error.responseJSON && error.responseJSON.detail) {
|
||||
errorMsg = error.responseJSON.detail;
|
||||
} else if (error.status === 401) {
|
||||
errorMsg = "Вы не авторизованы";
|
||||
} else if (error.status === 403) {
|
||||
errorMsg = "У вас недостаточно прав";
|
||||
}
|
||||
|
||||
Utils.showToast(errorMsg, "error");
|
||||
$btn.prop("disabled", false);
|
||||
$spinner.addClass("hidden");
|
||||
$deleteModal.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape") {
|
||||
if (!$deleteModal.hasClass("hidden")) {
|
||||
$deleteModal.addClass("hidden");
|
||||
} else if (!$successModal.hasClass("hidden")) {
|
||||
$successModal.addClass("hidden");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
const svg = document.getElementById("bookSvg");
|
||||
const $svg = $("#bookSvg");
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
const svgWidth = 200;
|
||||
@@ -11,9 +11,9 @@ const bookX = (svgWidth - bookWidth) / 2;
|
||||
const bookY = (svgHeight - bookHeight) / 2;
|
||||
const desiredLineSpacing = 8;
|
||||
const baseLineWidth = 2;
|
||||
const maxLineWidth = 10;
|
||||
const maxLineWidth = 8;
|
||||
const maxLineHeight = bookHeight - 24;
|
||||
const innerPaddingX = 10;
|
||||
const innerPaddingX = 15;
|
||||
const appearStagger = 8;
|
||||
|
||||
let lineSpacing;
|
||||
@@ -28,7 +28,7 @@ if (lineCount > 1) {
|
||||
const linesSpan = lineSpacing * (lineCount - 1);
|
||||
|
||||
const rightBase = bookX + bookWidth - innerPaddingX - maxLineWidth;
|
||||
const lineStartX = rightBase - linesSpan;
|
||||
const lineStartX = rightBase - linesSpan + maxLineWidth;
|
||||
|
||||
const leftLimit = bookX + innerPaddingX;
|
||||
|
||||
@@ -48,27 +48,27 @@ const disappearDuration =
|
||||
const pauseDuration = 30;
|
||||
|
||||
const book = document.createElementNS(NS, "rect");
|
||||
book.setAttribute("x", bookX);
|
||||
book.setAttribute("y", bookY);
|
||||
book.setAttribute("width", bookWidth);
|
||||
book.setAttribute("height", bookHeight);
|
||||
book.setAttribute("fill", "#374151");
|
||||
book.setAttribute("rx", "4");
|
||||
svg.appendChild(book);
|
||||
$(book)
|
||||
.attr("x", bookX)
|
||||
.attr("y", bookY)
|
||||
.attr("width", bookWidth)
|
||||
.attr("height", bookHeight)
|
||||
.attr("fill", "#374151")
|
||||
.attr("rx", "4");
|
||||
$svg.append(book);
|
||||
|
||||
const lines = [];
|
||||
for (let i = 0; i < lineCount; i++) {
|
||||
const line = document.createElementNS(NS, "rect");
|
||||
line.setAttribute("fill", "#ffffff");
|
||||
line.setAttribute("rx", "1");
|
||||
svg.appendChild(line);
|
||||
$(line).attr("fill", "#ffffff").attr("rx", "1");
|
||||
$svg.append(line);
|
||||
|
||||
const baseX = lineStartX + i * lineSpacing;
|
||||
const targetX = leftLimit + i * lineSpacing;
|
||||
const moveDistance = baseX - targetX;
|
||||
|
||||
lines.push({
|
||||
el: line,
|
||||
el: $(line),
|
||||
baseX,
|
||||
targetX,
|
||||
moveDistance,
|
||||
@@ -91,13 +91,14 @@ function easeInQuad(t) {
|
||||
}
|
||||
|
||||
function updateLine(line) {
|
||||
const el = line.el;
|
||||
const $el = line.el;
|
||||
const centerY = bookY + bookHeight / 2;
|
||||
|
||||
el.setAttribute("x", line.currentX);
|
||||
el.setAttribute("y", centerY - line.height / 2);
|
||||
el.setAttribute("width", line.width);
|
||||
el.setAttribute("height", Math.max(0, line.height));
|
||||
$el
|
||||
.attr("x", line.currentX)
|
||||
.attr("y", centerY - line.height / 2)
|
||||
.attr("width", line.width)
|
||||
.attr("height", Math.max(0, line.height));
|
||||
}
|
||||
|
||||
function animateBook() {
|
||||
@@ -171,7 +172,7 @@ function animateBook() {
|
||||
|
||||
animateBook();
|
||||
|
||||
function animateCounter(element, target, duration = 2000) {
|
||||
function animateCounter($element, target, duration = 2000) {
|
||||
const start = 0;
|
||||
const startTime = performance.now();
|
||||
|
||||
@@ -182,12 +183,12 @@ function animateCounter(element, target, duration = 2000) {
|
||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
||||
const current = Math.floor(start + (target - start) * easedProgress);
|
||||
|
||||
element.textContent = current.toLocaleString("ru-RU");
|
||||
$element.text(current.toLocaleString("ru-RU"));
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(update);
|
||||
} else {
|
||||
element.textContent = target.toLocaleString("ru-RU");
|
||||
$element.text(target.toLocaleString("ru-RU"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,54 +205,55 @@ async function loadStats() {
|
||||
const stats = await response.json();
|
||||
|
||||
setTimeout(() => {
|
||||
const booksEl = document.getElementById("stat-books");
|
||||
const authorsEl = document.getElementById("stat-authors");
|
||||
const genresEl = document.getElementById("stat-genres");
|
||||
const usersEl = document.getElementById("stat-users");
|
||||
const $booksEl = $("#stat-books");
|
||||
const $authorsEl = $("#stat-authors");
|
||||
const $genresEl = $("#stat-genres");
|
||||
const $usersEl = $("#stat-users");
|
||||
|
||||
if (booksEl) {
|
||||
animateCounter(booksEl, stats.books, 1500);
|
||||
if ($booksEl.length) {
|
||||
animateCounter($booksEl, stats.books, 1500);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (authorsEl) {
|
||||
animateCounter(authorsEl, stats.authors, 1500);
|
||||
if ($authorsEl.length) {
|
||||
animateCounter($authorsEl, stats.authors, 1500);
|
||||
}
|
||||
}, 150);
|
||||
|
||||
setTimeout(() => {
|
||||
if (genresEl) {
|
||||
animateCounter(genresEl, stats.genres, 1500);
|
||||
if ($genresEl.length) {
|
||||
animateCounter($genresEl, stats.genres, 1500);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
setTimeout(() => {
|
||||
if (usersEl) {
|
||||
animateCounter(usersEl, stats.users, 1500);
|
||||
if ($usersEl.length) {
|
||||
animateCounter($usersEl, stats.users, 1500);
|
||||
}
|
||||
}, 450);
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("Ошибка загрузки статистики:", error);
|
||||
|
||||
document.getElementById("stat-books").textContent = "—";
|
||||
document.getElementById("stat-authors").textContent = "—";
|
||||
document.getElementById("stat-genres").textContent = "—";
|
||||
document.getElementById("stat-users").textContent = "—";
|
||||
$("#stat-books").text("—");
|
||||
$("#stat-authors").text("—");
|
||||
$("#stat-genres").text("—");
|
||||
$("#stat-users").text("—");
|
||||
}
|
||||
}
|
||||
|
||||
function observeStatCards() {
|
||||
const cards = document.querySelectorAll(".stat-card");
|
||||
const $cards = $(".stat-card");
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry, index) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
entry.target.classList.add("animate-fade-in");
|
||||
entry.target.style.opacity = "1";
|
||||
entry.target.style.transform = "translateY(0)";
|
||||
$(entry.target).addClass("animate-fade-in").css({
|
||||
opacity: "1",
|
||||
transform: "translateY(0)",
|
||||
});
|
||||
}, index * 100);
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
@@ -260,15 +262,17 @@ function observeStatCards() {
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
cards.forEach((card) => {
|
||||
card.style.opacity = "0";
|
||||
card.style.transform = "translateY(20px)";
|
||||
card.style.transition = "opacity 0.5s ease, transform 0.5s ease";
|
||||
$cards.each(function (index, card) {
|
||||
$(card).css({
|
||||
opacity: "0",
|
||||
transform: "translateY(20px)",
|
||||
transition: "opacity 0.5s ease, transform 0.5s ease",
|
||||
});
|
||||
observer.observe(card);
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
$(document).ready(() => {
|
||||
loadStats();
|
||||
observeStatCards();
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
$(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");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
$(document).ready(() => {
|
||||
const token = StorageHelper.get("access_token");
|
||||
if (!token) {
|
||||
window.location.href = "/auth";
|
||||
return;
|
||||
}
|
||||
|
||||
let currentUsername = "";
|
||||
let currentRecoveryCodes = [];
|
||||
|
||||
loadProfile();
|
||||
|
||||
function loadProfile() {
|
||||
Promise.all([
|
||||
Api.get("/api/auth/me"),
|
||||
Api.get("/api/users/roles").catch(() => ({ roles: [] })),
|
||||
Api.get("/api/auth/recovery-codes/status").catch(() => null),
|
||||
])
|
||||
.then(async ([user, rolesData, recoveryStatus]) => {
|
||||
document.title = `LiB - ${user.full_name || user.username}`;
|
||||
currentUsername = user.username;
|
||||
|
||||
await renderProfileHeader(user);
|
||||
renderInfo(user);
|
||||
renderRoles(user.roles || [], rolesData.roles || []);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("update-2fa", { detail: user.is_2fa_enabled }),
|
||||
);
|
||||
|
||||
if (recoveryStatus) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("update-recovery-codes", {
|
||||
detail: recoveryStatus.remaining,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
$("#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;
|
||||
}
|
||||
|
||||
const roleMap = {};
|
||||
allRoles.forEach((r) => (roleMap[r.name] = r.description));
|
||||
|
||||
const html = userRoles
|
||||
.map(
|
||||
(role) => `
|
||||
<div class="p-3 bg-blue-50 border border-blue-100 rounded text-blue-800">
|
||||
<div class="font-bold capitalize">${Utils.escapeHtml(role)}</div>
|
||||
<div class="text-xs opacity-75">${Utils.escapeHtml(roleMap[role] || "")}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
$container.html(html);
|
||||
}
|
||||
|
||||
$("#recovery-codes-btn").on("click", function () {
|
||||
resetRecoveryCodesModal();
|
||||
window.dispatchEvent(new CustomEvent("open-recovery-codes-modal"));
|
||||
loadRecoveryCodesStatus();
|
||||
});
|
||||
|
||||
function resetRecoveryCodesModal() {
|
||||
$("#recovery-codes-loading").removeClass("hidden");
|
||||
$("#recovery-codes-status").addClass("hidden");
|
||||
$("#recovery-codes-display").addClass("hidden");
|
||||
$("#codes-saved-checkbox").prop("checked", false);
|
||||
$("#close-recovery-modal-btn").prop("disabled", true);
|
||||
$("#regenerate-codes-btn")
|
||||
.prop("disabled", false)
|
||||
.text("Сгенерировать новые коды");
|
||||
currentRecoveryCodes = [];
|
||||
}
|
||||
|
||||
async function loadRecoveryCodesStatus() {
|
||||
try {
|
||||
const status = await Api.get("/api/auth/recovery-codes/status");
|
||||
renderRecoveryCodesStatus(status);
|
||||
} catch (error) {
|
||||
Utils.showToast(
|
||||
error.message || "Ошибка загрузки статуса кодов",
|
||||
"error",
|
||||
);
|
||||
window.dispatchEvent(new CustomEvent("close-recovery-codes-modal"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderRecoveryCodesStatus(status) {
|
||||
const { total, remaining, used_codes, generated_at, should_regenerate } =
|
||||
status;
|
||||
|
||||
let iconBgClass, iconColorClass, iconSvg;
|
||||
if (remaining <= 2) {
|
||||
iconBgClass = "bg-red-100";
|
||||
iconColorClass = "text-red-600";
|
||||
iconSvg = `<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" />`;
|
||||
} else if (remaining <= 5) {
|
||||
iconBgClass = "bg-yellow-100";
|
||||
iconColorClass = "text-yellow-600";
|
||||
iconSvg = `<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" />`;
|
||||
} else {
|
||||
iconBgClass = "bg-green-100";
|
||||
iconColorClass = "text-green-600";
|
||||
iconSvg = `<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" />`;
|
||||
}
|
||||
|
||||
$("#status-icon-container")
|
||||
.removeClass()
|
||||
.addClass(
|
||||
`flex items-center justify-center w-12 h-12 mx-auto rounded-full mb-4 ${iconBgClass}`,
|
||||
)
|
||||
.html(
|
||||
`<svg class="w-6 h-6 ${iconColorClass}" fill="none" stroke="currentColor" viewBox="0 0 24 24">${iconSvg}</svg>`,
|
||||
);
|
||||
|
||||
let statusColorClass;
|
||||
if (remaining <= 2) {
|
||||
statusColorClass = "text-red-600";
|
||||
} else if (remaining <= 5) {
|
||||
statusColorClass = "text-yellow-600";
|
||||
} else {
|
||||
statusColorClass = "text-green-600";
|
||||
}
|
||||
|
||||
$("#codes-status-summary").html(`
|
||||
<p class="text-sm text-gray-600">
|
||||
Доступно кодов: <strong class="${statusColorClass}">${remaining}</strong> из <strong>${total}</strong>
|
||||
</p>
|
||||
`);
|
||||
|
||||
const $list = $("#codes-status-list");
|
||||
$list.empty();
|
||||
|
||||
used_codes.forEach((used, index) => {
|
||||
const codeDisplay = "████-████-████-████";
|
||||
const statusClass = used
|
||||
? "text-gray-300 line-through"
|
||||
: "text-green-600";
|
||||
const statusIcon = used ? "✗" : "✓";
|
||||
const bgClass = used ? "bg-gray-50" : "bg-green-50";
|
||||
|
||||
$list.append(`
|
||||
<div class="flex items-center justify-between py-1 px-2 rounded ${bgClass}">
|
||||
<span class="font-mono text-sm ${statusClass}">${index + 1}. ${codeDisplay}</span>
|
||||
<span class="${used ? "text-gray-400" : "text-green-600"}">${statusIcon}</span>
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
if (should_regenerate || remaining <= 2) {
|
||||
let warningText;
|
||||
if (remaining === 0) {
|
||||
warningText =
|
||||
"У вас не осталось резервных кодов! Срочно сгенерируйте новые.";
|
||||
} else if (remaining <= 2) {
|
||||
warningText = "Осталось мало кодов. Рекомендуем сгенерировать новые.";
|
||||
} else {
|
||||
warningText = "Рекомендуем сгенерировать новые коды для безопасности.";
|
||||
}
|
||||
$("#warning-text").text(warningText);
|
||||
$("#codes-warning").removeClass("hidden");
|
||||
} else {
|
||||
$("#codes-warning").addClass("hidden");
|
||||
}
|
||||
|
||||
if (generated_at) {
|
||||
const date = new Date(generated_at);
|
||||
$("#codes-generated-at").text(`Сгенерированы: ${date.toLocaleString()}`);
|
||||
}
|
||||
|
||||
$("#recovery-codes-loading").addClass("hidden");
|
||||
$("#recovery-codes-status").removeClass("hidden");
|
||||
}
|
||||
|
||||
$("#regenerate-codes-btn").on("click", async function () {
|
||||
const $btn = $(this);
|
||||
$btn.prop("disabled", true).text("Генерация...");
|
||||
|
||||
try {
|
||||
const response = await Api.post("/api/auth/recovery-codes/regenerate");
|
||||
|
||||
currentRecoveryCodes = response.codes;
|
||||
displayNewRecoveryCodes(response.codes, response.generated_at);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("update-recovery-codes", {
|
||||
detail: response.codes.length,
|
||||
}),
|
||||
);
|
||||
|
||||
Utils.showToast("Новые коды успешно сгенерированы", "success");
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || "Ошибка генерации кодов", "error");
|
||||
$btn.prop("disabled", false).text("Сгенерировать новые коды");
|
||||
}
|
||||
});
|
||||
|
||||
function displayNewRecoveryCodes(codes, generatedAt) {
|
||||
const $list = $("#recovery-codes-list");
|
||||
$list.empty();
|
||||
|
||||
codes.forEach((code, index) => {
|
||||
$list.append(`
|
||||
<div class="py-1 px-2 bg-white rounded border select-all font-mono text-gray-800">
|
||||
${index + 1}. ${Utils.escapeHtml(code)}
|
||||
</div>
|
||||
`);
|
||||
});
|
||||
|
||||
if (generatedAt) {
|
||||
const date = new Date(generatedAt);
|
||||
$("#recovery-codes-generated-at").text(
|
||||
`Сгенерированы: ${date.toLocaleString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
$("#recovery-codes-status").addClass("hidden");
|
||||
$("#recovery-codes-display").removeClass("hidden");
|
||||
}
|
||||
|
||||
$("#codes-saved-checkbox").on("change", function () {
|
||||
$("#close-recovery-modal-btn").prop("disabled", !this.checked);
|
||||
});
|
||||
|
||||
$("#copy-codes-btn").on("click", function () {
|
||||
if (currentRecoveryCodes.length === 0) return;
|
||||
|
||||
const codesText = currentRecoveryCodes.join("\n");
|
||||
navigator.clipboard.writeText(codesText).then(() => {
|
||||
const $btn = $(this);
|
||||
const originalHtml = $btn.html();
|
||||
$btn.html(`
|
||||
<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" />
|
||||
</svg>
|
||||
<span>Скопировано!</span>
|
||||
`);
|
||||
setTimeout(() => $btn.html(originalHtml), 2000);
|
||||
Utils.showToast("Коды скопированы в буфер обмена", "success");
|
||||
});
|
||||
});
|
||||
|
||||
$("#download-codes-btn").on("click", function () {
|
||||
if (currentRecoveryCodes.length === 0) return;
|
||||
|
||||
const username = currentUsername || "user";
|
||||
const codesText = `Резервные коды для аккаунта: ${username}
|
||||
Дата: ${new Date().toLocaleString()}
|
||||
|
||||
${currentRecoveryCodes.map((c, i) => `${i + 1}. ${c}`).join("\n")}
|
||||
|
||||
Храните эти коды в надёжном месте!
|
||||
Каждый код можно использовать только один раз.`;
|
||||
|
||||
const blob = new Blob([codesText], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `recovery-codes-${username}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
Utils.showToast("Файл с кодами скачан", "success");
|
||||
});
|
||||
|
||||
$("#close-recovery-modal-btn, #close-status-modal-btn").on(
|
||||
"click",
|
||||
function () {
|
||||
window.dispatchEvent(new CustomEvent("close-recovery-codes-modal"));
|
||||
},
|
||||
);
|
||||
|
||||
$("#submit-disable-2fa-btn").on("click", async function () {
|
||||
const $btn = $(this);
|
||||
const password = $("#disable-2fa-password").val();
|
||||
|
||||
if (!password) {
|
||||
Utils.showToast("Введите пароль", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop("disabled", true).text("Отключение...");
|
||||
|
||||
try {
|
||||
await Api.post("/api/auth/2fa/disable", { password });
|
||||
Utils.showToast("2FA успешно отключена", "success");
|
||||
window.dispatchEvent(new CustomEvent("update-2fa", { detail: false }));
|
||||
window.dispatchEvent(new CustomEvent("close-disable-2fa-modal"));
|
||||
|
||||
$("#disable-2fa-form")[0].reset();
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || "Ошибка отключения 2FA", "error");
|
||||
} finally {
|
||||
$btn.prop("disabled", false).text("Отключить");
|
||||
}
|
||||
});
|
||||
|
||||
$("#submit-password-btn").on("click", async function () {
|
||||
const $btn = $(this);
|
||||
const newPass = $("#new-password").val();
|
||||
const confirm = $("#confirm-password").val();
|
||||
|
||||
if (newPass !== confirm) {
|
||||
Utils.showToast("Пароли не совпадают", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPass.length < 8) {
|
||||
Utils.showToast("Пароль должен быть минимум 8 символов", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
$btn.prop("disabled", true).text("Сохранение...");
|
||||
|
||||
try {
|
||||
await Api.put("/api/auth/me", { password: newPass });
|
||||
|
||||
Utils.showToast("Пароль успешно изменён", "success");
|
||||
|
||||
window.dispatchEvent(new CustomEvent("close-password-modal"));
|
||||
|
||||
$("#change-password-form")[0].reset();
|
||||
} catch (error) {
|
||||
Utils.showToast(error.message || "Ошибка смены пароля", "error");
|
||||
} finally {
|
||||
$btn.prop("disabled", false).text("Сменить");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
const $svg = $("#canvas");
|
||||
|
||||
const CONFIG = {
|
||||
holeRadius: 60,
|
||||
maxRadius: 220,
|
||||
tilt: 0.4,
|
||||
|
||||
ringsCount: 7,
|
||||
ringSpeed: 0.002,
|
||||
ringStroke: 5,
|
||||
|
||||
particlesCount: 40,
|
||||
particleSpeedBase: 0.02,
|
||||
particleFallSpeed: 0.2,
|
||||
};
|
||||
|
||||
function create(tag, attrs) {
|
||||
const el = document.createElementNS(NS, tag);
|
||||
for (let k in attrs) el.setAttribute(k, attrs[k]);
|
||||
return el;
|
||||
}
|
||||
|
||||
const $layerBack = $(create("g", { id: "layer-back" }));
|
||||
const $layerHole = $(create("g", { id: "layer-hole" }));
|
||||
const $layerFront = $(create("g", { id: "layer-front" }));
|
||||
|
||||
$svg.append($layerBack, $layerHole, $layerFront);
|
||||
|
||||
const holeHalo = create("circle", {
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r: CONFIG.holeRadius + 4,
|
||||
fill: "#ffffff",
|
||||
stroke: "none",
|
||||
});
|
||||
const holeBody = create("circle", {
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
r: CONFIG.holeRadius,
|
||||
fill: "#000000",
|
||||
});
|
||||
$layerHole.append(holeHalo, holeBody);
|
||||
|
||||
class Ring {
|
||||
constructor(offset) {
|
||||
this.progress = offset;
|
||||
|
||||
const style = {
|
||||
fill: "none",
|
||||
stroke: "#000",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-width": CONFIG.ringStroke,
|
||||
};
|
||||
|
||||
this.elBack = create("path", style);
|
||||
this.elFront = create("path", style);
|
||||
|
||||
$layerBack.append(this.elBack);
|
||||
$layerFront.append(this.elFront);
|
||||
}
|
||||
|
||||
update() {
|
||||
this.progress += CONFIG.ringSpeed;
|
||||
if (this.progress >= 1) this.progress -= 1;
|
||||
|
||||
const t = this.progress;
|
||||
|
||||
const currentR =
|
||||
CONFIG.maxRadius - t * (CONFIG.maxRadius - CONFIG.holeRadius);
|
||||
const currentRy = currentR * CONFIG.tilt;
|
||||
|
||||
const distFromHole = currentR - CONFIG.holeRadius;
|
||||
const distFromEdge = CONFIG.maxRadius - currentR;
|
||||
|
||||
const fadeHole = Math.min(1, distFromHole / 40);
|
||||
const fadeEdge = Math.min(1, distFromEdge / 40);
|
||||
|
||||
const opacity = fadeHole * fadeEdge;
|
||||
|
||||
if (opacity <= 0.01) {
|
||||
this.elBack.setAttribute("opacity", 0);
|
||||
this.elFront.setAttribute("opacity", 0);
|
||||
} else {
|
||||
const dBack = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 1 ${currentR} 0`;
|
||||
const dFront = `M ${-currentR} 0 A ${currentR} ${currentRy} 0 0 0 ${currentR} 0`;
|
||||
|
||||
this.elBack.setAttribute("d", dBack);
|
||||
this.elFront.setAttribute("d", dFront);
|
||||
|
||||
this.elBack.setAttribute("opacity", opacity);
|
||||
this.elFront.setAttribute("opacity", opacity);
|
||||
|
||||
const sw =
|
||||
CONFIG.ringStroke *
|
||||
(0.6 + 0.4 * (distFromHole / (CONFIG.maxRadius - CONFIG.holeRadius)));
|
||||
this.elBack.setAttribute("stroke-width", sw);
|
||||
this.elFront.setAttribute("stroke-width", sw);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Particle {
|
||||
constructor() {
|
||||
this.el = create("circle", { fill: "#000" });
|
||||
this.reset(true);
|
||||
$layerFront.append(this.el);
|
||||
this.inFront = true;
|
||||
}
|
||||
|
||||
reset(randomStart = false) {
|
||||
this.angle = Math.random() * Math.PI * 2;
|
||||
this.r = randomStart
|
||||
? CONFIG.holeRadius +
|
||||
Math.random() * (CONFIG.maxRadius - CONFIG.holeRadius)
|
||||
: CONFIG.maxRadius;
|
||||
|
||||
this.speed = CONFIG.particleSpeedBase + Math.random() * 0.02;
|
||||
this.size = 1.5 + Math.random() * 2.5;
|
||||
}
|
||||
|
||||
update() {
|
||||
const acceleration = CONFIG.maxRadius / this.r;
|
||||
this.angle += this.speed * acceleration;
|
||||
this.r -= CONFIG.particleFallSpeed * (acceleration * 0.8);
|
||||
|
||||
const x = Math.cos(this.angle) * this.r;
|
||||
const y = Math.sin(this.angle) * this.r * CONFIG.tilt;
|
||||
|
||||
const isNowFront = Math.sin(this.angle) > 0;
|
||||
|
||||
if (this.inFront !== isNowFront) {
|
||||
this.inFront = isNowFront;
|
||||
if (this.inFront) {
|
||||
$layerFront.append(this.el);
|
||||
} else {
|
||||
$layerBack.append(this.el);
|
||||
}
|
||||
}
|
||||
|
||||
const distFromHole = this.r - CONFIG.holeRadius;
|
||||
const distFromEdge = CONFIG.maxRadius - this.r;
|
||||
|
||||
const fadeHole = Math.min(1, distFromHole / 30);
|
||||
const fadeEdge = Math.min(1, distFromEdge / 30);
|
||||
const opacity = fadeHole * fadeEdge;
|
||||
|
||||
this.el.setAttribute("cx", x);
|
||||
this.el.setAttribute("cy", y);
|
||||
this.el.setAttribute("r", this.size * Math.min(1, this.r / 100));
|
||||
this.el.setAttribute("opacity", opacity);
|
||||
|
||||
if (this.r <= CONFIG.holeRadius) {
|
||||
this.reset(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rings = [];
|
||||
for (let i = 0; i < CONFIG.ringsCount; i++) {
|
||||
rings.push(new Ring(i / CONFIG.ringsCount));
|
||||
}
|
||||
|
||||
const particles = [];
|
||||
for (let i = 0; i < CONFIG.particlesCount; i++) {
|
||||
particles.push(new Particle());
|
||||
}
|
||||
|
||||
function animate() {
|
||||
rings.forEach((r) => r.update());
|
||||
particles.forEach((p) => p.update());
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
animate();
|
||||
@@ -0,0 +1,700 @@
|
||||
$(document).ready(() => {
|
||||
if (!window.isAdmin()) {
|
||||
$("#users-container").html(
|
||||
document.getElementById("access-denied-template").innerHTML,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let allRoles = [];
|
||||
let users = [];
|
||||
let currentPage = 1;
|
||||
const 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/users?skip=0&limit=100"),
|
||||
Api.get("/api/users/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 skip = (currentPage - 1) * pageSize;
|
||||
|
||||
showLoadingState();
|
||||
|
||||
Api.get(`/api/users?skip=${skip}&limit=${pageSize}`)
|
||||
.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");
|
||||
let totalPayroll = 0;
|
||||
|
||||
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);
|
||||
|
||||
const fullRole = allRoles.find((r) => r.name === roleName);
|
||||
if (fullRole && fullRole.payroll) {
|
||||
totalPayroll += fullRole.payroll;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
rolesContainer.innerHTML =
|
||||
'<span class="text-gray-400 text-sm italic">Нет ролей</span>';
|
||||
}
|
||||
|
||||
if (totalPayroll > 0) {
|
||||
const payrollBadge = clone.querySelector(".user-payroll");
|
||||
const payrollAmount = clone.querySelector(".user-payroll-amount");
|
||||
|
||||
payrollBadge.classList.remove("hidden");
|
||||
payrollAmount.textContent = totalPayroll.toLocaleString("ru-RU");
|
||||
}
|
||||
|
||||
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" : ""}>←</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" : ""}>→</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/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/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-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();
|
||||
const isActive = $("#edit-user-active").prop("checked");
|
||||
|
||||
if (!email) {
|
||||
Utils.showToast("Email обязателен", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
email: email,
|
||||
full_name: fullName || null,
|
||||
is_active: isActive,
|
||||
};
|
||||
|
||||
if (password) {
|
||||
updateData.password = password;
|
||||
}
|
||||
|
||||
Api.put(`/api/users/${userId}`, updateData)
|
||||
.then((updatedUser) => {
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex] = updatedUser;
|
||||
}
|
||||
renderUsers();
|
||||
closeEditModal();
|
||||
Utils.showToast("Пользователь обновлён", "success");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка обновления", "error");
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const actionText = user.is_active ? "деактивировать" : "удалить навсегда";
|
||||
$("#delete-user-name").text(user.full_name || user.username);
|
||||
$("#delete-user-modal .text-sm.text-gray-500").html(
|
||||
`Вы уверены, что хотите <strong>${actionText}</strong> пользователя <strong>${Utils.escapeHtml(user.full_name || user.username)}</strong>?` +
|
||||
(user.is_active
|
||||
? ""
|
||||
: " <span class='text-red-600'>Это действие необратимо!</span>"),
|
||||
);
|
||||
|
||||
$("#delete-user-modal").removeClass("hidden");
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
$("#delete-user-modal").addClass("hidden");
|
||||
userToDelete = null;
|
||||
}
|
||||
|
||||
function confirmDeleteUser() {
|
||||
if (!userToDelete) return;
|
||||
|
||||
Api.delete(`/api/users/${userToDelete.id}`)
|
||||
.then((deletedUser) => {
|
||||
if (deletedUser.is_active === false) {
|
||||
const userIndex = users.findIndex((u) => u.id === userToDelete.id);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex] = deletedUser;
|
||||
}
|
||||
Utils.showToast("Пользователь деактивирован", "success");
|
||||
} else {
|
||||
users = users.filter((u) => u.id !== userToDelete.id);
|
||||
totalUsers--;
|
||||
$("#total-users-count").text(totalUsers);
|
||||
Utils.showToast("Пользователь удалён", "success");
|
||||
}
|
||||
renderUsers();
|
||||
closeDeleteModal();
|
||||
})
|
||||
.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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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-family: "Novem";
|
||||
src: url("novem.regular.ttf") format("truetype");
|
||||
src: url(novem.regular.ttf) format("truetype");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Dited";
|
||||
src: url("dited.regular.ttf") format("truetype");
|
||||
src: url(dited.regular.ttf) format("truetype");
|
||||
}
|
||||
|
||||
h1 {
|
||||
@@ -13,6 +76,9 @@ h1 {
|
||||
letter-spacing: 10px;
|
||||
}
|
||||
|
||||
.book-id,
|
||||
.book-status,
|
||||
h2,
|
||||
nav ul li a {
|
||||
font-family: "Dited", sans-serif;
|
||||
letter-spacing: 2.5px;
|
||||
@@ -70,7 +136,7 @@ nav ul li a {
|
||||
top: 6px;
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
border: solid white;
|
||||
border: solid #fff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
@@ -93,17 +159,6 @@ button:disabled {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.flex.justify-center.gap-4 button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
@@ -112,30 +167,10 @@ button:disabled {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%,
|
||||
30%,
|
||||
50%,
|
||||
70%,
|
||||
90% {
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60%,
|
||||
80% {
|
||||
transform: translateX(5px);
|
||||
}
|
||||
}
|
||||
|
||||
#req-digit,
|
||||
#req-length,
|
||||
#req-upper,
|
||||
#req-lower,
|
||||
#req-digit {
|
||||
#req-upper {
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -150,17 +185,6 @@ button:disabled {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#login-tab,
|
||||
#register-tab {
|
||||
font-family: "Dited", sans-serif;
|
||||
@@ -172,17 +196,6 @@ button:disabled {
|
||||
animation: dropdownFade 0.1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes dropdownFade {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
#user-arrow.rotate-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
@@ -195,16 +208,6 @@ button:disabled {
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-soft {
|
||||
animation: pulse-soft 2s ease-in-out infinite;
|
||||
}
|
||||
@@ -213,33 +216,55 @@ button:disabled {
|
||||
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 {
|
||||
animation: fadeInUp 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
.stat-card:hover svg {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card svg {
|
||||
.stat-card svg,
|
||||
.stat-card:hover svg {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #374151 0%, #6b7280 100%);
|
||||
background: linear-gradient(135deg, #374151 0, #6b7280 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
transition: all 0.25s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
footer a::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 0;
|
||||
width: 0;
|
||||
height: 1px;
|
||||
background: #fff;
|
||||
transition: width 0.25s ease;
|
||||
}
|
||||
|
||||
footer a:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
footer a:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
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 === null || text === undefined) return "";
|
||||
return String(text).replace(
|
||||
/[&<>"']/g,
|
||||
(m) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[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(() => ({}));
|
||||
const error = new Error("API Error");
|
||||
Object.assign(error, errorData);
|
||||
|
||||
if (typeof errorData.detail === "string") {
|
||||
error.message = errorData.detail;
|
||||
} else if (errorData.error_description) {
|
||||
error.message = errorData.error_description;
|
||||
} else if (!errorData.detail) {
|
||||
error.message = `Ошибка ${response.status}`;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
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(),
|
||||
});
|
||||
},
|
||||
|
||||
async uploadFile(endpoint, formData) {
|
||||
const fullUrl = this.getBaseUrl() + endpoint;
|
||||
const token = StorageHelper.get("access_token");
|
||||
|
||||
const headers = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await Auth.tryRefresh();
|
||||
if (refreshed) {
|
||||
headers["Authorization"] =
|
||||
`Bearer ${StorageHelper.get("access_token")}`;
|
||||
const retryResponse = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (retryResponse.ok) {
|
||||
return retryResponse.json();
|
||||
}
|
||||
}
|
||||
Auth.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
let errorMessage = `Ошибка ${response.status}`;
|
||||
|
||||
if (typeof errorData.detail === "string") {
|
||||
errorMessage = errorData.detail;
|
||||
} else if (Array.isArray(errorData.detail)) {
|
||||
errorMessage = errorData.detail.map((e) => e.msg || e).join(", ");
|
||||
} else if (errorData.detail?.message) {
|
||||
errorMessage = errorData.detail.message;
|
||||
} else if (errorData.message) {
|
||||
errorMessage = errorData.message;
|
||||
}
|
||||
|
||||
const error = new Error(errorMessage);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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())
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
{% extends "base.html" %}{% 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">
|
||||
Отсканируйте код в приложении Аутентификатора
|
||||
</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/page/2fa.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,137 @@
|
||||
{% extends "base.html" %}{% 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" selected>7 дней</option>
|
||||
<option value="30">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/page/analytics.js"></script>
|
||||
{% endblock %}
|
||||
@@ -2,15 +2,16 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title id="pageTitle">Loading...</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{ app_info.title }}</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/dagre/0.8.5/dagre.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jsPlumb/2.15.6/js/jsplumb.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
h1 {
|
||||
@@ -20,11 +21,13 @@
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
li {
|
||||
margin: 15px 0;
|
||||
}
|
||||
li { margin: 10px 0; }
|
||||
a {
|
||||
display: inline-block;
|
||||
padding: 8px 15px;
|
||||
@@ -33,29 +36,306 @@
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
font-size: 14px;
|
||||
}
|
||||
a:hover {
|
||||
background-color: #2980b9;
|
||||
a:hover { background-color: #2980b9; }
|
||||
p { margin: 5px 0; }
|
||||
.status-ok { color: #27ae60; }
|
||||
.status-error { color: #e74c3c; }
|
||||
.server-time { color: #7f8c8d; font-size: 12px; }
|
||||
#erDiagram {
|
||||
position: relative;
|
||||
width: 100%; height: 700px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
margin-top: 30px;
|
||||
background: #fcfcfc;
|
||||
background-image:
|
||||
linear-gradient(#eee 1px, transparent 1px),
|
||||
linear-gradient(90deg, #eee 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
background-position: -1px -1px;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
p {
|
||||
margin: 5px 0;
|
||||
#erDiagram:active { cursor: grabbing; }
|
||||
.er-table {
|
||||
position: absolute;
|
||||
width: 200px;
|
||||
background: #fff;
|
||||
border: 1px solid #bdc3c7;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.er-table-header {
|
||||
background: #3498db;
|
||||
color: #ecf0f1;
|
||||
padding: 8px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.er-table-body {
|
||||
background: #fff;
|
||||
padding: 4px 0;
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
.er-field {
|
||||
padding: 4px 10px;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
.er-field:hover {
|
||||
background-color: #ecf0f1;
|
||||
color: #2980b9;
|
||||
}
|
||||
.relation-label {
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: white;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid #bdc3c7;
|
||||
border-radius: 3px;
|
||||
color: #7f8c8d;
|
||||
z-index: 20;
|
||||
}
|
||||
.jtk-connector { z-index: 5; }
|
||||
.jtk-endpoint { z-index: 5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="/favicon.ico" />
|
||||
<h1>Welcome to {{ app_info.title }}!</h1>
|
||||
<p>Description: {{ app_info.description }}</p>
|
||||
<p>Version: {{ app_info.version }}</p>
|
||||
<p>Current Time: {{ server_time }}</p>
|
||||
<p>Status: {{ status }}</p>
|
||||
<h1 id="mainTitle">Загрузка...</h1>
|
||||
<p>Версия: <span id="appVersion">-</span></p>
|
||||
<p>Описание: <span id="appDescription">-</span></p>
|
||||
<p>Статус: <span id="appStatus">-</span></p>
|
||||
<p class="server-time">Время сервера: <span id="serverTime">-</span></p>
|
||||
<ul>
|
||||
<li><a href="/">Home page</a></li>
|
||||
<li>
|
||||
<a href="https://github.com/wowlikon/LibraryAPI">Source Code</a>
|
||||
</li>
|
||||
<li><a href="/">Главная</a></li>
|
||||
<li><a href="/docs">Swagger UI</a></li>
|
||||
<li><a href="/redoc">ReDoc</a></li>
|
||||
<li><a href="https://github.com/wowlikon/LiB">Исходный код</a></li>
|
||||
</ul>
|
||||
<h2>Интерактивная ER диаграмма</h2>
|
||||
<div id="erDiagram"></div>
|
||||
<script>
|
||||
async function fetchInfo() {
|
||||
try {
|
||||
const response = await fetch('/api/info');
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('pageTitle').textContent = data.app_info.title;
|
||||
document.getElementById('mainTitle').textContent = `Добро пожаловать в ${data.app_info.title} API!`;
|
||||
document.getElementById('appVersion').textContent = data.app_info.version;
|
||||
document.getElementById('appDescription').textContent = data.app_info.description;
|
||||
|
||||
const statusEl = document.getElementById('appStatus');
|
||||
statusEl.textContent = data.status;
|
||||
statusEl.className = data.status === 'ok' ? 'status-ok' : 'status-error';
|
||||
|
||||
document.getElementById('serverTime').textContent = new Date(data.server_time).toLocaleString();
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки info:', error);
|
||||
document.getElementById('appStatus').textContent = 'Ошибка соединения';
|
||||
document.getElementById('appStatus').className = 'status-error';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSchemaAndRender() {
|
||||
try {
|
||||
const response = await fetch('/api/schema');
|
||||
const diagramData = await response.json();
|
||||
renderDiagram(diagramData);
|
||||
} catch (error) {
|
||||
console.error('Ошибка загрузки схемы:', error);
|
||||
document.getElementById('erDiagram').innerHTML = '<p style="padding:20px;color:#e74c3c;">Ошибка загрузки схемы</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiagram(diagramData) {
|
||||
jsPlumb.ready(function () {
|
||||
const instance = jsPlumb.getInstance({
|
||||
Container: "erDiagram",
|
||||
Endpoint: "Blank",
|
||||
Connector: ["Flowchart", { stub: 30, gap: 0, cornerRadius: 5, alwaysRespectStubs: true }]
|
||||
});
|
||||
|
||||
const container = document.getElementById("erDiagram");
|
||||
const tableWidth = 200;
|
||||
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({
|
||||
nodesep: 60, ranksep: 80,
|
||||
marginx: 20, marginy: 20,
|
||||
rankdir: 'LR',
|
||||
});
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
const fieldIndexByEntity = {};
|
||||
diagramData.entities.forEach(entity => {
|
||||
const idxMap = {};
|
||||
entity.fields.forEach((field, idx) => { idxMap[field.id] = idx; });
|
||||
fieldIndexByEntity[entity.id] = idxMap;
|
||||
});
|
||||
|
||||
diagramData.entities.forEach(entity => {
|
||||
const table = document.createElement("div");
|
||||
table.className = "er-table";
|
||||
table.id = "table-" + entity.id;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "er-table-header";
|
||||
header.textContent = entity.title || entity.id;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "er-table-body";
|
||||
|
||||
entity.fields.forEach(field => {
|
||||
const row = document.createElement("div");
|
||||
row.className = "er-field";
|
||||
row.id = `field-${entity.id}-${field.id}`;
|
||||
row.style.display = "flex";
|
||||
row.style.alignItems = "center";
|
||||
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.textContent = field.label || field.id;
|
||||
row.appendChild(labelSpan);
|
||||
|
||||
if (field.tooltip) {
|
||||
row.title = field.tooltip;
|
||||
|
||||
const tip = document.createElement("span");
|
||||
tip.textContent = "ⓘ";
|
||||
tip.title = field.tooltip;
|
||||
tip.style.marginLeft = "4px";
|
||||
tip.style.marginRight = "0";
|
||||
tip.style.fontSize = "10px";
|
||||
tip.style.cursor = "help";
|
||||
tip.style.marginLeft = "auto";
|
||||
row.appendChild(tip);
|
||||
}
|
||||
|
||||
body.appendChild(row);
|
||||
});
|
||||
|
||||
table.appendChild(header);
|
||||
table.appendChild(body);
|
||||
container.appendChild(table);
|
||||
|
||||
const estimatedHeight = 20 + (entity.fields.length * 26);
|
||||
g.setNode(entity.id, { width: tableWidth, height: estimatedHeight });
|
||||
});
|
||||
|
||||
const layoutEdges = [];
|
||||
const m2oGroups = {};
|
||||
|
||||
diagramData.relations.forEach(rel => {
|
||||
const isManyToOne = (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') && (rel.toMultiplicity === '1');
|
||||
|
||||
if (isManyToOne) {
|
||||
const fi = (fieldIndexByEntity[rel.fromEntity] || {})[rel.fromField] ?? 0;
|
||||
if (!m2oGroups[rel.fromEntity]) m2oGroups[rel.fromEntity] = [];
|
||||
m2oGroups[rel.fromEntity].push({ rel, fieldIndex: fi });
|
||||
} else {
|
||||
layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity });
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(m2oGroups).forEach(fromEntity => {
|
||||
const arr = m2oGroups[fromEntity];
|
||||
arr.sort((a, b) => a.fieldIndex - b.fieldIndex);
|
||||
|
||||
arr.forEach((item, idx) => {
|
||||
const rel = item.rel;
|
||||
if (idx % 2 === 0) { layoutEdges.push({ source: rel.toEntity, target: rel.fromEntity });
|
||||
} else { layoutEdges.push({ source: rel.fromEntity, target: rel.toEntity }); }
|
||||
});
|
||||
});
|
||||
|
||||
layoutEdges.forEach(e => g.setEdge(e.source, e.target));
|
||||
dagre.layout(g);
|
||||
|
||||
g.nodes().forEach(function(v) {
|
||||
const node = g.node(v);
|
||||
const el = document.getElementById("table-" + v);
|
||||
el.style.left = (node.x - (tableWidth / 2)) + "px";
|
||||
el.style.top = (node.y - (node.height / 2)) + "px";
|
||||
});
|
||||
|
||||
diagramData.relations.forEach(rel => {
|
||||
const overlays = [];
|
||||
|
||||
if (rel.fromMultiplicity === '1') {
|
||||
overlays.push(["Arrow", {
|
||||
location: 8, width: 14, length: 1, foldback: 1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: 14, width: 14, length: 1, foldback: 1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
} else if (rel.fromMultiplicity === 'N' || rel.fromMultiplicity === '*') {
|
||||
overlays.push(["Arrow", {
|
||||
location: 8, width: 14, length: 1, foldback: 1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: 10, width: 14, length: 10, foldback: 0.1, direction: 1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
}
|
||||
|
||||
if (rel.toMultiplicity === '1') {
|
||||
overlays.push(["Arrow", {
|
||||
location: -8, width: 14, length: 1, foldback: 1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: -14, width: 14, length: 1, foldback: 1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
} else if (rel.toMultiplicity === 'N' || rel.toMultiplicity === '*') {
|
||||
overlays.push(["Arrow", {
|
||||
location: -8, width: 14, length: 1, foldback: 1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
overlays.push(["Arrow", {
|
||||
location: -10, width: 14, length: 10, foldback: 0.1, direction: -1,
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2, fill: "transparent" }
|
||||
}]);
|
||||
}
|
||||
|
||||
instance.connect({
|
||||
source: `field-${rel.fromEntity}-${rel.fromField}`,
|
||||
target: `field-${rel.toEntity}-${rel.toField}`,
|
||||
anchor: ["Continuous", { faces: ["left", "right"] }],
|
||||
paintStyle: { stroke: "#7f8c8d", strokeWidth: 2 },
|
||||
hoverPaintStyle: { stroke: "#3498db", strokeWidth: 3 },
|
||||
overlays: overlays
|
||||
});
|
||||
});
|
||||
|
||||
const tableIds = diagramData.entities.map(e => "table-" + e.id);
|
||||
instance.draggable(tableIds, {containment: "parent", stop: instance.repaintEverything});
|
||||
});
|
||||
}
|
||||
|
||||
fetchInfo();
|
||||
setInterval(fetchInfo, 60000);
|
||||
|
||||
fetchSchemaAndRender();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+289
-260
@@ -1,327 +1,356 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Авторизация{% endblock %} {%
|
||||
block content %}
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4">
|
||||
<div class="w-full max-w-md">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="flex border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
id="login-tab"
|
||||
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500"
|
||||
>
|
||||
<div id="auth-tabs" class="flex border-b border-gray-200">
|
||||
<button type="button" id="login-tab"
|
||||
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-700 bg-gray-50 border-b-2 border-gray-500">
|
||||
Вход
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="register-tab"
|
||||
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<button type="button" id="register-tab"
|
||||
class="flex-1 py-4 text-center font-medium transition duration-200 text-gray-400 hover:text-gray-600">
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="p-6">
|
||||
<div id="credentials-section">
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="login-username"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Имя пользователя</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="login-username"
|
||||
name="username"
|
||||
<label for="login-username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input type="text" id="login-username" name="username"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Введите имя пользователя"
|
||||
required
|
||||
/>
|
||||
placeholder="Введите имя пользователя" required />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="login-password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Пароль</label
|
||||
>
|
||||
<label for="login-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="login-password"
|
||||
name="password"
|
||||
<input type="password" id="login-password" name="password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Введите пароль"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onclick="togglePassword(this)"
|
||||
>
|
||||
<svg
|
||||
class="eye-open w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
placeholder="Введите пароль" required />
|
||||
<button type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg
|
||||
class="eye-closed w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<label
|
||||
class="custom-checkbox flex items-center text-sm text-gray-600"
|
||||
>
|
||||
<label class="custom-checkbox flex items-center text-sm text-gray-600">
|
||||
<input type="checkbox" id="remember-me" />
|
||||
<span class="checkmark"></span>Запомнить меня
|
||||
</label>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition"
|
||||
>Забыли пароль?</a
|
||||
>
|
||||
<button type="button" id="forgot-password-btn"
|
||||
class="text-sm text-gray-500 hover:text-gray-700 transition">
|
||||
Забыли пароль?
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
id="login-error"
|
||||
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
||||
></div>
|
||||
<button
|
||||
type="submit"
|
||||
id="login-submit"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div id="totp-section" class="hidden">
|
||||
<div class="text-center mb-4">
|
||||
<div class="w-20 h-20 mx-auto relative flex items-center justify-center mb-3">
|
||||
<svg class="absolute inset-0 w-full h-full -rotate-90" viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="38" fill="none" stroke="#e5e7eb" stroke-width="2" />
|
||||
<circle id="lock-progress-circle" cx="40" cy="40" r="38" fill="none" stroke="#000000"
|
||||
stroke-width="2" stroke-linecap="round"
|
||||
style="stroke-dasharray: 238.761; stroke-dashoffset: 238.761;" />
|
||||
</svg>
|
||||
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center z-10">
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-800">Двухфакторная аутентификация</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Введите код из приложения аутентификатора
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<input type="text" id="login-totp" name="totp_code"
|
||||
class="w-full p-4 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center text-3xl tracking-[0.5em] font-mono"
|
||||
placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" />
|
||||
</div>
|
||||
|
||||
<button type="button" id="back-to-credentials-btn"
|
||||
class="w-full mb-4 text-gray-500 hover:text-gray-700 text-sm flex items-center justify-center gap-1">
|
||||
<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 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Назад
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="login-submit"
|
||||
class="w-full bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
<form
|
||||
id="register-form"
|
||||
class="p-6 hidden"
|
||||
onsubmit="return handleRegister(event);"
|
||||
>
|
||||
|
||||
<form id="register-form" class="p-6 hidden">
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-username"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Имя пользователя</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="register-username"
|
||||
name="username"
|
||||
<label for="register-username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input type="text" id="register-username" name="username"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Придумайте имя пользователя (мин. 3 символа)"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="50"
|
||||
/>
|
||||
placeholder="Придумайте имя пользователя" required minlength="3" maxlength="50" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-email"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
id="register-email"
|
||||
name="email"
|
||||
<label for="register-email" class="block text-sm font-medium text-gray-700 mb-2">Email</label>
|
||||
<input type="email" id="register-email" name="email"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="example@mail.com"
|
||||
required
|
||||
/>
|
||||
placeholder="example@mail.com" required />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-fullname"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Полное имя
|
||||
<span class="text-gray-400"
|
||||
>(необязательно)</span
|
||||
></label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="register-fullname"
|
||||
name="full_name"
|
||||
<label for="register-fullname" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Полное имя <span class="text-gray-400">(необязательно)</span>
|
||||
</label>
|
||||
<input type="text" id="register-fullname" name="full_name"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Иван Иванов"
|
||||
maxlength="100"
|
||||
/>
|
||||
placeholder="Иван Иванов" maxlength="100" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-password"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Пароль</label
|
||||
>
|
||||
<label for="register-password" class="block text-sm font-medium text-gray-700 mb-2">Пароль</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="register-password"
|
||||
name="password"
|
||||
<input type="password" id="register-password" name="password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Минимум 8 символов, A-Z, a-z, 0-9"
|
||||
required
|
||||
minlength="8"
|
||||
maxlength="100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onclick="togglePassword(this)"
|
||||
>
|
||||
<svg
|
||||
class="eye-open w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
placeholder="Минимум 8 символов" required minlength="8" maxlength="100" />
|
||||
<button type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg
|
||||
class="eye-closed w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<div
|
||||
class="h-1 w-full bg-gray-200 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
id="password-strength-bar"
|
||||
class="h-full w-0 transition-all duration-300"
|
||||
></div>
|
||||
<div class="h-1 w-full bg-gray-200 rounded-full overflow-hidden">
|
||||
<div id="password-strength-bar" class="h-full w-0 transition-all duration-300"></div>
|
||||
</div>
|
||||
<p
|
||||
id="password-strength-text"
|
||||
class="text-xs mt-1 text-gray-500"
|
||||
></p>
|
||||
<p id="password-strength-text" class="text-xs mt-1 text-gray-500"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
for="register-password-confirm"
|
||||
class="block text-sm font-medium text-gray-700 mb-2"
|
||||
>Подтвердите пароль</label
|
||||
>
|
||||
<label for="register-password-confirm" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Подтвердите пароль
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="password"
|
||||
id="register-password-confirm"
|
||||
name="password_confirm"
|
||||
<input type="password" id="register-password-confirm" name="password_confirm"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Повторите пароль"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
onclick="togglePassword(this)"
|
||||
>
|
||||
<svg
|
||||
class="eye-open w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
></path>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
></path>
|
||||
placeholder="Повторите пароль" required />
|
||||
<button type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg
|
||||
class="eye-closed w-5 h-5 hidden"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
></path>
|
||||
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
id="password-match-error"
|
||||
class="text-xs mt-1 text-red-500 hidden"
|
||||
>
|
||||
<p id="password-match-error" class="text-xs mt-1 text-red-500 hidden">
|
||||
Пароли не совпадают
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
id="register-error"
|
||||
class="hidden mb-4 p-3 bg-red-100 border border-red-300 text-red-700 rounded-lg text-sm"
|
||||
></div>
|
||||
<div
|
||||
id="register-success"
|
||||
class="hidden mb-4 p-3 bg-green-100 border border-green-300 text-green-700 rounded-lg text-sm"
|
||||
></div>
|
||||
<button
|
||||
type="submit"
|
||||
id="register-submit"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
|
||||
<div class="mb-4">
|
||||
<cap-widget id="cap"
|
||||
data-cap-api-endpoint="/api/cap/"
|
||||
style="
|
||||
--cap-widget-width: 100%;
|
||||
--cap-background: #fdfdfd;
|
||||
--cap-border-color: #d1d5db;
|
||||
--cap-border-radius: 8px;
|
||||
--cap-widget-height: auto;
|
||||
--cap-color: #212121;
|
||||
--cap-checkbox-size: 32px;
|
||||
--cap-checkbox-border: 1.5px dashed #d1d5db;
|
||||
--cap-checkbox-border-radius: 6px;
|
||||
--cap-checkbox-background: #fafafa;
|
||||
--cap-checkbox-margin: 2px;
|
||||
--cap-spinner-color: #4b5563;
|
||||
--cap-spinner-background-color: #eee;
|
||||
--cap-spinner-thickness: 5px;"
|
||||
></cap-widget>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="register-submit"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
|
||||
Зарегистрироваться
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<form id="reset-password-form" class="p-6 hidden">
|
||||
<div class="mb-4 text-center">
|
||||
<h3 class="text-lg font-semibold text-gray-800">Сброс пароля</h3>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
Используйте один из резервных кодов, полученных при регистрации
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="reset-username" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Имя пользователя
|
||||
</label>
|
||||
<input type="text" id="reset-username" name="username"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Введите имя пользователя" required />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="reset-recovery-code" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Резервный код
|
||||
</label>
|
||||
<input type="text" id="reset-recovery-code" name="recovery_code"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200 text-center font-mono uppercase"
|
||||
placeholder="XXXX-XXXX-XXXX-XXXX" maxlength="19" required
|
||||
pattern="[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}" />
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="reset-new-password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Новый пароль
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password" id="reset-new-password" name="new_password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Минимум 8 символов" required minlength="8" maxlength="100" />
|
||||
<button type="button"
|
||||
class="toggle-password absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600">
|
||||
<svg class="eye-open w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="eye-closed w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="reset-confirm-password" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Подтвердите новый пароль
|
||||
</label>
|
||||
<input type="password" id="reset-confirm-password" name="confirm_password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition duration-200"
|
||||
placeholder="Повторите новый пароль" required />
|
||||
<p id="reset-password-match-error" class="text-xs mt-1 text-red-500 hidden">
|
||||
Пароли не совпадают
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button type="submit" id="reset-submit"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium">
|
||||
Сбросить пароль
|
||||
</button>
|
||||
<button type="button" id="back-to-login-btn"
|
||||
class="w-full mt-3 text-gray-500 hover:text-gray-700 text-sm">
|
||||
← Вернуться к входу
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recovery-codes-modal"
|
||||
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-yellow-100 rounded-full mb-4">
|
||||
<svg class="w-6 h-6 text-yellow-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>
|
||||
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
|
||||
Сохраните резервные коды!
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 text-center mb-4">
|
||||
Эти коды понадобятся для восстановления доступа к аккаунту.
|
||||
<strong class="text-red-600">Сохраните их в надёжном месте!</strong>
|
||||
</p>
|
||||
|
||||
<div id="recovery-codes-list"
|
||||
class="bg-gray-50 rounded-lg p-4 font-mono text-sm text-center space-y-2 mb-4">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button type="button" id="copy-codes-btn"
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
|
||||
<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 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
Копировать
|
||||
</button>
|
||||
<button type="button" id="download-codes-btn"
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Скачать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 mb-4">
|
||||
<input type="checkbox" id="codes-saved-checkbox" class="rounded" />
|
||||
Я сохранил(а) коды в надёжном месте
|
||||
</label>
|
||||
|
||||
<button type="button" id="close-recovery-modal-btn" disabled
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
#auth-tabs {
|
||||
transition: transform 0.3s ease, opacity 0.2s ease, height 0.1s ease;
|
||||
transform: translateY(0);
|
||||
}
|
||||
#auth-tabs.hide-animated {
|
||||
transform: translateY(-12px);
|
||||
pointer-events: none;
|
||||
height: 0; opacity: 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/auth.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@cap.js/widget"></script>
|
||||
<script src="/static/page/auth.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-4xl">
|
||||
<div id="author-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a
|
||||
href="/authors"
|
||||
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<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 id="author-loader" class="flex items-start animate-pulse">
|
||||
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
<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 %}
|
||||
<script src="/static/page/author.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,129 @@
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<div
|
||||
class="flex flex-col md:flex-row justify-between items-center mb-6 gap-4"
|
||||
>
|
||||
<h2 class="text-2xl font-bold text-gray-800">Авторы</h2>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 w-full md:w-auto">
|
||||
<a href="/author/create" id="add-author-btn" class="hidden flex justify-center items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition whitespace-nowrap">
|
||||
<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"></path>
|
||||
</svg>
|
||||
Добавить автора
|
||||
</a>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="author-search-input"
|
||||
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
|
||||
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>
|
||||
</template>
|
||||
<template id="empty-state-template">
|
||||
<div class="col-span-full 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>
|
||||
</template>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/page/authors.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,77 +1,319 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<title>{% block title %}LiB{% endblock %}</title>
|
||||
<title>{{ title }}</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async="" src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
|
||||
<meta property="og:title" content="{{ title }}" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:description" content="Ваша персональная библиотека книг" />
|
||||
<meta property="og:url" content="{{ request.url.scheme }}://{{ domain }}/" />
|
||||
<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://cdn.tailwindcss.com"></script>
|
||||
<script src="/static/utils.js"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen bg-gray-100">
|
||||
<header class="bg-gray-500 text-white p-4 shadow-md">
|
||||
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
||||
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||
<body
|
||||
class="flex flex-col min-h-screen bg-gray-100"
|
||||
x-data="{
|
||||
user: null,
|
||||
menuOpen: false,
|
||||
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 px-3 md:pl-5 md:pr-3 flex justify-between items-center">
|
||||
<div class="flex items-center">
|
||||
<button
|
||||
@click="menuOpen = !menuOpen"
|
||||
class="md:hidden flex gap-2 items-center hover:opacity-80 transition focus:outline-none"
|
||||
:aria-expanded="menuOpen"
|
||||
aria-label="Меню навигации"
|
||||
>
|
||||
<img class="invert max-w-10 h-auto" src="/static/logo.svg" />
|
||||
<h1 class="text-xl font-bold">
|
||||
<span class="text-gray-300 mr-1">≡</span>LiB
|
||||
</h1>
|
||||
</button>
|
||||
|
||||
<a class="hidden md:flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||
<img class="invert" src="/static/logo.svg" />
|
||||
<h1 class="text-2xl font-bold">LiB</h1>
|
||||
</a>
|
||||
<nav>
|
||||
</div>
|
||||
|
||||
<nav class="hidden md:block">
|
||||
<ul class="flex space-x-4">
|
||||
<li><a href="/" class="hover:text-gray-200">Главная</a></li>
|
||||
<li><a href="/books" class="hover:text-gray-200">Книги</a></li>
|
||||
<li><a href="/about" class="hover:text-gray-200">О нас</a></li>
|
||||
<li><a href="/api" class="hover:text-gray-200">API</a></li>
|
||||
<li>
|
||||
<a href="/" class="hover:text-gray-200">Главная</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/books" class="hover:text-gray-200">Книги</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/authors" class="hover:text-gray-200">Авторы</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api" class="hover:text-gray-200">API</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="relative" id="user-menu-area">
|
||||
<a href="/auth" id="guest-link" class="block hover:opacity-80 transition"><img class="w-6 h-6 invert" src="/static/avatar.svg" /></a>
|
||||
<button type="button" id="user-btn" class="hidden items-center gap-2 hover:opacity-80 transition focus:outline-none">
|
||||
<img
|
||||
id="user-avatar"
|
||||
src="https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y"
|
||||
class="w-8 h-8 rounded-full border-2 border-white object-cover bg-gray-600"
|
||||
alt="User Avatar"
|
||||
/>
|
||||
|
||||
<svg id="user-arrow" class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
<div class="relative" x-data="{ open: false }">
|
||||
<template x-if="!user">
|
||||
<a
|
||||
href="/auth"
|
||||
class="block hover:opacity-80 transition"
|
||||
>
|
||||
<svg
|
||||
class="w-7 h-7"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
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"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</template>
|
||||
<template x-if="user">
|
||||
<div>
|
||||
<button
|
||||
@click="open = !open"
|
||||
@click.outside="open = false"
|
||||
type="button"
|
||||
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>
|
||||
<div id="user-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-56 max-w-[calc(100vw-2rem)] 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 id="dropdown-name" class="text-sm font-semibold text-gray-900 truncate">Пользователь</p>
|
||||
<p id="dropdown-username" class="text-sm text-gray-500 truncate">@username</p>
|
||||
<p id="dropdown-email" class="text-xs text-gray-400 truncate mt-1">email@example.com</p>
|
||||
<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">
|
||||
<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
|
||||
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">
|
||||
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg>
|
||||
<p class="text-gray-700 text-sm">Мои книги</p>
|
||||
<a
|
||||
href="/my-books"
|
||||
class="flex items-center px-4 py-2 text-sm hover:bg-gray-100 text-gray-700"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 mr-3 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
></path>
|
||||
</svg>
|
||||
Мои книги
|
||||
</a>
|
||||
<div class="border-t border-gray-200 mt-1 pt-1">
|
||||
<button type="button" id="logout-btn" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
|
||||
<p class="text-gray-700 text-sm">Выйти</p>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
x-show="menuOpen"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
@click.outside="menuOpen = false"
|
||||
class="md:hidden mt-4 pb-2 border-t border-gray-500"
|
||||
style="display: none"
|
||||
>
|
||||
<ul class="flex flex-col space-y-1 pt-3">
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Главная
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/books"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Книги
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/authors"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
Авторы
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/api"
|
||||
@click="menuOpen = false"
|
||||
class="block px-3 py-2 rounded hover:bg-gray-500 transition"
|
||||
>
|
||||
API
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<main class="flex-grow">{% block content %}{% endblock %}</main>
|
||||
<div
|
||||
id="toast-container"
|
||||
class="fixed bottom-5 left-4 right-4 md:left-auto md:right-5 flex flex-col gap-2 z-50 items-center md:items-end"
|
||||
></div>
|
||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||
<div class="container mx-auto text-center">
|
||||
<p>© 2025 My Awesome Library. All rights reserved.</p>
|
||||
<div class="container mx-auto text-center text-sm md:text-base">
|
||||
<p>
|
||||
© 2026 LiB Library. Разработано в рамках дипломного проекта.
|
||||
<br class="sm:hidden" />
|
||||
Код открыт под лицензией
|
||||
<a href="https://github.com/wowlikon/LiB/blob/main/LICENSE" class="underline hover:text-gray-300">MIT</a>.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,310 @@
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-6xl">
|
||||
<div id="book-card" class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<a
|
||||
href="/books"
|
||||
class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<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
|
||||
id="book-loader"
|
||||
class="flex flex-col md:flex-row items-start animate-pulse"
|
||||
>
|
||||
<div
|
||||
class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"
|
||||
></div>
|
||||
<div class="flex-1 w-full">
|
||||
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="book-content"
|
||||
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
|
||||
id="book-cover-container"
|
||||
class="relative w-40 h-56 rounded-lg shadow-md mb-4 overflow-hidden flex items-center justify-center bg-gray-100 group"
|
||||
>
|
||||
<div class="w-full h-full bg-gradient-to-br from-gray-400 to-gray-600 flex items-center justify-center">
|
||||
<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>
|
||||
<input type="file" id="cover-file-input" class="hidden" accept="image/*" />
|
||||
<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>
|
||||
<p id="book-page-count-text" class="text-sm text-gray-500 mb-6 hidden">
|
||||
<span class="font-medium">Количество страниц:</span>
|
||||
<span id="book-page-count-value"></span>
|
||||
</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 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"
|
||||
>​</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>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/page/book.js"></script>
|
||||
{% endblock %}
|
||||
{% block extra_head %}
|
||||
{% if img %}
|
||||
<meta property="og:image" content="{{ request.url.scheme }}://{{ domain }}/static/books/{{ img }}.jpg" />
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -1,21 +1,112 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Главная{% endblock %} {% block
|
||||
content %}
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<aside
|
||||
class="w-1/4 bg-white p-4 rounded-lg shadow-md mr-4 h-fit resize-x overflow-auto min-w-64 max-w-96"
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<style>
|
||||
.range-double {
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.range-double::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 100%;
|
||||
background: #4b5563;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px #4b5563;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.range-double::-moz-range-thumb {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 100%;
|
||||
background: #4b5563;
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 0 0 1px #4b5563;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.range-double::-moz-range-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
<div class="container mx-auto p-4 flex flex-col md:flex-row gap-6">
|
||||
<aside class="w-full md:w-1/4">
|
||||
<div
|
||||
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
|
||||
href="/author/create"
|
||||
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Добавить автора
|
||||
</a>
|
||||
<a
|
||||
href="/genre/create"
|
||||
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Добавить жанр
|
||||
</a>
|
||||
<a
|
||||
href="/book/create"
|
||||
class="w-full flex justify-center items-center px-4 py-2 my-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Добавить книгу
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Поиск</h2>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="book-search-input"
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
placeholder="Поиск книг (мин. 3 символа)..."
|
||||
minlength="3"
|
||||
maxlength="50"
|
||||
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-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
class="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -25,55 +116,185 @@ content %}
|
||||
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>
|
||||
<h2 class="text-xl font-semibold mb-4">Фильтры</h2>
|
||||
<div class="mb-4">
|
||||
<h3 class="font-medium mb-2">Авторы</h3>
|
||||
<div class="relative">
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white min-h-[42px]"
|
||||
id="selected-authors-container"
|
||||
x-data="pagesSlider(0, 2000, 10)"
|
||||
class="bg-white p-4 rounded-lg shadow-md mb-6"
|
||||
>
|
||||
<h2 class="text-xl font-bold mb-4">Страниц</h2>
|
||||
|
||||
<div class="flex justify-between text-xs text-gray-500 mb-2">
|
||||
<span>От: <span id="pages-min-value" x-text="minValue"></span></span>
|
||||
<span>До: <span id="pages-max-value" x-text="maxValue"></span></span>
|
||||
</div>
|
||||
|
||||
<div class="relative mt-4 mb-6">
|
||||
<div class="absolute top-1/2 -translate-y-1/2 w-full h-1 bg-gray-200 rounded-full"></div>
|
||||
|
||||
<div
|
||||
id="pages-range-progress"
|
||||
class="absolute top-1/2 -translate-y-1/2 h-1 bg-gray-600 rounded-full"
|
||||
:style="{ left: leftPercent + '%', right: rightPercent + '%' }"
|
||||
></div>
|
||||
|
||||
<input
|
||||
id="pages-min"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
x-model.number="minValue"
|
||||
@input="onMinInput()"
|
||||
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
|
||||
/>
|
||||
|
||||
<input
|
||||
id="pages-max"
|
||||
type="range"
|
||||
:min="min"
|
||||
:max="max"
|
||||
x-model.number="maxValue"
|
||||
@input="onMaxInput()"
|
||||
class="range-double absolute top-0 left-0 w-full bg-transparent appearance-none pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Авторы</h2>
|
||||
<div
|
||||
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"
|
||||
class="flex-grow outline-none bg-transparent min-w-[100px]"
|
||||
placeholder="Начните вводить..."
|
||||
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"
|
||||
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 class="mb-4">
|
||||
<h3 class="font-medium mb-2">Жанры</h3>
|
||||
<ul id="genres-list" class="max-h-60 overflow-y-auto"></ul>
|
||||
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Жанры</h2>
|
||||
<ul
|
||||
id="genres-list"
|
||||
class="space-y-2 max-h-60 overflow-y-auto text-sm text-gray-700"
|
||||
></ul>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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
|
||||
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>
|
||||
<div
|
||||
id="results-counter"
|
||||
class="mt-4 text-center text-sm text-gray-500"
|
||||
></div>
|
||||
</aside>
|
||||
<main class="flex-1">
|
||||
<div id="books-container"></div>
|
||||
<main class="w-full md:w-3/4">
|
||||
<div id="books-container" class="grid grid-cols-1 gap-4"></div>
|
||||
<div id="pagination-container"></div>
|
||||
</main>
|
||||
</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-page-count text-sm text-gray-600 mb-2 hidden">
|
||||
<span class="font-medium">Страниц:</span>
|
||||
<span class="page-count-value"></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 %}
|
||||
<script type="text/javascript" src="/static/books.js"></script>
|
||||
<script src="/static/page/books.js"></script>
|
||||
<script>
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('pagesSlider', (min, max, gap) => ({
|
||||
min,
|
||||
max,
|
||||
gap,
|
||||
minValue: min,
|
||||
maxValue: max,
|
||||
|
||||
// проценты для заливки
|
||||
get leftPercent() {
|
||||
return (this.minValue - this.min) * 100 / (this.max - this.min);
|
||||
},
|
||||
get rightPercent() {
|
||||
return 100 - (this.maxValue - this.min) * 100 / (this.max - this.min);
|
||||
},
|
||||
|
||||
onMinInput() {
|
||||
if (this.maxValue - this.minValue < this.gap) {
|
||||
this.minValue = this.maxValue - this.gap;
|
||||
}
|
||||
},
|
||||
onMaxInput() {
|
||||
if (this.maxValue - this.minValue < this.gap) {
|
||||
this.maxValue = this.minValue + this.gap;
|
||||
}
|
||||
}
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}{% 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/page/create_author.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,244 @@
|
||||
{% extends "base.html" %}{% 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>
|
||||
<label
|
||||
for="book-page-count"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Количество страниц
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="book-page-count"
|
||||
name="page_count"
|
||||
min="1"
|
||||
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>
|
||||
<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/page/create_book.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "base.html" %}{% 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/page/create_genre.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,315 @@
|
||||
{% extends "base.html" %}{% 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/page/edit_author.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,410 @@
|
||||
{% extends "base.html" %}{% 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-page-count"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Количество страниц
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="book-page-count"
|
||||
name="page_count"
|
||||
min="1"
|
||||
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>
|
||||
|
||||
<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/page/edit_book.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,316 @@
|
||||
{% extends "base.html" %}{% 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="/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 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/page/edit_genre.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Библиотека{% endblock %} {%
|
||||
block content %}
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4">
|
||||
<div class="w-full max-w-4xl">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
@@ -186,10 +186,10 @@ block content %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 text-center text-gray-400 text-sm">
|
||||
<p>LiB — Библиотека. Создано с ❤️</p>
|
||||
<p>LiB — Библиотека. Красиво, функционально, безопасно.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/index.js"></script>
|
||||
<script src="/static/page/index.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{% 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/page/my_books.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,293 @@
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-2xl"
|
||||
x-data="{ showPasswordModal: false, showDisable2FAModal: false, showRecoveryCodesModal: false, is2FAEnabled: false, recoveryCodesRemaining: null }"
|
||||
@update-2fa.window="is2FAEnabled = $event.detail"
|
||||
@update-recovery-codes.window="recoveryCodesRemaining = $event.detail"
|
||||
@close-password-modal.window="showPasswordModal = false"
|
||||
@close-disable-2fa-modal.window="showDisable2FAModal = false"
|
||||
@close-recovery-codes-modal.window="showRecoveryCodesModal = false"
|
||||
@open-recovery-codes-modal.window="showRecoveryCodesModal = true">
|
||||
|
||||
<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 rounded-lg shadow-md p-6 mb-6 hidden">
|
||||
<h2 class="text-xl font-bold mb-4 border-b pb-2">Информация</h2>
|
||||
<div id="account-info" class="space-y-4"></div>
|
||||
</div>
|
||||
|
||||
<div id="roles-section" class="bg-white rounded-lg shadow-md p-6 mb-6 hidden">
|
||||
<h2 class="text-xl font-bold mb-4 border-b pb-2">Роли</h2>
|
||||
<div id="roles-container" class="space-y-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 class="text-xl font-bold mb-4 border-b pb-2">Безопасность</h2>
|
||||
<div class="space-y-3">
|
||||
<button type="button"
|
||||
@click="is2FAEnabled ? showDisable2FAModal = true : window.location.href = '/2fa'"
|
||||
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 gap-3">
|
||||
<svg class="w-5 h-5 text-gray-500" 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" />
|
||||
</svg>
|
||||
<span class="text-gray-700 font-medium">Двухфакторная аутентификация</span>
|
||||
</div>
|
||||
<span x-show="is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
|
||||
Включена
|
||||
</span>
|
||||
<span x-show="!is2FAEnabled" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-600">
|
||||
Выключена
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button type="button" id="recovery-codes-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 gap-3">
|
||||
<svg class="w-5 h-5 text-gray-500" 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 font-medium">Резервные коды</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template x-if="recoveryCodesRemaining !== null">
|
||||
<span :class="{
|
||||
'bg-green-100 text-green-800': recoveryCodesRemaining > 5,
|
||||
'bg-yellow-100 text-yellow-800': recoveryCodesRemaining > 2 && recoveryCodesRemaining <= 5,
|
||||
'bg-red-100 text-red-800': recoveryCodesRemaining <= 2
|
||||
}" class="px-2 py-1 text-xs font-medium rounded-full">
|
||||
<span x-text="recoveryCodesRemaining"></span> / 10
|
||||
</span>
|
||||
</template>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<button @click="showPasswordModal = true"
|
||||
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 gap-3">
|
||||
<svg class="w-5 h-5 text-gray-500" 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" />
|
||||
</svg>
|
||||
<span class="text-gray-700 font-medium">Сменить пароль</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 onclick="Auth.logout()"
|
||||
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 gap-3">
|
||||
<svg class="w-5 h-5 text-red-500" 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 font-medium">Выйти из аккаунта</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showPasswordModal" x-cloak class="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:block sm:p-0">
|
||||
<div x-show="showPasswordModal" x-transition.opacity class="fixed inset-0 transition-opacity">
|
||||
<div class="absolute inset-0 bg-gray-500 opacity-75" @click="showPasswordModal = false"></div>
|
||||
</div>
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||
<div x-show="showPasswordModal" x-transition
|
||||
class="inline-block align-bottom bg-white rounded-xl 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-6 pt-6 pb-4">
|
||||
<h3 class="text-lg leading-6 font-semibold text-gray-900 mb-4">Смена пароля</h3>
|
||||
<form id="change-password-form" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label>
|
||||
<input type="password" id="new-password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
|
||||
placeholder="Минимум 8 символов" />
|
||||
</div>
|
||||
<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.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
|
||||
placeholder="Повторите пароль" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
|
||||
<button type="button" id="submit-password-btn"
|
||||
class="px-5 py-2.5 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition">
|
||||
Сменить
|
||||
</button>
|
||||
<button type="button" @click="showPasswordModal = false"
|
||||
class="px-5 py-2.5 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showDisable2FAModal" x-cloak class="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:block sm:p-0">
|
||||
<div x-show="showDisable2FAModal" x-transition.opacity class="fixed inset-0 transition-opacity">
|
||||
<div class="absolute inset-0 bg-gray-500 opacity-75" @click="showDisable2FAModal = false"></div>
|
||||
</div>
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||
<div x-show="showDisable2FAModal" x-transition
|
||||
class="inline-block align-bottom bg-white rounded-xl 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-6 pt-6 pb-4">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 bg-red-100 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="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>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-semibold text-gray-900">Отключить 2FA</h3>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-4">
|
||||
Это снизит безопасность вашего аккаунта. Для подтверждения введите пароль.
|
||||
</p>
|
||||
<form id="disable-2fa-form">
|
||||
<div>
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Пароль</label>
|
||||
<input type="password" id="disable-2fa-password"
|
||||
class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gray-500 focus:border-transparent outline-none transition"
|
||||
placeholder="Введите ваш пароль" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-4 flex flex-row-reverse gap-3">
|
||||
<button type="button" id="submit-disable-2fa-btn"
|
||||
class="px-5 py-2.5 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition">
|
||||
Отключить
|
||||
</button>
|
||||
<button type="button" @click="showDisable2FAModal = false"
|
||||
class="px-5 py-2.5 bg-white text-gray-700 font-medium rounded-lg border border-gray-300 hover:bg-gray-50 transition">
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="showRecoveryCodesModal" x-cloak class="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:block sm:p-0">
|
||||
<div x-show="showRecoveryCodesModal" x-transition.opacity class="fixed inset-0 transition-opacity">
|
||||
<div class="absolute inset-0 bg-gray-500 opacity-75"></div>
|
||||
</div>
|
||||
<span class="hidden sm:inline-block sm:align-middle sm:h-screen">​</span>
|
||||
<div x-show="showRecoveryCodesModal" x-transition
|
||||
class="inline-block align-bottom bg-white rounded-xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md w-full">
|
||||
<div class="bg-white px-6 pt-6 pb-4">
|
||||
<div id="recovery-codes-loading" class="text-center py-8">
|
||||
<div class="animate-spin w-8 h-8 border-2 border-gray-300 border-t-gray-600 rounded-full mx-auto mb-4"></div>
|
||||
<p class="text-gray-500">Загрузка...</p>
|
||||
</div>
|
||||
|
||||
<div id="recovery-codes-status" class="hidden">
|
||||
<div class="flex items-center justify-center w-12 h-12 mx-auto rounded-full mb-4" id="status-icon-container">
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
|
||||
Резервные коды
|
||||
</h3>
|
||||
|
||||
<div id="codes-status-summary" class="text-center mb-4"></div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-3 mb-4">
|
||||
<p class="text-xs text-gray-500 mb-2 text-center">Статус кодов:</p>
|
||||
<div id="codes-status-list" class="space-y-1 max-h-48 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<div id="codes-warning" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-yellow-800 flex items-start gap-2">
|
||||
<svg class="w-5 h-5 flex-shrink-0 mt-0.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" />
|
||||
</svg>
|
||||
<span id="warning-text"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p id="codes-generated-at" class="text-xs text-gray-400 text-center mb-4"></p>
|
||||
|
||||
<button type="button" id="regenerate-codes-btn"
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium mb-3">
|
||||
Сгенерировать новые коды
|
||||
</button>
|
||||
<button type="button" id="close-status-modal-btn"
|
||||
class="w-full text-gray-500 hover:text-gray-700 text-sm py-2">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="recovery-codes-display" class="hidden">
|
||||
<div class="flex items-center justify-center w-12 h-12 mx-auto bg-green-100 rounded-full mb-4">
|
||||
<svg class="w-6 h-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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-center text-gray-800 mb-2">
|
||||
Новые резервные коды
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 text-center mb-4">
|
||||
<strong class="text-red-600">Сохраните эти коды!</strong>
|
||||
Они понадобятся для восстановления доступа.
|
||||
</p>
|
||||
|
||||
<div id="recovery-codes-list"
|
||||
class="bg-gray-50 rounded-lg p-4 font-mono text-sm text-center space-y-2 mb-4 max-h-64 overflow-y-auto">
|
||||
</div>
|
||||
|
||||
<p id="recovery-codes-generated-at" class="text-xs text-gray-400 text-center mb-4"></p>
|
||||
|
||||
<div class="flex gap-2 mb-4">
|
||||
<button type="button" id="copy-codes-btn"
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
|
||||
<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 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>Копировать</span>
|
||||
</button>
|
||||
<button type="button" id="download-codes-btn"
|
||||
class="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm font-medium transition">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
<span>Скачать</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-gray-600 mb-4 cursor-pointer">
|
||||
<input type="checkbox" id="codes-saved-checkbox" class="rounded border-gray-300 text-gray-600 focus:ring-gray-500" />
|
||||
<span>Я сохранил(а) коды в надёжном месте</span>
|
||||
</label>
|
||||
|
||||
<button type="button" id="close-recovery-modal-btn" disabled
|
||||
class="w-full bg-gray-500 text-white py-3 px-4 rounded-lg hover:bg-gray-600 transition duration-200 font-medium disabled:bg-gray-300 disabled:cursor-not-allowed">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/page/profile.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}{% block content %}
|
||||
<div class="flex flex-1 items-center justify-center p-4 min-h-[70vh]">
|
||||
<div class="w-full max-w-2xl">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<div class="p-8 text-center">
|
||||
<div class="mb-6 relative">
|
||||
<svg id="canvas" viewBox="-250 -50 500 100" style="width: 70vmin; height: 25vmin; max-width: 600px; max-height: 600px"></svg>
|
||||
</div>
|
||||
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-3">
|
||||
Страница не найдена
|
||||
</h1>
|
||||
|
||||
<p class="text-gray-500 mb-2">
|
||||
К сожалению, запрашиваемая страница не существует.
|
||||
</p>
|
||||
<p class="text-gray-400 text-sm mb-8">
|
||||
Возможно, она была удалена или вы ввели неверный адрес.
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-100 rounded-lg px-4 py-3 mb-8 inline-block">
|
||||
<code id="pathh" class="text-gray-600 text-sm">
|
||||
<span class="text-gray-400">Путь:</span>
|
||||
{{ request.url.path }}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onclick="history.back()"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition duration-200 font-medium shadow-sm hover:shadow-md transform hover:-translate-y-0.5"
|
||||
>
|
||||
<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="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||
</svg>
|
||||
Назад
|
||||
</button>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-flex items-center justify-center px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition duration-200 font-medium shadow-md hover:shadow-lg transform hover:-translate-y-0.5"
|
||||
>
|
||||
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
На главную
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 px-8 py-6 border-t border-gray-200">
|
||||
<p class="text-gray-500 text-sm text-center mb-4">Возможно, вы искали:</p>
|
||||
<div class="flex flex-wrap justify-center gap-3">
|
||||
<a href="/books" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
|
||||
<svg class="w-4 h-4 mr-2 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>
|
||||
<a href="/authors" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||
</svg>
|
||||
Авторы
|
||||
</a>
|
||||
<a href="/api" class="inline-flex items-center px-4 py-2 bg-white border border-gray-200 rounded-lg text-gray-600 hover:border-gray-400 hover:text-gray-800 transition text-sm">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
|
||||
</svg>
|
||||
API
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/page/unknown.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,429 @@
|
||||
{% extends "base.html" %}{% 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.5 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 flex flex-col sm:flex-row sm:items-end justify-between gap-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 class="user-payroll hidden shrink-0 flex items-center gap-1.5 bg-emerald-50 text-emerald-700 px-3 py-1 rounded-lg border border-emerald-100 shadow-sm">
|
||||
<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 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-emerald-600">Оклад:</div>
|
||||
<div class="font-bold font-mono text-lg leading-none user-payroll-amount"></div>
|
||||
<div class="text-xs font-medium">₽</div>
|
||||
</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>
|
||||
<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>
|
||||
</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/page/users.js"></script>
|
||||
{% endblock %}
|
||||
@@ -20,6 +20,7 @@ if config.config_file_name is not None:
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
from library_service.models.enums import *
|
||||
from library_service.models.db import *
|
||||
|
||||
target_metadata = SQLModel.metadata
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
import sqlmodel, pgvector
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Loans
|
||||
|
||||
Revision ID: 02ed6e775351
|
||||
Revises: b838606ad8d1
|
||||
Create Date: 2025-12-20 10:36:30.853896
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "02ed6e775351"
|
||||
down_revision: Union[str, None] = "b838606ad8d1"
|
||||
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! ###
|
||||
book_status_enum = sa.Enum(
|
||||
"active",
|
||||
"borrowed",
|
||||
"reserved",
|
||||
"restoration",
|
||||
"written_off",
|
||||
name="bookstatus",
|
||||
)
|
||||
book_status_enum.create(op.get_bind())
|
||||
op.create_table(
|
||||
"loans",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("book_id", sa.Integer(), nullable=False),
|
||||
sa.Column("user_id", sa.Integer(), nullable=False),
|
||||
sa.Column("borrowed_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("due_date", sa.DateTime(), nullable=False),
|
||||
sa.Column("returned_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
["book_id"],
|
||||
["book.id"],
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"],
|
||||
["users.id"],
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index(op.f("ix_loans_id"), "loans", ["id"], unique=False)
|
||||
op.add_column(
|
||||
"book",
|
||||
sa.Column("status", book_status_enum, nullable=False, server_default="active"),
|
||||
)
|
||||
op.drop_index(op.f("ix_roles_name"), table_name="roles")
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=True)
|
||||
op.drop_column("book", "status")
|
||||
op.drop_index(op.f("ix_loans_id"), table_name="loans")
|
||||
op.drop_table("loans")
|
||||
book_status_enum = sa.Enum(
|
||||
"active",
|
||||
"borrowed",
|
||||
"reserved",
|
||||
"restoration",
|
||||
"written_off",
|
||||
name="bookstatus",
|
||||
)
|
||||
book_status_enum.drop(op.get_bind())
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Book vector search
|
||||
|
||||
Revision ID: 6c616cc9d1f0
|
||||
Revises: c5dfc16bdc66
|
||||
Create Date: 2026-01-27 22:37:48.077761
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel, pgvector
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6c616cc9d1f0"
|
||||
down_revision: Union[str, None] = "c5dfc16bdc66"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column(
|
||||
"book",
|
||||
sa.Column(
|
||||
"embedding", pgvector.sqlalchemy.vector.VECTOR(dim=1024), nullable=True
|
||||
),
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column("book", "embedding")
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,4 +1,4 @@
|
||||
"""genres
|
||||
"""Genres
|
||||
|
||||
Revision ID: 9d7a43ac5dfc
|
||||
Revises: d266fdc61e99
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user