mirror of
https://github.com/wowlikon/LiB.git
synced 2026-02-04 12:31:09 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49d1681bcb | |||
| 1141cf5e66 | |||
| 4b06c4fb1f | |||
| 9d25d2e5de | |||
| 5096b45243 | |||
| 2bb7d420ec | |||
| 4839de99af | |||
| 368bb84fe5 |
@@ -2,7 +2,7 @@
|
||||
# DEFAULT_ADMIN_EMAIL = "admin@example.com"
|
||||
# DEFAULT_ADMIN_PASSWORD = "password-is-generated-randomly-on-first-launch"
|
||||
|
||||
POSTGRES_HOST = "localhost"
|
||||
POSTGRES_HOST = "db"
|
||||
POSTGRES_PORT = "5432"
|
||||
POSTGRES_USER = "postgres"
|
||||
POSTGRES_PASSWORD = "postgres"
|
||||
|
||||
Vendored
+2
@@ -1,3 +1,5 @@
|
||||
.env
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
+24
-17
@@ -1,22 +1,29 @@
|
||||
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
|
||||
|
||||
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 poetry
|
||||
RUN poetry config virtualenvs.create false
|
||||
|
||||
COPY ./pyproject.toml ./poetry.lock* /code/
|
||||
|
||||
RUN poetry install --with dev --no-root --no-interaction
|
||||
|
||||
COPY ./library_service /code/library_service
|
||||
COPY ./alembic.ini /code/
|
||||
COPY ./data.py /code/
|
||||
|
||||
RUN useradd app && chown -R app:app /code
|
||||
USER app
|
||||
|
||||
ENV PYTHONPATH=/code
|
||||
|
||||
CMD ["uvicorn", "library_service.main:app", "--host", "0.0.0.0", "--port", "8000", "--forwarded-allow-ips=\"*\""]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||

|
||||
# LibraryAPI
|
||||
# LiB
|
||||
|
||||
Это проект приложения на FastAPI - современном веб фреймворке для создания API на Python. Я использую Pydantic для валидации данных, SQLModel для взаимодействия с базой данных, Alembic для управления миграциями, PostgreSQL как систему базы данных и Docker Compose для легкого развертывания.
|
||||
|
||||
@@ -37,12 +37,12 @@
|
||||
|
||||
5. Запустите приложение:
|
||||
```bash
|
||||
docker compose up api
|
||||
docker compose up api -d
|
||||
```
|
||||
|
||||
Для создания новых миграций:
|
||||
```bash
|
||||
docker compose run --rm -T api alembic revision --autogenerate -m "Migration name"
|
||||
alembic revision --autogenerate -m "Migration name"
|
||||
```
|
||||
|
||||
Для запуска тестов:
|
||||
@@ -50,34 +50,39 @@
|
||||
docker compose up test
|
||||
```
|
||||
|
||||
Для добавление данных для примера используйте:
|
||||
```bash
|
||||
python data.py
|
||||
```
|
||||
|
||||
### **Эндпоинты API**
|
||||
|
||||
**Авторы**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/authors` | Создать нового автора |
|
||||
| GET | `/authors` | Получить список всех авторов |
|
||||
| GET | `/authors/{id}` | Получить автора по ID с книгами |
|
||||
| PUT | `/authors/{id}` | Обновить автора по ID |
|
||||
| DELETE | `/authors/{id}` | Удалить автора по ID |
|
||||
|--------|---------------------------|---------------------------------|
|
||||
| POST | `/api/authors` | Создать нового автора |
|
||||
| GET | `/api/authors` | Получить список всех авторов |
|
||||
| GET | `/api/authors/{id}` | Получить автора по ID с книгами |
|
||||
| PUT | `/api/authors/{id}` | Обновить автора по ID |
|
||||
| DELETE | `/api/authors/{id}` | Удалить автора по ID |
|
||||
|
||||
**Книги**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/books` | Создать новую книгу |
|
||||
| GET | `/books` | Получить список всех книг |
|
||||
| GET | `/book/{id}` | Получить книгу по ID с авторами |
|
||||
| PUT | `/books/{id}` | Обновить книгу по ID |
|
||||
| DELETE | `/books/{id}` | Удалить книгу по ID |
|
||||
|--------|---------------------------|---------------------------------|
|
||||
| POST | `/api/books` | Создать новую книгу |
|
||||
| GET | `/api/books` | Получить список всех книг |
|
||||
| GET | `/api/book/{id}` | Получить книгу по ID с авторами |
|
||||
| PUT | `/api/books/{id}` | Обновить книгу по ID |
|
||||
| DELETE | `/api/books/{id}` | Удалить книгу по ID |
|
||||
|
||||
**Жанры**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-----------------------|---------------------------------|
|
||||
| POST | `/genres` | Создать новый жанр |
|
||||
| GET | `/genres` | Получить список всех жанров |
|
||||
| GET | `/genres/{id}` | Получить жанр по ID |
|
||||
| PUT | `/genres/{id}` | Обновить жанр по ID |
|
||||
| DELETE | `/genres/{id}` | Удалить жанр по ID |
|
||||
|--------|----------------------------|--------------------------------|
|
||||
| POST | `/api/genres` | Создать новый жанр |
|
||||
| GET | `/api/genres` | Получить список всех жанров |
|
||||
| GET | `/api/genres/{id}` | Получить жанр по ID |
|
||||
| PUT | `/api/genres/{id}` | Обновить жанр по ID |
|
||||
| DELETE | `/api/genres/{id}` | Удалить жанр по ID |
|
||||
|
||||
**Связи**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
@@ -93,43 +98,73 @@
|
||||
|
||||
**Другие**
|
||||
| Метод | Эндпоинты | Описание |
|
||||
|--------|-------------|-------------------------------|
|
||||
| GET | `/api/info` | Получить информацию о сервисе |
|
||||
|--------|--------------|----------------------------------------------|
|
||||
| GET | `/api/info` | Получить общую информацию о сервисе |
|
||||
| GET | `/api/stats` | Получить статистическую информацию о сервисе |
|
||||
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
AUTHOR {
|
||||
int id PK "ID автора"
|
||||
string name "Имя автора"
|
||||
USER {
|
||||
int id PK
|
||||
string username UK
|
||||
string email UK
|
||||
string full_name
|
||||
string password
|
||||
boolean is_active
|
||||
boolean is_verified
|
||||
}
|
||||
|
||||
USER_ROLE {
|
||||
int user_id FK
|
||||
string role
|
||||
}
|
||||
|
||||
LOAN {
|
||||
int id PK
|
||||
int book_id FK
|
||||
int user_id FK
|
||||
datetime borrowed_at
|
||||
datetime due_date
|
||||
datetime returned_at
|
||||
}
|
||||
|
||||
BOOK {
|
||||
int id PK "ID книги"
|
||||
string title "Название книги"
|
||||
string description "Описание книги"
|
||||
int id PK
|
||||
string title
|
||||
string description
|
||||
}
|
||||
|
||||
AUTHOR {
|
||||
int id PK
|
||||
string name
|
||||
string bio
|
||||
}
|
||||
|
||||
GENRE {
|
||||
int id PK "ID жанра"
|
||||
string name "Название жанра"
|
||||
int id PK
|
||||
string name
|
||||
}
|
||||
|
||||
AUTHOR_BOOK {
|
||||
int author_id FK "ID автора"
|
||||
int book_id FK "ID книги"
|
||||
int author_id FK
|
||||
int book_id FK
|
||||
}
|
||||
|
||||
GENRE_BOOK {
|
||||
int genre_id FK "ID жанра"
|
||||
int book_id FK "ID книги"
|
||||
int genre_id FK
|
||||
int book_id FK
|
||||
}
|
||||
|
||||
AUTHOR ||--o{ AUTHOR_BOOK : "писал"
|
||||
BOOK ||--o{ AUTHOR_BOOK : "написан"
|
||||
USER ||--o{ USER_ROLE : "имеет роли"
|
||||
USER ||--o{ LOAN : "берёт книги"
|
||||
LOAN }o--|| BOOK : "выдача"
|
||||
|
||||
AUTHOR ||--o{ AUTHOR_BOOK : "пишет"
|
||||
AUTHOR_BOOK }o--|| BOOK : "авторство"
|
||||
|
||||
BOOK ||--o{ GENRE_BOOK : "принадлежит"
|
||||
GENRE ||--o{ GENRE_BOOK : "содержит"
|
||||
GENRE_BOOK }o--|| BOOK : "жанр"
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Optional
|
||||
|
||||
# Конфигурация
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "7WaVlcj8EWzEbbdab9kqRw"
|
||||
PASSWORD = "your-password-here"
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
|
||||
|
||||
+46
-11
@@ -1,28 +1,63 @@
|
||||
services:
|
||||
db:
|
||||
container_name: db
|
||||
image: postgres:17
|
||||
ports:
|
||||
- 5432:5432
|
||||
# volumes:
|
||||
# - ./data/db:/var/lib/postgresql/data
|
||||
container_name: db
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
volumes:
|
||||
- ./data/db:/var/lib/postgresql/data
|
||||
networks:
|
||||
- proxy
|
||||
env_file:
|
||||
- ./.env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
container_name: api
|
||||
build: .
|
||||
command: bash -c "alembic upgrade head && uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --reload"
|
||||
container_name: api
|
||||
restart: unless-stopped
|
||||
command: bash -c "uvicorn library_service.main:app --host 0.0.0.0 --port 8000 --forwarded-allow-ips=\"*\""
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- proxy
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- .:/code
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# depends_on:
|
||||
# - db
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
tests:
|
||||
container_name: tests
|
||||
build: .
|
||||
command: bash -c "pytest tests"
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
networks:
|
||||
- proxy
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- .:/code
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
networks:
|
||||
proxy: # Рекомендуется использовать через реверс-прокси
|
||||
name: proxy
|
||||
external: true
|
||||
|
||||
+21
-18
@@ -64,25 +64,27 @@ def create_refresh_token(data: dict) -> str:
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_token(token: str) -> TokenData:
|
||||
def decode_token(token: str, expected_type: str = "access") -> TokenData:
|
||||
"""Декодирование и проверка JWT токенов."""
|
||||
token_error = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
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"},
|
||||
)
|
||||
username: str | None = payload.get("sub")
|
||||
user_id: int | None = payload.get("user_id")
|
||||
token_type: str | None = payload.get("type")
|
||||
if 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)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
token_error.detail="Could not validate credentials"
|
||||
raise token_error
|
||||
|
||||
|
||||
def authenticate_user(session: Session, username: str, password: str) -> User | None:
|
||||
@@ -140,15 +142,16 @@ def require_role(role_name: str):
|
||||
# Создание dependencies
|
||||
RequireAuth = Annotated[User, Depends(get_current_active_user)]
|
||||
RequireAdmin = Annotated[User, Depends(require_role("admin"))]
|
||||
RequireModerator = Annotated[User, Depends(require_role("moderator"))]
|
||||
RequireMember = Annotated[User, Depends(require_role("member"))]
|
||||
RequireLibrarian = Annotated[User, Depends(require_role("librarian"))]
|
||||
|
||||
|
||||
def seed_roles(session: Session) -> dict[str, Role]:
|
||||
"""Создаёт роли по умолчанию, если их нет."""
|
||||
default_roles = [
|
||||
{"name": "admin", "description": "Администратор системы"},
|
||||
{"name": "librarian", "description": "Библиотекарь"},
|
||||
{"name": "member", "description": "Посетитель библиотеки"},
|
||||
{"name": "admin", "description": "Администратор системы", "payroll": 80000},
|
||||
{"name": "librarian", "description": "Библиотекарь", "payroll": 55000},
|
||||
{"name": "member", "description": "Посетитель библиотеки", "payroll": 0},
|
||||
]
|
||||
|
||||
roles = {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Модуль DB-моделей книг"""
|
||||
from typing import TYPE_CHECKING, List
|
||||
|
||||
from sqlalchemy import Column, String
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from library_service.models.dto.book import BookBase
|
||||
@@ -15,7 +16,10 @@ if TYPE_CHECKING:
|
||||
class Book(BookBase, table=True):
|
||||
"""Модель книги в базе данных"""
|
||||
id: int | None = Field(default=None, primary_key=True, index=True)
|
||||
status: BookStatus = Field(default=BookStatus.ACTIVE)
|
||||
status: BookStatus = Field(
|
||||
default=BookStatus.ACTIVE,
|
||||
sa_column=Column(String, nullable=False, default="active")
|
||||
)
|
||||
authors: List["Author"] = Relationship(
|
||||
back_populates="books", link_model=AuthorBookLink
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from .author import AuthorRead
|
||||
from .genre import GenreRead
|
||||
from .book import BookRead
|
||||
from .loan import LoanRead
|
||||
|
||||
from ..enums import BookStatus
|
||||
|
||||
class AuthorWithBooks(SQLModel):
|
||||
"""Модель автора с книгами"""
|
||||
@@ -35,6 +35,7 @@ class BookWithGenres(SQLModel):
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
status: BookStatus | None = None
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -43,6 +44,7 @@ class BookWithAuthorsAndGenres(SQLModel):
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
status: BookStatus | None = None
|
||||
authors: List[AuthorRead] = Field(default_factory=list)
|
||||
genres: List[GenreRead] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ class RoleBase(SQLModel):
|
||||
"""Базовая модель роли"""
|
||||
name: str
|
||||
description: str | None = None
|
||||
payroll: int = 0
|
||||
|
||||
|
||||
class RoleCreate(RoleBase):
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
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.models.db import Role, User
|
||||
from library_service.models.dto import Token, UserCreate, UserRead, UserUpdate, UserList, RoleRead, RoleList
|
||||
from library_service.settings import get_session
|
||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin,
|
||||
RequireAuth, authenticate_user, get_password_hash,
|
||||
from library_service.auth import (ACCESS_TOKEN_EXPIRE_MINUTES, RequireAdmin, RequireAuth,
|
||||
authenticate_user, get_password_hash, decode_token,
|
||||
create_access_token, create_refresh_token)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
@@ -49,7 +49,7 @@ def register(user_data: UserCreate, session: Session = Depends(get_session)):
|
||||
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)
|
||||
|
||||
@@ -93,13 +93,62 @@ def login(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=Token,
|
||||
summary="Обновление токена",
|
||||
description="Получение новой пары токенов (Access + Refresh) используя действующий Refresh токен",
|
||||
)
|
||||
def refresh_token(
|
||||
refresh_token: str = Body(..., embed=True),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт для обновления токенов."""
|
||||
try:
|
||||
token_data = decode_token(refresh_token, expected_type="refresh")
|
||||
except HTTPException:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
user = session.get(User, token_data.user_id)
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User is inactive",
|
||||
)
|
||||
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
new_access_token = create_access_token(
|
||||
data={"sub": user.username, "user_id": user.id},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
new_refresh_token = create_refresh_token(
|
||||
data={"sub": user.username, "user_id": user.id}
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token,
|
||||
token_type="bearer",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserRead,
|
||||
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]
|
||||
@@ -142,14 +191,17 @@ def update_user_me(
|
||||
)
|
||||
def read_users(
|
||||
admin: RequireAdmin,
|
||||
session: Session = Depends(get_session),
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт получения списка всех пользователей"""
|
||||
users = session.exec(select(User).offset(skip).limit(limit)).all()
|
||||
return UserList(
|
||||
users=[UserRead(**user.model_dump()) for user in users],
|
||||
users=[
|
||||
UserRead(**user.model_dump(), roles=[r.name for r in user.roles])
|
||||
for user in users
|
||||
],
|
||||
total=len(users),
|
||||
)
|
||||
|
||||
@@ -243,11 +295,14 @@ def remove_role_from_user(
|
||||
description="Возвращает список ролей",
|
||||
)
|
||||
def get_roles(
|
||||
auth: RequireAuth,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
"""Эндпоинт получения списа ролей"""
|
||||
user_roles = [role.name for role in auth.roles]
|
||||
exclude = {"payroll"} if "admin" in user_roles else set()
|
||||
roles = session.exec(select(Role)).all()
|
||||
return RoleList(
|
||||
roles=[RoleRead(**role.model_dump()) for role in roles],
|
||||
roles=[RoleRead(**role.model_dump(exclude=exclude)) for role in roles],
|
||||
total=len(roles),
|
||||
)
|
||||
|
||||
@@ -25,7 +25,7 @@ router = APIRouter(prefix="/books", tags=["books"])
|
||||
)
|
||||
def filter_books(
|
||||
session: Session = Depends(get_session),
|
||||
q: str | None = Query(None, min_length=3, max_length=50, description="Поиск"),
|
||||
q: str | None = Query(None, max_length=50, description="Поиск"),
|
||||
author_ids: List[int] | None = Query(None, description="Список ID авторов"),
|
||||
genre_ids: List[int] | None = Query(None, description="Список ID жанров"),
|
||||
page: int = Query(1, gt=0, description="Номер страницы"),
|
||||
@@ -67,7 +67,7 @@ def filter_books(
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=Book,
|
||||
response_model=BookRead,
|
||||
summary="Создать книгу",
|
||||
description="Добавляет книгу в систему",
|
||||
)
|
||||
@@ -149,6 +149,7 @@ def update_book(
|
||||
|
||||
db_book.title = book.title or db_book.title
|
||||
db_book.description = book.description or db_book.description
|
||||
db_book.status = book.status or db_book.status
|
||||
session.commit()
|
||||
session.refresh(db_book)
|
||||
return db_book
|
||||
@@ -170,7 +171,7 @@ def delete_book(
|
||||
if not book:
|
||||
raise HTTPException(status_code=404, 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, status=book.status
|
||||
)
|
||||
session.delete(book)
|
||||
session.commit()
|
||||
|
||||
@@ -36,12 +36,36 @@ async def root(request: Request):
|
||||
return templates.TemplateResponse(request, "index.html")
|
||||
|
||||
|
||||
@router.get("/genre/create", include_in_schema=False)
|
||||
async def create_genre(request: Request):
|
||||
"""Эндпоинт страницы создания жанра"""
|
||||
return templates.TemplateResponse(request, "create_genre.html")
|
||||
|
||||
|
||||
@router.get("/genre/{genre_id}/edit", include_in_schema=False)
|
||||
async def edit_genre(request: Request, genre_id: int):
|
||||
"""Эндпоинт страницы редактирования жанра"""
|
||||
return templates.TemplateResponse(request, "edit_genre.html")
|
||||
|
||||
|
||||
@router.get("/authors", include_in_schema=False)
|
||||
async def authors(request: Request):
|
||||
"""Эндпоинт страницы выбора автора"""
|
||||
return templates.TemplateResponse(request, "authors.html")
|
||||
|
||||
|
||||
@router.get("/author/create", include_in_schema=False)
|
||||
async def create_author(request: Request):
|
||||
"""Эндпоинт страницы создания автора"""
|
||||
return templates.TemplateResponse(request, "create_author.html")
|
||||
|
||||
|
||||
@router.get("/author/{author_id}/edit", include_in_schema=False)
|
||||
async def edit_author(request: Request, author_id: int):
|
||||
"""Эндпоинт страницы редактирования автора"""
|
||||
return templates.TemplateResponse(request, "edit_author.html")
|
||||
|
||||
|
||||
@router.get("/author/{author_id}", include_in_schema=False)
|
||||
async def author(request: Request, author_id: int):
|
||||
"""Эндпоинт страницы автора"""
|
||||
@@ -54,6 +78,18 @@ async def books(request: Request):
|
||||
return templates.TemplateResponse(request, "books.html")
|
||||
|
||||
|
||||
@router.get("/book/create", include_in_schema=False)
|
||||
async def create_book(request: Request):
|
||||
"""Эндпоинт страницы создания книги"""
|
||||
return templates.TemplateResponse(request, "create_book.html")
|
||||
|
||||
|
||||
@router.get("/book/{book_id}/edit", include_in_schema=False)
|
||||
async def edit_book(request: Request, book_id: int):
|
||||
"""Эндпоинт страницы редактирования книги"""
|
||||
return templates.TemplateResponse(request, "edit_book.html")
|
||||
|
||||
|
||||
@router.get("/book/{book_id}", include_in_schema=False)
|
||||
async def book(request: Request, book_id: int):
|
||||
"""Эндпоинт страницы книги"""
|
||||
@@ -72,6 +108,12 @@ async def profile(request: Request):
|
||||
return templates.TemplateResponse(request, "profile.html")
|
||||
|
||||
|
||||
@router.get("/users", include_in_schema=False)
|
||||
async def users(request: Request):
|
||||
"""Эндпоинт страницы управления пользователями"""
|
||||
return templates.TemplateResponse(request, "users.html")
|
||||
|
||||
|
||||
@router.get("/api", include_in_schema=False)
|
||||
async def api(request: Request, app=Depends(lambda: get_app())):
|
||||
"""Страница с сылками на документацию API"""
|
||||
|
||||
+45
-194
@@ -1,57 +1,32 @@
|
||||
$(function () {
|
||||
const $loginTab = $("#login-tab");
|
||||
const $registerTab = $("#register-tab");
|
||||
const $loginForm = $("#login-form");
|
||||
const $registerForm = $("#register-form");
|
||||
$(() => {
|
||||
$("#login-tab").on("click", function () {
|
||||
$("#login-tab")
|
||||
.removeClass("text-gray-400 hover:text-gray-600")
|
||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||
$("#register-tab")
|
||||
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
|
||||
.addClass("text-gray-400 hover:text-gray-600");
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
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");
|
||||
$("#login-form").removeClass("hidden");
|
||||
$("#register-form").addClass("hidden");
|
||||
});
|
||||
|
||||
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");
|
||||
}
|
||||
$("#register-tab").on("click", function () {
|
||||
$("#register-tab")
|
||||
.removeClass("text-gray-400 hover:text-gray-600")
|
||||
.addClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500");
|
||||
$("#login-tab")
|
||||
.removeClass("text-gray-700 bg-gray-50 border-b-2 border-gray-500")
|
||||
.addClass("text-gray-400 hover:text-gray-600");
|
||||
|
||||
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-form").removeClass("hidden");
|
||||
$("#login-form").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++;
|
||||
@@ -93,15 +68,12 @@ $(function () {
|
||||
|
||||
$("#register-password-confirm").on("input", checkPasswordMatch);
|
||||
|
||||
$loginForm.on("submit", async function (event) {
|
||||
$("#login-form").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 {
|
||||
@@ -109,42 +81,27 @@ $(function () {
|
||||
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 Api.postForm("/api/auth/token", formData);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
if (data.refresh_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");
|
||||
Utils.showToast(error.message || "Ошибка входа", "error");
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false).text("Войти");
|
||||
}
|
||||
});
|
||||
|
||||
$registerForm.on("submit", async function (event) {
|
||||
$("#register-form").on("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
|
||||
const $errorDiv = $("#register-error");
|
||||
const $successDiv = $("#register-success");
|
||||
const $submitBtn = $("#register-submit");
|
||||
const pass = $("#register-password").val();
|
||||
const confirm = $("#register-password-confirm").val();
|
||||
|
||||
if (!checkPasswordMatch()) {
|
||||
$errorDiv.text("Пароли не совпадают").removeClass("hidden");
|
||||
if (pass !== confirm) {
|
||||
Utils.showToast("Пароли не совпадают", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -152,136 +109,30 @@ $(function () {
|
||||
username: $("#register-username").val(),
|
||||
email: $("#register-email").val(),
|
||||
full_name: $("#register-fullname").val() || null,
|
||||
password: $("#register-password").val(),
|
||||
password: pass,
|
||||
};
|
||||
|
||||
$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");
|
||||
}
|
||||
await Api.post("/api/auth/register", userData);
|
||||
Utils.showToast("Регистрация успешна! Войдите в систему.", "success");
|
||||
setTimeout(() => window.location.reload(), 1500);
|
||||
} catch (error) {
|
||||
console.error("Register error:", error);
|
||||
$errorDiv.text("Ошибка соединения с сервером").removeClass("hidden");
|
||||
let msg = error.message;
|
||||
if (Array.isArray(error.detail)) {
|
||||
msg = error.detail.map((e) => e.msg).join(". ");
|
||||
}
|
||||
Utils.showToast(msg || "Ошибка регистрации", "error");
|
||||
} finally {
|
||||
$submitBtn.prop("disabled", false).text("Зарегистрироваться");
|
||||
}
|
||||
});
|
||||
|
||||
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();
|
||||
$("body").on("click", ".toggle-password", function () {
|
||||
const $input = $(this).siblings("input");
|
||||
const type = $input.attr("type") === "password" ? "text" : "password";
|
||||
$input.attr("type", type);
|
||||
$(this).find("svg").toggleClass("hidden");
|
||||
});
|
||||
|
||||
$(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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -3,303 +3,64 @@ $(document).ready(() => {
|
||||
const authorId = pathParts[pathParts.length - 1];
|
||||
|
||||
if (!authorId || isNaN(authorId)) {
|
||||
showErrorState("Некорректный ID автора");
|
||||
Utils.showToast("Некорректный ID автора", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
loadAuthor(authorId);
|
||||
|
||||
function loadAuthor(id) {
|
||||
showLoadingState();
|
||||
|
||||
fetch(`/api/authors/${id}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error("Автор не найден");
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
Api.get(`/api/authors/${authorId}`)
|
||||
.then((author) => {
|
||||
document.title = `LiB - ${author.name}`;
|
||||
renderAuthor(author);
|
||||
renderBooks(author.books);
|
||||
document.title = `LiB - ${author.name}`;
|
||||
if (window.canManage) {
|
||||
$("#edit-author-btn")
|
||||
.attr("href", `/author/${author.id}/edit`)
|
||||
.removeClass("hidden");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading author:", error);
|
||||
showErrorState(error.message);
|
||||
console.error(error);
|
||||
Utils.showToast("Автор не найден", "error");
|
||||
$("#author-loader").html('<p class="text-red-500">Ошибка загрузки</p>');
|
||||
});
|
||||
}
|
||||
|
||||
function renderAuthor(author) {
|
||||
const $card = $("#author-card");
|
||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||
const booksCount = author.books ? author.books.length : 0;
|
||||
const booksWord = getWordForm(booksCount, ["книга", "книги", "книг"]);
|
||||
$("#author-name").text(author.name);
|
||||
$("#author-id").text(`ID: ${author.id}`);
|
||||
$("#author-avatar").text(author.name.charAt(0).toUpperCase());
|
||||
|
||||
$card.html(`
|
||||
<div class="flex items-start">
|
||||
<!-- Аватар -->
|
||||
<div class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0">
|
||||
${firstLetter}
|
||||
</div>
|
||||
const count = author.books ? author.books.length : 0;
|
||||
$("#author-books-count").text(`${count} книг в библиотеке`);
|
||||
|
||||
<!-- Информация -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(author.name)}</h1>
|
||||
<span class="text-sm text-gray-500">ID: ${author.id}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center text-gray-600 mb-4">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<span>${booksCount} ${booksWord} в библиотеке</span>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка назад -->
|
||||
<a href="/authors" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Вернуться к списку авторов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$("#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(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
<p class="text-gray-500">У этого автора пока нет книг в библиотеке</p>
|
||||
</div>
|
||||
`);
|
||||
$container.html('<p class="text-gray-500 italic">Книг пока нет</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const $grid = $('<div class="space-y-4"></div>');
|
||||
|
||||
books.forEach((book) => {
|
||||
const $bookCard = $(`
|
||||
<div class="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow duration-200 cursor-pointer book-card" data-id="${book.id}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors mb-2">
|
||||
${escapeHtml(book.title)}
|
||||
</h3>
|
||||
<p class="text-gray-600 text-sm line-clamp-3">
|
||||
${escapeHtml(book.description || "Описание отсутствует")}
|
||||
</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400 ml-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
const card = clone.querySelector(".book-card");
|
||||
|
||||
$grid.append($bookCard);
|
||||
});
|
||||
card.dataset.id = book.id;
|
||||
clone.querySelector(".book-title").textContent = book.title;
|
||||
clone.querySelector(".book-desc").textContent =
|
||||
book.description || "Описание отсутствует";
|
||||
|
||||
$container.append($grid);
|
||||
|
||||
$container.off("click", ".book-card").on("click", ".book-card", function () {
|
||||
const bookId = $(this).data("id");
|
||||
window.location.href = `/book/${bookId}`;
|
||||
$container.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $authorCard = $("#author-card");
|
||||
const $booksContainer = $("#books-container");
|
||||
|
||||
$authorCard.html(`
|
||||
<div class="flex items-start animate-pulse">
|
||||
<div class="w-24 h-24 bg-gray-200 rounded-full mr-6"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-8 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4 mb-4"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/5"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$booksContainer.html(`
|
||||
<div class="space-y-4">
|
||||
${Array(3)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="border border-gray-200 rounded-lg p-4 animate-pulse">
|
||||
<div class="h-5 bg-gray-200 rounded w-1/2 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full mb-1"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState(message) {
|
||||
const $authorCard = $("#author-card");
|
||||
const $booksSection = $("#books-section");
|
||||
|
||||
$booksSection.hide();
|
||||
|
||||
$authorCard.html(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||
<p class="text-gray-500 mb-6">Не удалось загрузить информацию об авторе</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Попробовать снова
|
||||
</button>
|
||||
<a href="/authors" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
К списку авторов
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", function () {
|
||||
$booksSection.show();
|
||||
loadAuthor(authorId);
|
||||
$("#books-container").on("click", ".book-card", function () {
|
||||
window.location.href = `/book/${$(this).data("id")}`;
|
||||
});
|
||||
}
|
||||
|
||||
function getWordForm(number, forms) {
|
||||
const cases = [2, 0, 1, 1, 1, 2];
|
||||
const index =
|
||||
number % 100 > 4 && number % 100 < 20
|
||||
? 2
|
||||
: cases[Math.min(number % 10, 5)];
|
||||
return forms[index];
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -2,7 +2,7 @@ $(document).ready(() => {
|
||||
let allAuthors = [];
|
||||
let filteredAuthors = [];
|
||||
let currentPage = 1;
|
||||
let pageSize = 12;
|
||||
let pageSize = 24;
|
||||
let currentSort = "name_asc";
|
||||
|
||||
loadAuthors();
|
||||
@@ -10,20 +10,15 @@ $(document).ready(() => {
|
||||
function loadAuthors() {
|
||||
showLoadingState();
|
||||
|
||||
fetch("/api/authors")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
Api.get("/api/authors")
|
||||
.then((data) => {
|
||||
allAuthors = data.authors;
|
||||
applyFiltersAndSort();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading authors:", error);
|
||||
showErrorState();
|
||||
console.error(error);
|
||||
Utils.showToast("Не удалось загрузить авторов", "error");
|
||||
$("#authors-container").empty();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,184 +26,79 @@ $(document).ready(() => {
|
||||
const searchQuery = $("#author-search-input").val().trim().toLowerCase();
|
||||
|
||||
filteredAuthors = allAuthors.filter((author) =>
|
||||
author.name.toLowerCase().includes(searchQuery)
|
||||
author.name.toLowerCase().includes(searchQuery),
|
||||
);
|
||||
|
||||
filteredAuthors.sort((a, b) => {
|
||||
const nameA = a.name.toLowerCase();
|
||||
const nameB = b.name.toLowerCase();
|
||||
|
||||
if (currentSort === "name_asc") {
|
||||
return nameA.localeCompare(nameB, "ru");
|
||||
} else {
|
||||
return nameB.localeCompare(nameA, "ru");
|
||||
}
|
||||
return currentSort === "name_asc"
|
||||
? nameA.localeCompare(nameB, "ru")
|
||||
: nameB.localeCompare(nameA, "ru");
|
||||
});
|
||||
|
||||
updateResultsCounter();
|
||||
const total = filteredAuthors.length;
|
||||
$("#results-counter").text(
|
||||
total === 0 ? "Авторы не найдены" : `Найдено: ${total}`,
|
||||
);
|
||||
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
}
|
||||
|
||||
function updateResultsCounter() {
|
||||
const $counter = $("#results-counter");
|
||||
const total = filteredAuthors.length;
|
||||
|
||||
if (total === 0) {
|
||||
$counter.text("Авторы не найдены");
|
||||
} else {
|
||||
const wordForm = getWordForm(total, ["автор", "автора", "авторов"]);
|
||||
$counter.text(`Найдено: ${total} ${wordForm}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getWordForm(number, forms) {
|
||||
const cases = [2, 0, 1, 1, 1, 2];
|
||||
const index =
|
||||
number % 100 > 4 && number % 100 < 20
|
||||
? 2
|
||||
: cases[Math.min(number % 10, 5)];
|
||||
return forms[index];
|
||||
}
|
||||
|
||||
function renderAuthors() {
|
||||
const $container = $("#authors-container");
|
||||
const tpl = document.getElementById("author-card-template");
|
||||
const emptyTpl = document.getElementById("empty-state-template");
|
||||
|
||||
$container.empty();
|
||||
|
||||
if (filteredAuthors.length === 0) {
|
||||
$container.html(`
|
||||
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Авторы не найдены</h3>
|
||||
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
|
||||
</div>
|
||||
`);
|
||||
$container.append(emptyTpl.content.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const pageAuthors = filteredAuthors.slice(startIndex, endIndex);
|
||||
|
||||
const $grid = $('<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"></div>');
|
||||
const pageAuthors = filteredAuthors.slice(
|
||||
startIndex,
|
||||
startIndex + pageSize,
|
||||
);
|
||||
|
||||
pageAuthors.forEach((author) => {
|
||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
const card = clone.querySelector(".author-card");
|
||||
|
||||
const $authorCard = $(`
|
||||
<div class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer author-card" data-id="${author.id}">
|
||||
<div class="flex items-center">
|
||||
<div class="w-12 h-12 bg-gray-500 text-white rounded-full flex items-center justify-center text-xl font-bold mr-4">
|
||||
${firstLetter}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 hover:text-blue-600 transition-colors">
|
||||
${escapeHtml(author.name)}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">ID: ${author.id}</p>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
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();
|
||||
|
||||
$grid.append($authorCard);
|
||||
});
|
||||
|
||||
$container.append($grid);
|
||||
|
||||
$container.off("click", ".author-card").on("click", ".author-card", function () {
|
||||
const authorId = $(this).data("id");
|
||||
window.location.href = `/author/${authorId}`;
|
||||
$container.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const $paginationContainer = $("#pagination-container");
|
||||
$paginationContainer.empty();
|
||||
|
||||
$("#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 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>
|
||||
<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 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>
|
||||
<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 = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page === "...") {
|
||||
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
|
||||
} else {
|
||||
const isActive = page === currentPage;
|
||||
$pageNumbers.append(`
|
||||
<button class="page-btn px-3 py-2 rounded-lg ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
|
||||
${page}
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
$paginationContainer.append($pagination);
|
||||
|
||||
$paginationContainer.find("#prev-page").on("click", function () {
|
||||
if (currentPage > 1) {
|
||||
currentPage--;
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$paginationContainer.find("#next-page").on("click", function () {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$paginationContainer.find(".page-btn").on("click", function () {
|
||||
const page = parseInt($(this).data("page"));
|
||||
if (page !== currentPage) {
|
||||
currentPage = page;
|
||||
renderAuthors();
|
||||
renderPagination();
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function generatePageNumbers(current, total) {
|
||||
const pages = [];
|
||||
const delta = 2;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (
|
||||
i === 1 ||
|
||||
i === total ||
|
||||
(i >= current - delta && i <= current + delta)
|
||||
i === totalPages ||
|
||||
(i >= currentPage - 2 && i <= currentPage + 2)
|
||||
) {
|
||||
pages.push(i);
|
||||
} else if (pages[pages.length - 1] !== "...") {
|
||||
@@ -216,202 +106,78 @@ $(document).ready(() => {
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
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>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
function scrollToTop() {
|
||||
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||
$("#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() {
|
||||
const $container = $("#authors-container");
|
||||
$container.html(`
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
$("#authors-container").html(`
|
||||
${Array(6)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||
<div class="flex items-center">
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse flex items-center">
|
||||
<div class="w-12 h-12 bg-gray-200 rounded-full mr-4"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-gray-200 rounded w-3/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function showErrorState() {
|
||||
const $container = $("#authors-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", loadAuthors);
|
||||
function scrollToTop() {
|
||||
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function initializeFilters() {
|
||||
const $authorSearch = $("#author-search-input");
|
||||
const $resetBtn = $("#reset-filters-btn");
|
||||
const $sortRadios = $('input[name="sort"]');
|
||||
|
||||
let searchTimeout;
|
||||
$authorSearch.on("input", function () {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
$("#author-search-input").on("input", function () {
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
$authorSearch.on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
clearTimeout(searchTimeout);
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
}
|
||||
});
|
||||
|
||||
$sortRadios.on("change", function () {
|
||||
$('input[name="sort"]').on("change", function () {
|
||||
currentSort = $(this).val();
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
});
|
||||
|
||||
$resetBtn.on("click", function () {
|
||||
$authorSearch.val("");
|
||||
$('input[name="sort"][value="name_asc"]').prop("checked", true);
|
||||
currentSort = "name_asc";
|
||||
currentPage = 1;
|
||||
applyFiltersAndSort();
|
||||
$("#authors-container").on("click", ".author-card", function () {
|
||||
window.location.href = `/author/${$(this).data("id")}`;
|
||||
});
|
||||
}
|
||||
|
||||
initializeFilters();
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<svg
|
||||
width="800px"
|
||||
height="800px"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M0.877014 7.49988C0.877014 3.84219 3.84216 0.877045 7.49985 0.877045C11.1575 0.877045 14.1227 3.84219 14.1227 7.49988C14.1227 11.1575 11.1575 14.1227 7.49985 14.1227C3.84216 14.1227 0.877014 11.1575 0.877014 7.49988ZM7.49985 1.82704C4.36683 1.82704 1.82701 4.36686 1.82701 7.49988C1.82701 8.97196 2.38774 10.3131 3.30727 11.3213C4.19074 9.94119 5.73818 9.02499 7.50023 9.02499C9.26206 9.02499 10.8093 9.94097 11.6929 11.3208C12.6121 10.3127 13.1727 8.97172 13.1727 7.49988C13.1727 4.36686 10.6328 1.82704 7.49985 1.82704ZM10.9818 11.9787C10.2839 10.7795 8.9857 9.97499 7.50023 9.97499C6.01458 9.97499 4.71624 10.7797 4.01845 11.9791C4.97952 12.7272 6.18765 13.1727 7.49985 13.1727C8.81227 13.1727 10.0206 12.727 10.9818 11.9787ZM5.14999 6.50487C5.14999 5.207 6.20212 4.15487 7.49999 4.15487C8.79786 4.15487 9.84999 5.207 9.84999 6.50487C9.84999 7.80274 8.79786 8.85487 7.49999 8.85487C6.20212 8.85487 5.14999 7.80274 5.14999 6.50487ZM7.49999 5.10487C6.72679 5.10487 6.09999 5.73167 6.09999 6.50487C6.09999 7.27807 6.72679 7.90487 7.49999 7.90487C8.27319 7.90487 8.89999 7.27807 8.89999 6.50487C8.89999 5.73167 8.27319 5.10487 7.49999 5.10487Z"
|
||||
fill="#000000"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
+192
-268
@@ -1,321 +1,245 @@
|
||||
$(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>`,
|
||||
},
|
||||
};
|
||||
|
||||
function getStatusConfig(status) {
|
||||
return (
|
||||
STATUS_CONFIG[status] || {
|
||||
label: status || "Неизвестно",
|
||||
bgClass: "bg-gray-100",
|
||||
textClass: "text-gray-800",
|
||||
icon: "",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = pathParts[pathParts.length - 1];
|
||||
let currentBook = null;
|
||||
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
showErrorState("Некорректный ID книги");
|
||||
Utils.showToast("Некорректный ID книги", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
loadBook(bookId);
|
||||
|
||||
function loadBook(id) {
|
||||
showLoadingState();
|
||||
|
||||
fetch(`/api/books/${id}`)
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
throw new Error("Книга не найдена");
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
Api.get(`/api/books/${bookId}`)
|
||||
.then((book) => {
|
||||
renderBook(book);
|
||||
renderAuthors(book.authors);
|
||||
renderGenres(book.genres);
|
||||
currentBook = book;
|
||||
document.title = `LiB - ${book.title}`;
|
||||
renderBook(book);
|
||||
if (window.canManage) {
|
||||
$("#edit-book-btn")
|
||||
.attr("href", `/book/${book.id}/edit`)
|
||||
.removeClass("hidden");
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading book:", error);
|
||||
showErrorState(error.message);
|
||||
console.error(error);
|
||||
Utils.showToast("Книга не найдена", "error");
|
||||
$("#book-loader").html(
|
||||
'<p class="text-center text-red-500 w-full p-4">Ошибка загрузки</p>',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBook(book) {
|
||||
const $card = $("#book-card");
|
||||
const authorsText = book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||
$("#book-title").text(book.title);
|
||||
$("#book-id").text(`ID: ${book.id}`);
|
||||
$("#book-authors-text").text(
|
||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен",
|
||||
);
|
||||
$("#book-description").text(book.description || "Описание отсутствует");
|
||||
|
||||
$card.html(`
|
||||
<div class="flex flex-col md:flex-row items-start">
|
||||
<!-- Иконка книги -->
|
||||
<div class="w-32 h-40 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center mb-4 md:mb-0 md:mr-6 flex-shrink-0 shadow-md">
|
||||
<svg class="w-16 h-16 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>
|
||||
</div>
|
||||
renderStatusWidget(book);
|
||||
|
||||
<!-- Информация о книге -->
|
||||
<div class="flex-1">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900">${escapeHtml(book.title)}</h1>
|
||||
<span class="text-sm text-gray-500 ml-4">ID: ${book.id}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-lg text-gray-600 mb-4">
|
||||
${escapeHtml(authorsText)}
|
||||
</p>
|
||||
|
||||
<div class="prose prose-gray max-w-none mb-6">
|
||||
<p class="text-gray-700 leading-relaxed">
|
||||
${escapeHtml(book.description || "Описание отсутствует")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка назад -->
|
||||
<a href="/books" class="inline-flex items-center text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Вернуться к списку книг
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
if (!window.canManage && book.status === "active") {
|
||||
renderReserveButton();
|
||||
}
|
||||
|
||||
function renderAuthors(authors) {
|
||||
const $container = $("#authors-container");
|
||||
const $section = $("#authors-section");
|
||||
$container.empty();
|
||||
|
||||
if (!authors || authors.length === 0) {
|
||||
$section.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const $grid = $('<div class="flex flex-wrap gap-3"></div>');
|
||||
|
||||
authors.forEach((author) => {
|
||||
const firstLetter = author.name.charAt(0).toUpperCase();
|
||||
|
||||
const $authorCard = $(`
|
||||
<a href="/author/${author.id}" class="flex items-center bg-gray-50 hover:bg-gray-100 rounded-lg p-3 transition-colors duration-200 border border-gray-200">
|
||||
<div class="w-10 h-10 bg-gray-500 text-white rounded-full flex items-center justify-center text-lg font-bold mr-3">
|
||||
${firstLetter}
|
||||
</div>
|
||||
<span class="text-gray-900 font-medium">${escapeHtml(author.name)}</span>
|
||||
<svg class="w-4 h-4 text-gray-400 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
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>
|
||||
`);
|
||||
|
||||
$grid.append($authorCard);
|
||||
});
|
||||
|
||||
$container.append($grid);
|
||||
}
|
||||
|
||||
function renderGenres(genres) {
|
||||
const $container = $("#genres-container");
|
||||
const $section = $("#genres-section");
|
||||
$container.empty();
|
||||
|
||||
if (!genres || genres.length === 0) {
|
||||
$section.hide();
|
||||
return;
|
||||
}
|
||||
|
||||
const $grid = $('<div class="flex flex-wrap gap-2"></div>');
|
||||
|
||||
genres.forEach((genre) => {
|
||||
const $genreTag = $(`
|
||||
<a href="/books?genre_id=${genre.id}" class="inline-flex items-center bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-full transition-colors duration-200">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/>
|
||||
</svg>
|
||||
${escapeHtml(genre.name)}
|
||||
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>
|
||||
`);
|
||||
|
||||
$grid.append($genreTag);
|
||||
});
|
||||
|
||||
$container.append($grid);
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $bookCard = $("#book-card");
|
||||
const $authorsContainer = $("#authors-container");
|
||||
const $genresContainer = $("#genres-container");
|
||||
|
||||
$bookCard.html(`
|
||||
<div class="flex flex-col md:flex-row items-start animate-pulse">
|
||||
<div class="w-32 h-40 bg-gray-200 rounded-lg mb-4 md:mb-0 md:mr-6"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-8 bg-gray-200 rounded w-2/3 mb-4"></div>
|
||||
<div class="h-5 bg-gray-200 rounded w-1/3 mb-4"></div>
|
||||
<div class="space-y-2 mb-6">
|
||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
</div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/4"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$authorsContainer.html(`
|
||||
<div class="flex gap-3 animate-pulse">
|
||||
<div class="flex items-center bg-gray-100 rounded-lg p-3">
|
||||
<div class="w-10 h-10 bg-gray-200 rounded-full mr-3"></div>
|
||||
<div class="h-5 bg-gray-200 rounded w-24"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$genresContainer.html(`
|
||||
<div class="flex gap-2 animate-pulse">
|
||||
<div class="h-10 bg-gray-200 rounded-full w-24"></div>
|
||||
<div class="h-10 bg-gray-200 rounded-full w-32"></div>
|
||||
</div>
|
||||
`);
|
||||
$("#book-loader").addClass("hidden");
|
||||
$("#book-content").removeClass("hidden");
|
||||
}
|
||||
|
||||
function showErrorState(message) {
|
||||
const $bookCard = $("#book-card");
|
||||
const $authorsSection = $("#authors-section");
|
||||
const $genresSection = $("#genres-section");
|
||||
function renderStatusWidget(book) {
|
||||
const $container = $("#book-status-container");
|
||||
$container.empty();
|
||||
const config = getStatusConfig(book.status);
|
||||
|
||||
$authorsSection.hide();
|
||||
$genresSection.hide();
|
||||
|
||||
$bookCard.html(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
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>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||
<p class="text-gray-500 mb-6">Не удалось загрузить информацию о книге</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Попробовать снова
|
||||
</button>
|
||||
<a href="/books" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
К списку книг
|
||||
</a>
|
||||
|
||||
<div 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]) => `
|
||||
<button class="status-option w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 flex items-center gap-2 ${book.status === key ? "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>
|
||||
${book.status === key ? '<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>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", function () {
|
||||
$authorsSection.show();
|
||||
$genresSection.show();
|
||||
loadBook(bookId);
|
||||
});
|
||||
}
|
||||
$container.append($dropdownHTML);
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
const $toggleBtn = $("#status-toggle-btn");
|
||||
const $menu = $("#status-menu");
|
||||
|
||||
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) {
|
||||
$toggleBtn.on("click", (e) => {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
$menu.toggleClass("hidden");
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
$(document).on("click", (e) => {
|
||||
if (
|
||||
!$toggleBtn.is(e.target) &&
|
||||
$toggleBtn.has(e.target).length === 0 &&
|
||||
!$menu.has(e.target).length
|
||||
) {
|
||||
$menu.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
$(".status-option").on("click", function () {
|
||||
const newStatus = $(this).data("status");
|
||||
if (newStatus !== currentBook.status) {
|
||||
updateBookStatus(newStatus);
|
||||
}
|
||||
$menu.addClass("hidden");
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
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);
|
||||
$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>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
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 () {
|
||||
Utils.showToast("Функция бронирования в разработке", "info");
|
||||
});
|
||||
}
|
||||
|
||||
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 = {
|
||||
title: currentBook.title,
|
||||
description: currentBook.description,
|
||||
status: newStatus,
|
||||
};
|
||||
|
||||
const updatedBook = await Api.put(
|
||||
`/api/books/${currentBook.id}`,
|
||||
payload,
|
||||
);
|
||||
currentBook = updatedBook;
|
||||
|
||||
Utils.showToast("Статус успешно изменен", "success");
|
||||
|
||||
renderStatusWidget(updatedBook);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка при смене статуса", "error");
|
||||
$toggleBtn
|
||||
.prop("disabled", false)
|
||||
.removeClass("opacity-75")
|
||||
.html(originalContent);
|
||||
}
|
||||
}
|
||||
});
|
||||
+224
-345
@@ -1,189 +1,232 @@
|
||||
$(document).ready(() => {
|
||||
const STATUS_CONFIG = {
|
||||
active: {
|
||||
label: "Доступна",
|
||||
bgClass: "bg-green-100",
|
||||
textClass: "text-green-800",
|
||||
},
|
||||
borrowed: {
|
||||
label: "Выдана",
|
||||
bgClass: "bg-yellow-100",
|
||||
textClass: "text-yellow-800",
|
||||
},
|
||||
reserved: {
|
||||
label: "Забронирована",
|
||||
bgClass: "bg-blue-100",
|
||||
textClass: "text-blue-800",
|
||||
},
|
||||
restoration: {
|
||||
label: "На реставрации",
|
||||
bgClass: "bg-orange-100",
|
||||
textClass: "text-orange-800",
|
||||
},
|
||||
written_off: {
|
||||
label: "Списана",
|
||||
bgClass: "bg-red-100",
|
||||
textClass: "text-red-800",
|
||||
},
|
||||
};
|
||||
|
||||
function getStatusConfig(status) {
|
||||
return (
|
||||
STATUS_CONFIG[status] || {
|
||||
label: status || "Неизвестно",
|
||||
bgClass: "bg-gray-100",
|
||||
textClass: "text-gray-800",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let selectedAuthors = new Map();
|
||||
let selectedGenres = new Map();
|
||||
let currentPage = 1;
|
||||
let pageSize = 20;
|
||||
let pageSize = 12;
|
||||
let totalBooks = 0;
|
||||
|
||||
Promise.all([
|
||||
fetch("/api/authors").then((response) => response.json()),
|
||||
fetch("/api/genres").then((response) => response.json()),
|
||||
])
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const genreIdsFromUrl = urlParams.getAll("genre_id");
|
||||
const authorIdsFromUrl = urlParams.getAll("author_id");
|
||||
const searchFromUrl = urlParams.get("q");
|
||||
|
||||
if (searchFromUrl) $("#book-search-input").val(searchFromUrl);
|
||||
|
||||
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
|
||||
.then(([authorsData, genresData]) => {
|
||||
initAuthors(authorsData.authors);
|
||||
initGenres(genresData.genres);
|
||||
initializeAuthorDropdownListeners();
|
||||
renderChips();
|
||||
loadBooks();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка загрузки данных", "error");
|
||||
});
|
||||
|
||||
function initAuthors(authors) {
|
||||
const $dropdown = $("#author-dropdown");
|
||||
authorsData.authors.forEach((author) => {
|
||||
authors.forEach((author) => {
|
||||
$("<div>")
|
||||
.addClass("p-2 hover:bg-gray-100 cursor-pointer author-item")
|
||||
.addClass(
|
||||
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors",
|
||||
)
|
||||
.attr("data-id", author.id)
|
||||
.attr("data-name", author.name)
|
||||
.text(author.name)
|
||||
.appendTo($dropdown);
|
||||
});
|
||||
|
||||
if (authorIdsFromUrl.includes(String(author.id))) {
|
||||
selectedAuthors.set(author.id, author.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initGenres(genres) {
|
||||
const $list = $("#genres-list");
|
||||
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);
|
||||
genres.forEach((genre) => {
|
||||
const isChecked = genreIdsFromUrl.includes(String(genre.id));
|
||||
if (isChecked) selectedGenres.set(genre.id, genre.name);
|
||||
|
||||
const editButton = window.canManage()
|
||||
? `<a href="/genre/${genre.id}/edit" class="ml-auto mr-2 p-1 text-gray-400 hover:text-gray-600 transition-colors" onclick="event.stopPropagation();" title="Редактировать жанр">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||
</svg>
|
||||
</a>`
|
||||
: "";
|
||||
|
||||
$list.append(`
|
||||
<li class="mb-1">
|
||||
<div class="flex items-center">
|
||||
<label class="custom-checkbox flex items-center flex-1">
|
||||
<input type="checkbox" data-id="${genre.id}" data-name="${Utils.escapeHtml(genre.name)}" ${isChecked ? "checked" : ""} />
|
||||
<span class="checkmark"></span> ${Utils.escapeHtml(genre.name)}
|
||||
</label>
|
||||
${editButton}
|
||||
</div>
|
||||
</li>
|
||||
`);
|
||||
});
|
||||
|
||||
initializeAuthorDropdown();
|
||||
initializeFilters();
|
||||
$list.on("change", "input", function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
|
||||
});
|
||||
|
||||
loadBooks();
|
||||
})
|
||||
.catch((error) => console.error("Error loading data:", error));
|
||||
$list.on("change", "input", function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
this.checked ? selectedGenres.set(id, name) : selectedGenres.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
function loadBooks() {
|
||||
const searchQuery = $("#book-search-input").val().trim();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (searchQuery.length >= 3) {
|
||||
|
||||
params.append("q", searchQuery);
|
||||
}
|
||||
selectedAuthors.forEach((_, id) => params.append("author_ids", id));
|
||||
selectedGenres.forEach((_, id) => params.append("genre_ids", id));
|
||||
|
||||
selectedAuthors.forEach((name, id) => {
|
||||
params.append("author_ids", id);
|
||||
});
|
||||
const browserParams = new URLSearchParams();
|
||||
browserParams.append("q", searchQuery);
|
||||
selectedAuthors.forEach((_, id) => browserParams.append("author_id", id));
|
||||
selectedGenres.forEach((_, id) => browserParams.append("genre_id", id));
|
||||
|
||||
selectedGenres.forEach((name, id) => {
|
||||
params.append("genre_ids", id);
|
||||
});
|
||||
const newUrl =
|
||||
window.location.pathname +
|
||||
(browserParams.toString() ? `?${browserParams.toString()}` : "");
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
|
||||
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();
|
||||
})
|
||||
Api.get(`/api/books/filter?${params.toString()}`)
|
||||
.then((data) => {
|
||||
totalBooks = data.total;
|
||||
renderBooks(data.books);
|
||||
renderPagination();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading books:", error);
|
||||
showErrorState();
|
||||
console.error(error);
|
||||
Utils.showToast("Не удалось загрузить книги", "error");
|
||||
$("#books-container").html(
|
||||
document.getElementById("empty-state-template").innerHTML,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function renderBooks(books) {
|
||||
const $container = $("#books-container");
|
||||
const tpl = document.getElementById("book-card-template");
|
||||
const emptyTpl = document.getElementById("empty-state-template");
|
||||
const badgeTpl = document.getElementById("genre-badge-template");
|
||||
|
||||
$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>
|
||||
`);
|
||||
$container.append(emptyTpl.content.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
|
||||
books.forEach((book) => {
|
||||
const authorsText =
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
const card = clone.querySelector(".book-card");
|
||||
|
||||
card.dataset.id = book.id;
|
||||
clone.querySelector(".book-title").textContent = book.title;
|
||||
clone.querySelector(".book-authors").textContent =
|
||||
book.authors.map((a) => a.name).join(", ") || "Автор неизвестен";
|
||||
const genresText =
|
||||
book.genres.map((g) => g.name).join(", ") || "Без жанра";
|
||||
clone.querySelector(".book-desc").textContent = book.description || "";
|
||||
|
||||
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>
|
||||
`);
|
||||
const statusConfig = getStatusConfig(book.status);
|
||||
const statusEl = clone.querySelector(".book-status");
|
||||
statusEl.textContent = statusConfig.label;
|
||||
statusEl.classList.add(statusConfig.bgClass, statusConfig.textClass);
|
||||
|
||||
$container.append($bookCard);
|
||||
const genresContainer = clone.querySelector(".book-genres");
|
||||
book.genres.forEach((g) => {
|
||||
const badge = badgeTpl.content.cloneNode(true);
|
||||
const span = badge.querySelector("span");
|
||||
span.textContent = g.name;
|
||||
genresContainer.appendChild(badge);
|
||||
});
|
||||
|
||||
$container.on("click", ".book-card", function () {
|
||||
const bookId = $(this).data("id");
|
||||
window.location.href = `/book/${bookId}`;
|
||||
$container.append(clone);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
$("#pagination-container").remove();
|
||||
|
||||
$("#pagination-container").empty();
|
||||
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 class="flex justify-center items-center gap-2 mt-6 mb-4">
|
||||
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === 1 ? "disabled" : ""}>←</button>
|
||||
<div id="page-numbers" class="flex gap-1"></div>
|
||||
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>
|
||||
<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>
|
||||
<button id="next-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed" ${currentPage === totalPages ? "disabled" : ""}>→</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const $pageNumbers = $pagination.find("#page-numbers");
|
||||
|
||||
const pages = generatePageNumbers(currentPage, totalPages);
|
||||
|
||||
pages.forEach((page) => {
|
||||
if (page === "...") {
|
||||
$pageNumbers.append(`<span class="px-3 py-2">...</span>`);
|
||||
$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 ${isActive ? "bg-gray-500 text-white" : "bg-white border border-gray-300 hover:bg-gray-50"}" data-page="${page}">
|
||||
${page}
|
||||
</button>
|
||||
<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>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
$("#books-container").after($pagination);
|
||||
$("#pagination-container").append($pagination);
|
||||
|
||||
$("#prev-page").on("click", function () {
|
||||
if (currentPage > 1) {
|
||||
@@ -192,7 +235,6 @@ $(document).ready(() => {
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$("#next-page").on("click", function () {
|
||||
if (currentPage < totalPages) {
|
||||
currentPage++;
|
||||
@@ -200,7 +242,6 @@ $(document).ready(() => {
|
||||
scrollToTop();
|
||||
}
|
||||
});
|
||||
|
||||
$(".page-btn").on("click", function () {
|
||||
const page = parseInt($(this).data("page"));
|
||||
if (page !== currentPage) {
|
||||
@@ -214,7 +255,6 @@ $(document).ready(() => {
|
||||
function generatePageNumbers(current, total) {
|
||||
const pages = [];
|
||||
const delta = 2;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (
|
||||
i === 1 ||
|
||||
@@ -226,17 +266,15 @@ $(document).ready(() => {
|
||||
pages.push("...");
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function scrollToTop() {
|
||||
$("html, body").animate({ scrollTop: 0 }, 300);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $container = $("#books-container");
|
||||
$container.html(`
|
||||
$("#books-container").html(`
|
||||
<div class="space-y-4">
|
||||
${Array(3)
|
||||
.fill()
|
||||
@@ -246,10 +284,6 @@ $(document).ready(() => {
|
||||
<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>
|
||||
`,
|
||||
)
|
||||
@@ -258,92 +292,60 @@ $(document).ready(() => {
|
||||
`);
|
||||
}
|
||||
|
||||
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"/>
|
||||
function renderChips() {
|
||||
const $container = $("#selected-authors-container");
|
||||
const $dropdown = $("#author-dropdown");
|
||||
|
||||
$container.empty();
|
||||
|
||||
selectedAuthors.forEach((name, id) => {
|
||||
$(`<span class="author-chip inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
|
||||
${Utils.escapeHtml(name)}
|
||||
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-400 rounded-full w-4 h-4 transition-colors" data-id="${id}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
<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>
|
||||
`);
|
||||
</span>`).appendTo($container);
|
||||
});
|
||||
|
||||
$("#retry-btn").on("click", loadBooks);
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
if (selectedAuthors.has(id)) {
|
||||
$(this)
|
||||
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.removeClass("hover:bg-gray-100");
|
||||
} else {
|
||||
$(this)
|
||||
.removeClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.addClass("hover:bg-gray-100");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function initializeAuthorDropdown() {
|
||||
function initializeAuthorDropdownListeners() {
|
||||
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());
|
||||
$input.on("focus", function () {
|
||||
$dropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$(document).on("click", (e) => {
|
||||
$input.on("input", function () {
|
||||
const val = $(this).val().toLowerCase();
|
||||
$dropdown.removeClass("hidden");
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
const text = $(this).text().toLowerCase();
|
||||
$(this).toggle(text.includes(val));
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (
|
||||
!$(e.target).closest("#selected-authors-container, #author-dropdown")
|
||||
.length
|
||||
!$(e.target).closest(
|
||||
"#author-search-input, #author-dropdown, #selected-authors-container",
|
||||
).length
|
||||
) {
|
||||
$dropdown.addClass("hidden");
|
||||
}
|
||||
@@ -351,184 +353,61 @@ $(document).ready(() => {
|
||||
|
||||
$dropdown.on("click", ".author-item", function (e) {
|
||||
e.stopPropagation();
|
||||
toggleAuthor($(this).attr("data-id"), $(this).attr("data-name"));
|
||||
$input.focus();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (selectedAuthors.has(id)) {
|
||||
selectedAuthors.delete(id);
|
||||
} else {
|
||||
selectedAuthors.set(id, name);
|
||||
}
|
||||
|
||||
$input.val("");
|
||||
$dropdown.find(".author-item").show();
|
||||
renderChips();
|
||||
$input[0].focus();
|
||||
});
|
||||
|
||||
$container.on("click", ".remove-author", function (e) {
|
||||
e.stopPropagation();
|
||||
selectedAuthors.delete(parseInt($(this).attr("data-id")));
|
||||
const id = parseInt($(this).data("id"));
|
||||
selectedAuthors.delete(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);
|
||||
}
|
||||
$("#books-container").on("click", ".book-card", function () {
|
||||
window.location.href = `/book/${$(this).data("id")}`;
|
||||
});
|
||||
|
||||
$applyBtn.on("click", function () {
|
||||
$("#apply-filters-btn").on("click", function () {
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
$resetBtn.on("click", function () {
|
||||
$bookSearch.val("");
|
||||
|
||||
$("#reset-filters-btn").on("click", function () {
|
||||
$("#book-search-input").val("");
|
||||
selectedAuthors.clear();
|
||||
$("#selected-authors-container .author-chip").remove();
|
||||
if (window.updateAuthorHighlights) window.updateAuthorHighlights();
|
||||
|
||||
selectedGenres.clear();
|
||||
$("#genres-list input[type='checkbox']").prop("checked", false);
|
||||
|
||||
$("#genres-list input").prop("checked", false);
|
||||
renderChips();
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
});
|
||||
|
||||
let searchTimeout;
|
||||
$bookSearch.on("input", function () {
|
||||
clearTimeout(searchTimeout);
|
||||
const query = $(this).val().trim();
|
||||
|
||||
if (query.length >= 3 || query.length === 0) {
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
$bookSearch.on("keypress", function (e) {
|
||||
$("#book-search-input").on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
clearTimeout(searchTimeout);
|
||||
currentPage = 1;
|
||||
loadBooks();
|
||||
}
|
||||
});
|
||||
|
||||
function showAdminControls() {
|
||||
if (window.canManage) {
|
||||
$("#admin-actions").removeClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
showAdminControls();
|
||||
setTimeout(showAdminControls, 100);
|
||||
});
|
||||
|
||||
@@ -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,346 @@
|
||||
$(document).ready(() => {
|
||||
if (!window.canManage) return;
|
||||
setTimeout(() => window.canManage, 100);
|
||||
|
||||
let allAuthors = [];
|
||||
let allGenres = [];
|
||||
const selectedAuthors = new Map();
|
||||
const selectedGenres = new Map();
|
||||
|
||||
const $form = $("#create-book-form");
|
||||
const $submitBtn = $("#submit-btn");
|
||||
const $submitText = $("#submit-text");
|
||||
const $loadingSpinner = $("#loading-spinner");
|
||||
const $successModal = $("#success-modal");
|
||||
|
||||
Promise.all([Api.get("/api/authors"), Api.get("/api/genres")])
|
||||
.then(([authorsData, genresData]) => {
|
||||
allAuthors = authorsData.authors || [];
|
||||
allGenres = genresData.genres || [];
|
||||
initAuthors(allAuthors);
|
||||
initGenres(allGenres);
|
||||
initializeDropdownListeners();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Ошибка загрузки данных:", err);
|
||||
Utils.showToast(
|
||||
"Не удалось загрузить списки авторов или жанров",
|
||||
"error",
|
||||
);
|
||||
});
|
||||
|
||||
$("#book-title").on("input", function () {
|
||||
$("#title-counter").text(`${this.value.length}/255`);
|
||||
});
|
||||
|
||||
$("#book-description").on("input", function () {
|
||||
$("#desc-counter").text(`${this.value.length}/2000`);
|
||||
});
|
||||
|
||||
function initAuthors(authors) {
|
||||
const $dropdown = $("#author-dropdown");
|
||||
$dropdown.empty();
|
||||
authors.forEach((author) => {
|
||||
$("<div>")
|
||||
.addClass(
|
||||
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors text-sm",
|
||||
)
|
||||
.attr("data-id", author.id)
|
||||
.attr("data-name", author.name)
|
||||
.text(author.name)
|
||||
.appendTo($dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
function initGenres(genres) {
|
||||
const $dropdown = $("#genre-dropdown");
|
||||
$dropdown.empty();
|
||||
genres.forEach((genre) => {
|
||||
$("<div>")
|
||||
.addClass(
|
||||
"p-2 hover:bg-gray-100 cursor-pointer genre-item transition-colors text-sm",
|
||||
)
|
||||
.attr("data-id", genre.id)
|
||||
.attr("data-name", genre.name)
|
||||
.text(genre.name)
|
||||
.appendTo($dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
function renderAuthorChips() {
|
||||
const $container = $("#selected-authors-container");
|
||||
const $dropdown = $("#author-dropdown");
|
||||
|
||||
$container.empty();
|
||||
|
||||
selectedAuthors.forEach((name, id) => {
|
||||
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
|
||||
${Utils.escapeHtml(name)}
|
||||
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>`).appendTo($container);
|
||||
});
|
||||
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
if (selectedAuthors.has(id)) {
|
||||
$(this)
|
||||
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.removeClass("hover:bg-gray-100");
|
||||
} else {
|
||||
$(this)
|
||||
.removeClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.addClass("hover:bg-gray-100");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderGenreChips() {
|
||||
const $container = $("#selected-genres-container");
|
||||
const $dropdown = $("#genre-dropdown");
|
||||
|
||||
$container.empty();
|
||||
|
||||
selectedGenres.forEach((name, id) => {
|
||||
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
|
||||
${Utils.escapeHtml(name)}
|
||||
<button type="button" class="remove-genre mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>`).appendTo($container);
|
||||
});
|
||||
|
||||
$dropdown.find(".genre-item").each(function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
if (selectedGenres.has(id)) {
|
||||
$(this)
|
||||
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.removeClass("hover:bg-gray-100");
|
||||
} else {
|
||||
$(this)
|
||||
.removeClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.addClass("hover:bg-gray-100");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initializeDropdownListeners() {
|
||||
const $authorInput = $("#author-search-input");
|
||||
const $authorDropdown = $("#author-dropdown");
|
||||
const $authorContainer = $("#selected-authors-container");
|
||||
|
||||
$authorInput.on("focus", function () {
|
||||
$authorDropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$authorInput.on("input", function () {
|
||||
const val = $(this).val().toLowerCase();
|
||||
$authorDropdown.removeClass("hidden");
|
||||
$authorDropdown.find(".author-item").each(function () {
|
||||
const text = $(this).text().toLowerCase();
|
||||
$(this).toggle(text.includes(val));
|
||||
});
|
||||
});
|
||||
|
||||
$authorDropdown.on("click", ".author-item", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (selectedAuthors.has(id)) {
|
||||
selectedAuthors.delete(id);
|
||||
} else {
|
||||
selectedAuthors.set(id, name);
|
||||
}
|
||||
|
||||
$authorInput.val("");
|
||||
$authorDropdown.find(".author-item").show();
|
||||
renderAuthorChips();
|
||||
$authorInput[0].focus();
|
||||
});
|
||||
|
||||
$authorContainer.on("click", ".remove-author", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
selectedAuthors.delete(id);
|
||||
renderAuthorChips();
|
||||
});
|
||||
|
||||
const $genreInput = $("#genre-search-input");
|
||||
const $genreDropdown = $("#genre-dropdown");
|
||||
const $genreContainer = $("#selected-genres-container");
|
||||
|
||||
$genreInput.on("focus", function () {
|
||||
$genreDropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$genreInput.on("input", function () {
|
||||
const val = $(this).val().toLowerCase();
|
||||
$genreDropdown.removeClass("hidden");
|
||||
$genreDropdown.find(".genre-item").each(function () {
|
||||
const text = $(this).text().toLowerCase();
|
||||
$(this).toggle(text.includes(val));
|
||||
});
|
||||
});
|
||||
|
||||
$genreDropdown.on("click", ".genre-item", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (selectedGenres.has(id)) {
|
||||
selectedGenres.delete(id);
|
||||
} else {
|
||||
selectedGenres.set(id, name);
|
||||
}
|
||||
|
||||
$genreInput.val("");
|
||||
$genreDropdown.find(".genre-item").show();
|
||||
renderGenreChips();
|
||||
$genreInput[0].focus();
|
||||
});
|
||||
|
||||
$genreContainer.on("click", ".remove-genre", function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
selectedGenres.delete(id);
|
||||
renderGenreChips();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (
|
||||
!$(e.target).closest(
|
||||
"#author-search-input, #author-dropdown, #selected-authors-container",
|
||||
).length
|
||||
) {
|
||||
$authorDropdown.addClass("hidden");
|
||||
}
|
||||
if (
|
||||
!$(e.target).closest(
|
||||
"#genre-search-input, #genre-dropdown, #selected-genres-container",
|
||||
).length
|
||||
) {
|
||||
$genreDropdown.addClass("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$form.on("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const title = $("#book-title").val().trim();
|
||||
const description = $("#book-description").val().trim();
|
||||
|
||||
if (!title) {
|
||||
Utils.showToast("Введите название книги", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const bookPayload = {
|
||||
title: title,
|
||||
description: description || null,
|
||||
};
|
||||
|
||||
const createdBook = await Api.post("/api/books/", bookPayload);
|
||||
|
||||
const linkPromises = [];
|
||||
|
||||
selectedAuthors.forEach((_, authorId) => {
|
||||
linkPromises.push(
|
||||
Api.post(
|
||||
`/api/relationships/author-book?author_id=${authorId}&book_id=${createdBook.id}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
selectedGenres.forEach((_, genreId) => {
|
||||
linkPromises.push(
|
||||
Api.post(
|
||||
`/api/relationships/genre-book?genre_id=${genreId}&book_id=${createdBook.id}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
if (linkPromises.length > 0) {
|
||||
await Promise.allSettled(linkPromises);
|
||||
}
|
||||
|
||||
showSuccess(createdBook);
|
||||
} catch (error) {
|
||||
console.error("Ошибка создания:", error);
|
||||
|
||||
let errorMsg = "Произошла ошибка при создании книги";
|
||||
if (error.responseJSON && error.responseJSON.detail) {
|
||||
errorMsg = error.responseJSON.detail;
|
||||
} else if (error.status === 401) {
|
||||
errorMsg = "Вы не авторизованы";
|
||||
} else if (error.status === 403) {
|
||||
errorMsg = "У вас недостаточно прав";
|
||||
}
|
||||
|
||||
Utils.showToast(errorMsg, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
function setLoading(isLoading) {
|
||||
$submitBtn.prop("disabled", isLoading);
|
||||
if (isLoading) {
|
||||
$submitText.text("Сохранение...");
|
||||
$loadingSpinner.removeClass("hidden");
|
||||
} else {
|
||||
$submitText.text("Создать книгу");
|
||||
$loadingSpinner.addClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccess(book) {
|
||||
$("#modal-book-title").text(book.title);
|
||||
$("#modal-link-btn").attr("href", `/book/${book.id}`);
|
||||
$successModal.removeClass("hidden");
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
$form[0].reset();
|
||||
selectedAuthors.clear();
|
||||
selectedGenres.clear();
|
||||
$("#selected-authors-container").empty();
|
||||
$("#selected-genres-container").empty();
|
||||
$("#title-counter").text("0/255");
|
||||
$("#desc-counter").text("0/2000");
|
||||
|
||||
$("#author-dropdown .author-item")
|
||||
.removeClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.addClass("hover:bg-gray-100");
|
||||
$("#genre-dropdown .genre-item")
|
||||
.removeClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.addClass("hover:bg-gray-100");
|
||||
}
|
||||
|
||||
$("#modal-close-btn").on("click", function () {
|
||||
$successModal.addClass("hidden");
|
||||
resetForm();
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
|
||||
$successModal.on("click", function (e) {
|
||||
if (e.target === this) {
|
||||
window.location.href = "/books";
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && !$successModal.hasClass("hidden")) {
|
||||
window.location.href = "/books";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,457 @@
|
||||
$(document).ready(() => {
|
||||
if (!window.canManage) return;
|
||||
setTimeout(() => window.canManage, 100);
|
||||
|
||||
const pathParts = window.location.pathname.split("/");
|
||||
const bookId = parseInt(pathParts[pathParts.length - 2]);
|
||||
|
||||
if (!bookId || isNaN(bookId)) {
|
||||
Utils.showToast("Некорректный ID книги", "error");
|
||||
setTimeout(() => (window.location.href = "/books"), 1500);
|
||||
return;
|
||||
}
|
||||
|
||||
let originalBook = null;
|
||||
let allAuthors = [];
|
||||
let allGenres = [];
|
||||
const currentAuthors = new Map();
|
||||
const currentGenres = new Map();
|
||||
|
||||
const $form = $("#edit-book-form");
|
||||
const $loader = $("#loader");
|
||||
const $dangerZone = $("#danger-zone");
|
||||
const $titleInput = $("#book-title");
|
||||
const $descInput = $("#book-description");
|
||||
const $statusSelect = $("#book-status");
|
||||
const $submitBtn = $("#submit-btn");
|
||||
const $submitText = $("#submit-text");
|
||||
const $loadingSpinner = $("#loading-spinner");
|
||||
const $deleteModal = $("#delete-modal");
|
||||
const $successModal = $("#success-modal");
|
||||
|
||||
Promise.all([
|
||||
Api.get(`/api/books/${bookId}`),
|
||||
Api.get(`/api/books/${bookId}/authors/`),
|
||||
Api.get(`/api/books/${bookId}/genres/`),
|
||||
Api.get("/api/authors"),
|
||||
Api.get("/api/genres"),
|
||||
])
|
||||
.then(([book, bookAuthors, bookGenres, authorsData, genresData]) => {
|
||||
originalBook = book;
|
||||
allAuthors = authorsData.authors || [];
|
||||
allGenres = genresData.genres || [];
|
||||
|
||||
(bookAuthors.authors || bookAuthors || []).forEach((a) =>
|
||||
currentAuthors.set(a.id, a.name),
|
||||
);
|
||||
(bookGenres.genres || bookGenres || []).forEach((g) =>
|
||||
currentGenres.set(g.id, g.name),
|
||||
);
|
||||
|
||||
document.title = `Редактирование: ${book.title} | LiB`;
|
||||
populateForm(book);
|
||||
initAuthorsDropdown();
|
||||
initGenresDropdown();
|
||||
renderCurrentAuthors();
|
||||
renderCurrentGenres();
|
||||
|
||||
$loader.addClass("hidden");
|
||||
$form.removeClass("hidden");
|
||||
$dangerZone.removeClass("hidden");
|
||||
$("#cancel-btn").attr("href", `/book/${bookId}`);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка загрузки данных", "error");
|
||||
setTimeout(() => (window.location.href = "/books"), 1500);
|
||||
});
|
||||
|
||||
function populateForm(book) {
|
||||
$titleInput.val(book.title);
|
||||
$descInput.val(book.description || "");
|
||||
$statusSelect.val(book.status);
|
||||
updateCounters();
|
||||
}
|
||||
|
||||
function updateCounters() {
|
||||
$("#title-counter").text(`${$titleInput.val().length}/255`);
|
||||
$("#desc-counter").text(`${$descInput.val().length}/2000`);
|
||||
}
|
||||
|
||||
$titleInput.on("input", updateCounters);
|
||||
$descInput.on("input", updateCounters);
|
||||
|
||||
function initAuthorsDropdown() {
|
||||
const $dropdown = $("#author-dropdown");
|
||||
$dropdown.empty();
|
||||
allAuthors.forEach((author) => {
|
||||
$("<div>")
|
||||
.addClass(
|
||||
"p-2 hover:bg-gray-100 cursor-pointer author-item transition-colors text-sm",
|
||||
)
|
||||
.attr("data-id", author.id)
|
||||
.attr("data-name", author.name)
|
||||
.text(author.name)
|
||||
.appendTo($dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
function initGenresDropdown() {
|
||||
const $dropdown = $("#genre-dropdown");
|
||||
$dropdown.empty();
|
||||
allGenres.forEach((genre) => {
|
||||
$("<div>")
|
||||
.addClass(
|
||||
"p-2 hover:bg-gray-100 cursor-pointer genre-item transition-colors text-sm",
|
||||
)
|
||||
.attr("data-id", genre.id)
|
||||
.attr("data-name", genre.name)
|
||||
.text(genre.name)
|
||||
.appendTo($dropdown);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCurrentAuthors() {
|
||||
const $container = $("#current-authors-container");
|
||||
const $dropdown = $("#author-dropdown");
|
||||
|
||||
$container.empty();
|
||||
$("#authors-count").text(
|
||||
currentAuthors.size > 0 ? `(${currentAuthors.size})` : "",
|
||||
);
|
||||
|
||||
currentAuthors.forEach((name, id) => {
|
||||
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
|
||||
${Utils.escapeHtml(name)}
|
||||
<button type="button" class="remove-author mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}" data-name="${Utils.escapeHtml(name)}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>`).appendTo($container);
|
||||
});
|
||||
|
||||
$dropdown.find(".author-item").each(function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
if (currentAuthors.has(id)) {
|
||||
$(this)
|
||||
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.removeClass("hover:bg-gray-100");
|
||||
} else {
|
||||
$(this)
|
||||
.removeClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.addClass("hover:bg-gray-100");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderCurrentGenres() {
|
||||
const $container = $("#current-genres-container");
|
||||
const $dropdown = $("#genre-dropdown");
|
||||
|
||||
$container.empty();
|
||||
$("#genres-count").text(
|
||||
currentGenres.size > 0 ? `(${currentGenres.size})` : "",
|
||||
);
|
||||
|
||||
currentGenres.forEach((name, id) => {
|
||||
$(`<span class="inline-flex items-center bg-gray-600 text-white text-sm font-medium px-2.5 pt-0.5 pb-1 rounded-full">
|
||||
${Utils.escapeHtml(name)}
|
||||
<button type="button" class="remove-genre mt-0.5 ml-2 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-gray-500 rounded-full w-4 h-4 transition-colors" data-id="${id}" data-name="${Utils.escapeHtml(name)}">
|
||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>`).appendTo($container);
|
||||
});
|
||||
|
||||
$dropdown.find(".genre-item").each(function () {
|
||||
const id = parseInt($(this).data("id"));
|
||||
if (currentGenres.has(id)) {
|
||||
$(this)
|
||||
.addClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.removeClass("hover:bg-gray-100");
|
||||
} else {
|
||||
$(this)
|
||||
.removeClass("bg-gray-200 text-gray-900 font-semibold")
|
||||
.addClass("hover:bg-gray-100");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const $authorInput = $("#author-search-input");
|
||||
const $authorDropdown = $("#author-dropdown");
|
||||
const $authorContainer = $("#current-authors-container");
|
||||
|
||||
$authorInput.on("focus", function () {
|
||||
$authorDropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$authorInput.on("input", function () {
|
||||
const val = $(this).val().toLowerCase();
|
||||
$authorDropdown.removeClass("hidden");
|
||||
$authorDropdown.find(".author-item").each(function () {
|
||||
const text = $(this).text().toLowerCase();
|
||||
$(this).toggle(text.includes(val));
|
||||
});
|
||||
});
|
||||
|
||||
$authorDropdown.on("click", ".author-item", async function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (currentAuthors.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$(this).addClass("opacity-50 pointer-events-none");
|
||||
|
||||
try {
|
||||
await Api.post(
|
||||
`/api/relationships/author-book?author_id=${id}&book_id=${bookId}`,
|
||||
);
|
||||
currentAuthors.set(id, name);
|
||||
renderCurrentAuthors();
|
||||
Utils.showToast(`Автор "${name}" добавлен`, "success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка добавления автора", "error");
|
||||
} finally {
|
||||
$(this).removeClass("opacity-50 pointer-events-none");
|
||||
}
|
||||
|
||||
$authorInput.val("");
|
||||
$authorDropdown.find(".author-item").show();
|
||||
});
|
||||
|
||||
$authorContainer.on("click", ".remove-author", async function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
const $chip = $(this).parent();
|
||||
|
||||
$chip.addClass("opacity-50");
|
||||
|
||||
try {
|
||||
await Api.delete(
|
||||
`/api/relationships/author-book?author_id=${id}&book_id=${bookId}`,
|
||||
);
|
||||
currentAuthors.delete(id);
|
||||
renderCurrentAuthors();
|
||||
Utils.showToast(`Автор "${name}" удалён`, "success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка удаления автора", "error");
|
||||
$chip.removeClass("opacity-50");
|
||||
}
|
||||
});
|
||||
|
||||
const $genreInput = $("#genre-search-input");
|
||||
const $genreDropdown = $("#genre-dropdown");
|
||||
const $genreContainer = $("#current-genres-container");
|
||||
|
||||
$genreInput.on("focus", function () {
|
||||
$genreDropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$genreInput.on("input", function () {
|
||||
const val = $(this).val().toLowerCase();
|
||||
$genreDropdown.removeClass("hidden");
|
||||
$genreDropdown.find(".genre-item").each(function () {
|
||||
const text = $(this).text().toLowerCase();
|
||||
$(this).toggle(text.includes(val));
|
||||
});
|
||||
});
|
||||
|
||||
$genreDropdown.on("click", ".genre-item", async function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (currentGenres.has(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$(this).addClass("opacity-50 pointer-events-none");
|
||||
|
||||
try {
|
||||
await Api.post(
|
||||
`/api/relationships/genre-book?genre_id=${id}&book_id=${bookId}`,
|
||||
);
|
||||
currentGenres.set(id, name);
|
||||
renderCurrentGenres();
|
||||
Utils.showToast(`Жанр "${name}" добавлен`, "success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка добавления жанра", "error");
|
||||
} finally {
|
||||
$(this).removeClass("opacity-50 pointer-events-none");
|
||||
}
|
||||
|
||||
$genreInput.val("");
|
||||
$genreDropdown.find(".genre-item").show();
|
||||
});
|
||||
|
||||
$genreContainer.on("click", ".remove-genre", async function (e) {
|
||||
e.stopPropagation();
|
||||
const id = parseInt($(this).data("id"));
|
||||
const name = $(this).data("name");
|
||||
const $chip = $(this).parent();
|
||||
|
||||
$chip.addClass("opacity-50");
|
||||
|
||||
try {
|
||||
await Api.delete(
|
||||
`/api/relationships/genre-book?genre_id=${id}&book_id=${bookId}`,
|
||||
);
|
||||
currentGenres.delete(id);
|
||||
renderCurrentGenres();
|
||||
Utils.showToast(`Жанр "${name}" удалён`, "success");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка удаления жанра", "error");
|
||||
$chip.removeClass("opacity-50");
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (!$(e.target).closest("#author-search-input, #author-dropdown").length) {
|
||||
$authorDropdown.addClass("hidden");
|
||||
}
|
||||
if (!$(e.target).closest("#genre-search-input, #genre-dropdown").length) {
|
||||
$genreDropdown.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$form.on("submit", async function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const title = $titleInput.val().trim();
|
||||
const description = $descInput.val().trim();
|
||||
const status = $statusSelect.val();
|
||||
|
||||
if (!title) {
|
||||
Utils.showToast("Введите название книги", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {};
|
||||
if (title !== originalBook.title) payload.title = title;
|
||||
if (description !== (originalBook.description || ""))
|
||||
payload.description = description || null;
|
||||
if (status !== originalBook.status) payload.status = status;
|
||||
|
||||
if (Object.keys(payload).length === 0) {
|
||||
Utils.showToast("Нет изменений для сохранения", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const updatedBook = await Api.put(`/api/books/${bookId}`, payload);
|
||||
originalBook = updatedBook;
|
||||
showSuccessModal(updatedBook);
|
||||
} catch (error) {
|
||||
console.error("Ошибка обновления:", error);
|
||||
|
||||
let errorMsg = "Произошла ошибка при обновлении книги";
|
||||
if (error.responseJSON && error.responseJSON.detail) {
|
||||
errorMsg = error.responseJSON.detail;
|
||||
} else if (error.status === 401) {
|
||||
errorMsg = "Вы не авторизованы";
|
||||
} else if (error.status === 403) {
|
||||
errorMsg = "У вас недостаточно прав";
|
||||
} else if (error.status === 404) {
|
||||
errorMsg = "Книга не найдена";
|
||||
}
|
||||
|
||||
Utils.showToast(errorMsg, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
function setLoading(isLoading) {
|
||||
$submitBtn.prop("disabled", isLoading);
|
||||
if (isLoading) {
|
||||
$submitText.text("Сохранение...");
|
||||
$loadingSpinner.removeClass("hidden");
|
||||
} else {
|
||||
$submitText.text("Сохранить изменения");
|
||||
$loadingSpinner.addClass("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function showSuccessModal(book) {
|
||||
$("#success-book-title").text(book.title);
|
||||
$("#success-link-btn").attr("href", `/book/${book.id}`);
|
||||
$successModal.removeClass("hidden");
|
||||
}
|
||||
|
||||
$("#success-close-btn").on("click", function () {
|
||||
$successModal.addClass("hidden");
|
||||
});
|
||||
|
||||
$successModal.on("click", function (e) {
|
||||
if (e.target === this) {
|
||||
$successModal.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#delete-btn").on("click", function () {
|
||||
$("#modal-book-title").text(originalBook.title);
|
||||
$deleteModal.removeClass("hidden");
|
||||
});
|
||||
|
||||
$("#cancel-delete-btn").on("click", function () {
|
||||
$deleteModal.addClass("hidden");
|
||||
});
|
||||
|
||||
$deleteModal.on("click", function (e) {
|
||||
if (e.target === this) {
|
||||
$deleteModal.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$("#confirm-delete-btn").on("click", async function () {
|
||||
const $btn = $(this);
|
||||
const $spinner = $("#delete-spinner");
|
||||
|
||||
$btn.prop("disabled", true);
|
||||
$spinner.removeClass("hidden");
|
||||
|
||||
try {
|
||||
await Api.delete(`/api/books/${bookId}`);
|
||||
Utils.showToast("Книга успешно удалена", "success");
|
||||
setTimeout(() => (window.location.href = "/books"), 1000);
|
||||
} catch (error) {
|
||||
console.error("Ошибка удаления:", error);
|
||||
|
||||
let errorMsg = "Произошла ошибка при удалении книги";
|
||||
if (error.responseJSON && error.responseJSON.detail) {
|
||||
errorMsg = error.responseJSON.detail;
|
||||
} else if (error.status === 401) {
|
||||
errorMsg = "Вы не авторизованы";
|
||||
} else if (error.status === 403) {
|
||||
errorMsg = "У вас недостаточно прав";
|
||||
}
|
||||
|
||||
Utils.showToast(errorMsg, "error");
|
||||
$btn.prop("disabled", false);
|
||||
$spinner.addClass("hidden");
|
||||
$deleteModal.addClass("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape") {
|
||||
if (!$deleteModal.hasClass("hidden")) {
|
||||
$deleteModal.addClass("hidden");
|
||||
} else if (!$successModal.hasClass("hidden")) {
|
||||
$successModal.addClass("hidden");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -250,9 +250,7 @@ function observeStatCards() {
|
||||
entries.forEach((entry, index) => {
|
||||
if (entry.isIntersecting) {
|
||||
setTimeout(() => {
|
||||
$(entry.target)
|
||||
.addClass("animate-fade-in")
|
||||
.css({
|
||||
$(entry.target).addClass("animate-fade-in").css({
|
||||
opacity: "1",
|
||||
transform: "translateY(0)",
|
||||
});
|
||||
@@ -261,10 +259,10 @@ function observeStatCards() {
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
$cards.each((index, card) => {
|
||||
$cards.each(function (index, card) {
|
||||
$(card).css({
|
||||
opacity: "0",
|
||||
transform: "translateY(20px)",
|
||||
@@ -277,108 +275,4 @@ function observeStatCards() {
|
||||
$(document).ready(() => {
|
||||
loadStats();
|
||||
observeStatCards();
|
||||
|
||||
const $guestLink = $("#guest-link");
|
||||
const $userBtn = $("#user-btn");
|
||||
const $userDropdown = $("#user-dropdown");
|
||||
const $userArrow = $("#user-arrow");
|
||||
const $userAvatar = $("#user-avatar");
|
||||
const $dropdownName = $("#dropdown-name");
|
||||
const $dropdownUsername = $("#dropdown-username");
|
||||
const $dropdownEmail = $("#dropdown-email");
|
||||
const $logoutBtn = $("#logout-btn");
|
||||
|
||||
let isDropdownOpen = false;
|
||||
|
||||
function openDropdown() {
|
||||
isDropdownOpen = true;
|
||||
$userDropdown.removeClass("hidden");
|
||||
$userArrow.addClass("rotate-180");
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isDropdownOpen = false;
|
||||
$userDropdown.addClass("hidden");
|
||||
$userArrow.removeClass("rotate-180");
|
||||
}
|
||||
|
||||
$userBtn.on("click", function (e) {
|
||||
e.stopPropagation();
|
||||
isDropdownOpen ? closeDropdown() : openDropdown();
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && isDropdownOpen) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", function () {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
function updateUserAvatar(email) {
|
||||
if (!email) return;
|
||||
const cleanEmail = email.trim().toLowerCase();
|
||||
const emailHash = sha256(cleanEmail);
|
||||
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
const avatarImg = document.getElementById("user-avatar");
|
||||
if (avatarImg) {
|
||||
avatarImg.src = avatarUrl;
|
||||
}
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
showGuest();
|
||||
} else {
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
throw new Error("Unauthorized");
|
||||
})
|
||||
.then((user) => {
|
||||
showUser(user);
|
||||
updateUserAvatar(user.email);
|
||||
|
||||
document.getElementById("user-btn").classList.remove("hidden");
|
||||
document.getElementById("guest-link").classList.add("hidden");
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
showGuest();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,509 +1,128 @@
|
||||
$(document).ready(() => {
|
||||
let currentUser = null;
|
||||
let allRoles = [];
|
||||
|
||||
const token = localStorage.getItem("access_token");
|
||||
|
||||
if (!token) {
|
||||
window.location.href = "/login";
|
||||
window.location.href = "/auth";
|
||||
return;
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
|
||||
function loadProfile() {
|
||||
showLoadingState();
|
||||
|
||||
Promise.all([
|
||||
fetch("/api/auth/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
}).then((response) => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}),
|
||||
fetch("/api/auth/roles", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
}).then((response) => {
|
||||
if (response.ok) return response.json();
|
||||
return { roles: [] };
|
||||
}),
|
||||
Api.get("/api/auth/me"),
|
||||
Api.get("/api/auth/roles").catch(() => ({ roles: [] })),
|
||||
])
|
||||
.then(([user, rolesData]) => {
|
||||
currentUser = user;
|
||||
allRoles = rolesData.roles || [];
|
||||
renderProfile(user);
|
||||
renderAccountInfo(user);
|
||||
renderRoles(user.roles, allRoles);
|
||||
renderActions();
|
||||
.then(async ([user, rolesData]) => {
|
||||
document.title = `LiB - ${user.full_name || user.username}`;
|
||||
await renderProfileHeader(user);
|
||||
renderInfo(user);
|
||||
renderRoles(user.roles || [], rolesData.roles || []);
|
||||
|
||||
$("#account-section, #roles-section").removeClass("hidden");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading profile:", error);
|
||||
if (error.message === "Unauthorized") {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
} else {
|
||||
showErrorState(error.message);
|
||||
}
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка загрузки профиля", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfile(user) {
|
||||
const $card = $("#profile-card");
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
async function renderProfileHeader(user) {
|
||||
const avatarUrl = await Utils.getGravatarUrl(user.email);
|
||||
const displayName = Utils.escapeHtml(user.full_name || user.username);
|
||||
|
||||
const emailHash = sha256(user.email.trim().toLowerCase());
|
||||
const avatarUrl = `https://www.gravatar.com/avatar/${emailHash}?d=identicon&s=200`;
|
||||
|
||||
$card.html(`
|
||||
$("#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}" alt="Аватар"
|
||||
class="w-24 h-24 rounded-full object-cover border-4 border-gray-200"
|
||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
||||
<div class="w-24 h-24 bg-gray-500 text-white rounded-full items-center justify-center text-4xl font-bold hidden">
|
||||
${firstLetter}
|
||||
<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>
|
||||
<!-- Статус верификации -->
|
||||
${user.is_verified ? `
|
||||
<div class="absolute -bottom-1 -right-1 bg-green-500 rounded-full p-1" title="Подтверждённый аккаунт">
|
||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<!-- Информация -->
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-1">${escapeHtml(displayName)}</h1>
|
||||
<p class="text-gray-500 mb-3">@${escapeHtml(user.username)}</p>
|
||||
|
||||
<!-- Статусы -->
|
||||
<div class="flex flex-wrap justify-center sm:justify-start gap-2">
|
||||
${user.is_active ? `
|
||||
<span class="inline-flex items-center bg-green-100 text-green-800 text-sm px-3 py-1 rounded-full">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
Активен
|
||||
<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>
|
||||
` : `
|
||||
<span class="inline-flex items-center bg-red-100 text-red-800 text-sm px-3 py-1 rounded-full">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-2"></span>
|
||||
Заблокирован
|
||||
</span>
|
||||
`}
|
||||
${user.is_verified ? `
|
||||
<span class="inline-flex items-center bg-blue-100 text-blue-800 text-sm px-3 py-1 rounded-full">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Подтверждён
|
||||
</span>
|
||||
` : `
|
||||
<span class="inline-flex items-center bg-yellow-100 text-yellow-800 text-sm px-3 py-1 rounded-full">
|
||||
<svg class="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
Не подтверждён
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderAccountInfo(user) {
|
||||
const $container = $("#account-container");
|
||||
function renderInfo(user) {
|
||||
const fields = [
|
||||
{ label: "ID пользователя", value: user.id },
|
||||
{ label: "Email", value: user.email },
|
||||
{ label: "Полное имя", value: user.full_name || "Не указано" },
|
||||
];
|
||||
|
||||
$container.html(`
|
||||
<div class="space-y-4">
|
||||
<!-- ID пользователя -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">ID пользователя</p>
|
||||
<p class="text-gray-900">${user.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Имя пользователя -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Имя пользователя</p>
|
||||
<p class="text-gray-900">@${escapeHtml(user.username)}</p>
|
||||
</div>
|
||||
</div>
|
||||
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("");
|
||||
|
||||
<!-- Полное имя -->
|
||||
<div class="flex items-center justify-between py-3 border-b border-gray-100">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Полное имя</p>
|
||||
<p class="text-gray-900">${escapeHtml(user.full_name || "Не указано")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div class="flex items-center justify-between py-3">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Email</p>
|
||||
<p class="text-gray-900">${escapeHtml(user.email)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
$("#account-info").html(html);
|
||||
}
|
||||
|
||||
function renderRoles(userRoles, allRoles) {
|
||||
const $container = $("#roles-container");
|
||||
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
$container.html(`
|
||||
<p class="text-gray-500">У вас нет назначенных ролей</p>
|
||||
`);
|
||||
if (userRoles.length === 0) {
|
||||
$container.html('<p class="text-gray-500">Нет ролей</p>');
|
||||
return;
|
||||
}
|
||||
|
||||
const roleDescriptions = {};
|
||||
allRoles.forEach((role) => {
|
||||
roleDescriptions[role.name] = role.description;
|
||||
});
|
||||
const roleMap = {};
|
||||
allRoles.forEach((r) => (roleMap[r.name] = r.description));
|
||||
|
||||
const roleIcons = {
|
||||
admin: `<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 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>`,
|
||||
librarian: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
|
||||
</svg>`,
|
||||
member: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
|
||||
</svg>`,
|
||||
};
|
||||
|
||||
const roleColors = {
|
||||
admin: "bg-red-100 text-red-800 border-red-200",
|
||||
librarian: "bg-blue-100 text-blue-800 border-blue-200",
|
||||
member: "bg-green-100 text-green-800 border-green-200",
|
||||
};
|
||||
|
||||
let rolesHtml = '<div class="space-y-3">';
|
||||
|
||||
userRoles.forEach((roleName) => {
|
||||
const description = roleDescriptions[roleName] || "Описание недоступно";
|
||||
const icon = roleIcons[roleName] || roleIcons.member;
|
||||
const colorClass = roleColors[roleName] || roleColors.member;
|
||||
|
||||
rolesHtml += `
|
||||
<div class="flex items-center p-4 rounded-lg border ${colorClass}">
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
${icon}
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-medium capitalize">${escapeHtml(roleName)}</h4>
|
||||
<p class="text-sm opacity-75">${escapeHtml(description)}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
rolesHtml += '</div>';
|
||||
|
||||
$container.html(rolesHtml);
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
const $container = $("#actions-container");
|
||||
|
||||
$container.html(`
|
||||
<div class="space-y-3">
|
||||
<!-- Смена пароля -->
|
||||
<button id="change-password-btn" class="w-full flex items-center justify-between p-4 bg-gray-50 hover:bg-gray-100 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-gray-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"/>
|
||||
</svg>
|
||||
<span class="text-gray-700">Сменить пароль</span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Выход -->
|
||||
<button id="logout-profile-btn" class="w-full flex items-center justify-between p-4 bg-red-50 hover:bg-red-100 rounded-lg transition-colors">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-5 h-5 text-red-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||
</svg>
|
||||
<span class="text-red-700">Выйти из аккаунта</span>
|
||||
</div>
|
||||
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#change-password-btn").on("click", openPasswordModal);
|
||||
$("#logout-profile-btn").on("click", logout);
|
||||
}
|
||||
|
||||
function openPasswordModal() {
|
||||
$("#password-modal").removeClass("hidden").addClass("flex");
|
||||
$("#current-password").focus();
|
||||
}
|
||||
|
||||
function closePasswordModal() {
|
||||
$("#password-modal").removeClass("flex").addClass("hidden");
|
||||
$("#password-form")[0].reset();
|
||||
$("#password-error").addClass("hidden").text("");
|
||||
}
|
||||
|
||||
$("#close-password-modal, #cancel-password").on("click", closePasswordModal);
|
||||
|
||||
$("#password-modal").on("click", function (e) {
|
||||
if (e.target === this) {
|
||||
closePasswordModal();
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape" && $("#password-modal").hasClass("flex")) {
|
||||
closePasswordModal();
|
||||
}
|
||||
});
|
||||
|
||||
$("#password-form").on("submit", function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
const currentPassword = $("#current-password").val();
|
||||
const newPassword = $("#new-password").val();
|
||||
const confirmPassword = $("#confirm-password").val();
|
||||
const $error = $("#password-error");
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
$error.text("Пароли не совпадают").removeClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
$error.text("Пароль должен содержать минимум 6 символов").removeClass("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: смена пароля, 2FA
|
||||
// fetch("/api/auth/change-password", {
|
||||
// method: "POST",
|
||||
// headers: {
|
||||
// "Content-Type": "application/json",
|
||||
// Authorization: "Bearer " + token,
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// current_password: currentPassword,
|
||||
// new_password: newPassword,
|
||||
// }),
|
||||
// })
|
||||
// .then((response) => {
|
||||
// if (!response.ok) throw new Error("Ошибка смены пароля");
|
||||
// return response.json();
|
||||
// })
|
||||
// .then(() => {
|
||||
// closePasswordModal();
|
||||
// showNotification("Пароль успешно изменён", "success");
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// $error.text(error.message).removeClass("hidden");
|
||||
// });
|
||||
|
||||
$error.text("Функция смены пароля временно недоступна").removeClass("hidden");
|
||||
});
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
const $profileCard = $("#profile-card");
|
||||
const $accountContainer = $("#account-container");
|
||||
const $rolesContainer = $("#roles-container");
|
||||
const $actionsContainer = $("#actions-container");
|
||||
|
||||
$profileCard.html(`
|
||||
<div class="flex flex-col sm:flex-row items-center sm:items-start animate-pulse">
|
||||
<div class="w-24 h-24 bg-gray-200 rounded-full mb-4 sm:mb-0 sm:mr-6"></div>
|
||||
<div class="flex-1 text-center sm:text-left">
|
||||
<div class="h-7 bg-gray-200 rounded w-48 mx-auto sm:mx-0 mb-2"></div>
|
||||
<div class="h-5 bg-gray-200 rounded w-32 mx-auto sm:mx-0 mb-3"></div>
|
||||
<div class="flex justify-center sm:justify-start gap-2">
|
||||
<div class="h-7 bg-gray-200 rounded-full w-20"></div>
|
||||
<div class="h-7 bg-gray-200 rounded-full w-28"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$accountContainer.html(`
|
||||
<div class="space-y-4 animate-pulse">
|
||||
${Array(4)
|
||||
.fill()
|
||||
const html = userRoles
|
||||
.map(
|
||||
() => `
|
||||
<div class="flex items-center py-3 border-b border-gray-100">
|
||||
<div class="w-5 h-5 bg-gray-200 rounded mr-3"></div>
|
||||
<div>
|
||||
<div class="h-3 bg-gray-200 rounded w-16 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-40"></div>
|
||||
(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>
|
||||
</div>
|
||||
`
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
.join("");
|
||||
|
||||
$rolesContainer.html(`
|
||||
<div class="space-y-3 animate-pulse">
|
||||
<div class="h-16 bg-gray-200 rounded-lg"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$actionsContainer.html(`
|
||||
<div class="space-y-3 animate-pulse">
|
||||
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
||||
<div class="h-14 bg-gray-200 rounded-lg"></div>
|
||||
</div>
|
||||
`);
|
||||
$container.html(html);
|
||||
}
|
||||
|
||||
function showErrorState(message) {
|
||||
const $profileCard = $("#profile-card");
|
||||
const $accountSection = $("#account-section");
|
||||
const $rolesSection = $("#roles-section");
|
||||
const $actionsSection = $("#actions-section");
|
||||
$("#submit-password-btn").on("click", async function () {
|
||||
const $btn = $(this);
|
||||
const newPass = $("#new-password").val();
|
||||
const confirm = $("#confirm-password").val();
|
||||
|
||||
$accountSection.hide();
|
||||
$rolesSection.hide();
|
||||
$actionsSection.hide();
|
||||
|
||||
$profileCard.html(`
|
||||
<div class="text-center py-8">
|
||||
<svg class="mx-auto h-16 w-16 text-red-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
|
||||
</svg>
|
||||
<h3 class="text-xl font-medium text-gray-900 mb-2">${escapeHtml(message)}</h3>
|
||||
<p class="text-gray-500 mb-6">Не удалось загрузить профиль</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button id="retry-btn" class="bg-gray-500 text-white px-6 py-2 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Попробовать снова
|
||||
</button>
|
||||
<a href="/" class="bg-white text-gray-700 px-6 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
На главную
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
$("#retry-btn").on("click", function () {
|
||||
$accountSection.show();
|
||||
$rolesSection.show();
|
||||
$actionsSection.show();
|
||||
loadProfile();
|
||||
});
|
||||
if (newPass !== confirm) {
|
||||
Utils.showToast("Пароли не совпадают", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (!text) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
if (newPass.length < 4) {
|
||||
Utils.showToast("Пароль слишком короткий", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
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");
|
||||
$btn.prop("disabled", true).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();
|
||||
try {
|
||||
await Api.put("/api/auth/me", {
|
||||
password: newPass,
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (isDropdownOpen && !$(e.target).closest("#user-menu-container").length) {
|
||||
closeDropdown();
|
||||
Utils.showToast("Пароль успешно изменен", "success");
|
||||
window.dispatchEvent(new CustomEvent("close-modal"));
|
||||
|
||||
$("#change-password-form")[0].reset();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка смены пароля", "error");
|
||||
} finally {
|
||||
$btn.prop("disabled", false).text("Сменить");
|
||||
}
|
||||
});
|
||||
|
||||
$logoutBtn.on("click", logout);
|
||||
|
||||
function showGuest() {
|
||||
$guestLink.removeClass("hidden");
|
||||
$userBtn.addClass("hidden").removeClass("flex");
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
function showUser(user) {
|
||||
$guestLink.addClass("hidden");
|
||||
$userBtn.removeClass("hidden").addClass("flex");
|
||||
|
||||
const displayName = user.full_name || user.username;
|
||||
const firstLetter = displayName.charAt(0).toUpperCase();
|
||||
|
||||
$userAvatar.text(firstLetter);
|
||||
$dropdownName.text(displayName);
|
||||
$dropdownUsername.text("@" + user.username);
|
||||
$dropdownEmail.text(user.email);
|
||||
}
|
||||
|
||||
if (currentUser) {
|
||||
showUser(currentUser);
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,9 @@ h1 {
|
||||
letter-spacing: 10px;
|
||||
}
|
||||
|
||||
h2,
|
||||
.book-id,
|
||||
.book-status,
|
||||
nav ul li a {
|
||||
font-family: "Dited", sans-serif;
|
||||
letter-spacing: 2.5px;
|
||||
|
||||
@@ -0,0 +1,701 @@
|
||||
$(document).ready(() => {
|
||||
if (!window.isAdmin()) {
|
||||
$("#users-container").html(
|
||||
document.getElementById("access-denied-template").innerHTML,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (!window.isAdmin()) {
|
||||
$("#users-container").html(
|
||||
document.getElementById("access-denied-template").innerHTML,
|
||||
);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
let allRoles = [];
|
||||
let users = [];
|
||||
let currentPage = 1;
|
||||
let pageSize = 20;
|
||||
let totalUsers = 0;
|
||||
let searchQuery = "";
|
||||
let selectedFilterRoles = new Set();
|
||||
let activeDropdown = null;
|
||||
let userToDelete = null;
|
||||
|
||||
const defaultPlaceholder = "Фильтр по роли...";
|
||||
|
||||
showLoadingState();
|
||||
|
||||
Promise.all([
|
||||
Api.get("/api/auth/users?skip=0&limit=100"),
|
||||
Api.get("/api/auth/roles"),
|
||||
])
|
||||
.then(([usersData, rolesData]) => {
|
||||
users = usersData.users;
|
||||
totalUsers = usersData.total;
|
||||
allRoles = rolesData.roles;
|
||||
$("#total-users-count").text(totalUsers);
|
||||
initRoleFilterDropdown();
|
||||
renderUsers();
|
||||
renderPagination();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Ошибка загрузки данных", "error");
|
||||
});
|
||||
|
||||
function initRoleFilterDropdown() {
|
||||
const $dropdown = $("#role-filter-dropdown");
|
||||
$dropdown.empty();
|
||||
|
||||
allRoles.forEach((role) => {
|
||||
$("<div>")
|
||||
.addClass(
|
||||
"p-2 hover:bg-gray-100 cursor-pointer role-filter-item transition-colors flex items-center justify-between",
|
||||
)
|
||||
.attr("data-name", role.name)
|
||||
.html(
|
||||
`<div>
|
||||
<div class="font-medium text-sm">${Utils.escapeHtml(role.name)}</div>
|
||||
${role.description ? `<div class="text-xs text-gray-500">${Utils.escapeHtml(role.description)}</div>` : ""}
|
||||
</div>
|
||||
<svg class="check-icon w-4 h-4 text-green-600 hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>`,
|
||||
)
|
||||
.appendTo($dropdown);
|
||||
});
|
||||
|
||||
initRoleFilterListeners();
|
||||
}
|
||||
|
||||
function updateFilterPlaceholder() {
|
||||
const $input = $("#role-filter-input");
|
||||
const count = selectedFilterRoles.size;
|
||||
|
||||
if (count === 0) {
|
||||
$input.attr("placeholder", defaultPlaceholder);
|
||||
} else {
|
||||
$input.attr("placeholder", `Выбрано ролей: ${count}`);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDropdownCheckmarks() {
|
||||
$("#role-filter-dropdown .role-filter-item").each(function () {
|
||||
const name = $(this).data("name");
|
||||
const $check = $(this).find(".check-icon");
|
||||
if (selectedFilterRoles.has(name)) {
|
||||
$check.removeClass("hidden");
|
||||
$(this).addClass("bg-gray-50");
|
||||
} else {
|
||||
$check.addClass("hidden");
|
||||
$(this).removeClass("bg-gray-50");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initRoleFilterListeners() {
|
||||
const $input = $("#role-filter-input");
|
||||
const $dropdown = $("#role-filter-dropdown");
|
||||
|
||||
$input.on("focus", function () {
|
||||
$dropdown.removeClass("hidden");
|
||||
});
|
||||
|
||||
$input.on("input", function () {
|
||||
const val = $(this).val().toLowerCase();
|
||||
$dropdown.removeClass("hidden");
|
||||
$dropdown.find(".role-filter-item").each(function () {
|
||||
const name = $(this).data("name").toLowerCase();
|
||||
$(this).toggle(name.includes(val));
|
||||
});
|
||||
});
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (
|
||||
!$(e.target).closest("#role-filter-input, #role-filter-dropdown").length
|
||||
) {
|
||||
$dropdown.addClass("hidden");
|
||||
$input.val("");
|
||||
$dropdown.find(".role-filter-item").show();
|
||||
}
|
||||
});
|
||||
|
||||
$dropdown.on("click", ".role-filter-item", function (e) {
|
||||
e.stopPropagation();
|
||||
const name = $(this).data("name");
|
||||
|
||||
if (selectedFilterRoles.has(name)) {
|
||||
selectedFilterRoles.delete(name);
|
||||
} else {
|
||||
selectedFilterRoles.add(name);
|
||||
}
|
||||
|
||||
updateDropdownCheckmarks();
|
||||
updateFilterPlaceholder();
|
||||
renderUsers();
|
||||
});
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
const params = new URLSearchParams();
|
||||
params.append("skip", (currentPage - 1) * pageSize);
|
||||
params.append("limit", pageSize);
|
||||
|
||||
showLoadingState();
|
||||
|
||||
Api.get(`/api/auth/users?${params.toString()}`)
|
||||
.then((data) => {
|
||||
users = data.users;
|
||||
totalUsers = data.total;
|
||||
$("#total-users-count").text(totalUsers);
|
||||
renderUsers();
|
||||
renderPagination();
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast("Не удалось загрузить пользователей", "error");
|
||||
});
|
||||
}
|
||||
|
||||
async function renderUsers() {
|
||||
const $container = $("#users-container");
|
||||
const tpl = document.getElementById("user-card-template");
|
||||
const emptyTpl = document.getElementById("empty-state-template");
|
||||
const roleBadgeTpl = document.getElementById("role-badge-template");
|
||||
|
||||
$container.empty();
|
||||
|
||||
let filteredUsers = users;
|
||||
|
||||
if (searchQuery) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
filteredUsers = filteredUsers.filter(
|
||||
(user) =>
|
||||
user.username.toLowerCase().includes(q) ||
|
||||
user.email.toLowerCase().includes(q) ||
|
||||
(user.full_name && user.full_name.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedFilterRoles.size > 0) {
|
||||
filteredUsers = filteredUsers.filter((user) => {
|
||||
if (!user.roles || user.roles.length === 0) return false;
|
||||
return Array.from(selectedFilterRoles).every((roleName) =>
|
||||
user.roles.includes(roleName),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredUsers.length === 0) {
|
||||
$container.append(emptyTpl.content.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUser = window.getUser();
|
||||
|
||||
for (const user of filteredUsers) {
|
||||
const clone = tpl.content.cloneNode(true);
|
||||
const card = clone.querySelector(".user-card");
|
||||
|
||||
card.dataset.id = user.id;
|
||||
clone.querySelector(".user-fullname").textContent =
|
||||
user.full_name || user.username;
|
||||
clone.querySelector(".user-username").textContent = "@" + user.username;
|
||||
clone.querySelector(".user-email").textContent = user.email;
|
||||
|
||||
const avatar = clone.querySelector(".user-avatar");
|
||||
Utils.getGravatarUrl(user.email).then((url) => {
|
||||
avatar.src = url;
|
||||
});
|
||||
|
||||
if (user.is_verified) {
|
||||
clone.querySelector(".user-verified-badge").classList.remove("hidden");
|
||||
}
|
||||
if (user.is_active) {
|
||||
clone.querySelector(".user-active-badge").classList.remove("hidden");
|
||||
} else {
|
||||
clone.querySelector(".user-inactive-badge").classList.remove("hidden");
|
||||
}
|
||||
|
||||
const rolesContainer = clone.querySelector(".user-roles");
|
||||
if (user.roles && user.roles.length > 0) {
|
||||
user.roles.forEach((roleName) => {
|
||||
const badge = roleBadgeTpl.content.cloneNode(true);
|
||||
const badgeSpan = badge.querySelector(".role-badge");
|
||||
|
||||
if (roleName === "admin") {
|
||||
badgeSpan.classList.remove("bg-gray-600");
|
||||
badgeSpan.classList.add("bg-red-600");
|
||||
} else if (roleName === "librarian") {
|
||||
badgeSpan.classList.remove("bg-gray-600");
|
||||
badgeSpan.classList.add("bg-blue-600");
|
||||
}
|
||||
|
||||
badge.querySelector(".role-name").textContent = roleName;
|
||||
const removeBtn = badge.querySelector(".remove-role-btn");
|
||||
removeBtn.dataset.userId = user.id;
|
||||
removeBtn.dataset.roleName = roleName;
|
||||
rolesContainer.appendChild(badge);
|
||||
});
|
||||
} else {
|
||||
rolesContainer.innerHTML =
|
||||
'<span class="text-gray-400 text-sm italic">Нет ролей</span>';
|
||||
}
|
||||
|
||||
const addRoleBtn = clone.querySelector(".add-role-btn");
|
||||
addRoleBtn.dataset.userId = user.id;
|
||||
|
||||
const editBtn = clone.querySelector(".edit-user-btn");
|
||||
editBtn.dataset.userId = user.id;
|
||||
|
||||
const deleteBtn = clone.querySelector(".delete-user-btn");
|
||||
deleteBtn.dataset.userId = user.id;
|
||||
|
||||
if (currentUser && currentUser.id === user.id) {
|
||||
deleteBtn.classList.add("opacity-30", "cursor-not-allowed");
|
||||
deleteBtn.disabled = true;
|
||||
deleteBtn.title = "Нельзя удалить себя";
|
||||
}
|
||||
|
||||
$container.append(clone);
|
||||
}
|
||||
}
|
||||
|
||||
function showLoadingState() {
|
||||
$("#users-container").html(`
|
||||
<div class="space-y-4">
|
||||
${Array(3)
|
||||
.fill()
|
||||
.map(
|
||||
() => `
|
||||
<div class="bg-white p-4 rounded-lg shadow-md animate-pulse">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-14 h-14 bg-gray-200 rounded-full"></div>
|
||||
<div class="flex-1">
|
||||
<div class="h-5 bg-gray-200 rounded w-1/4 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/3 mb-2"></div>
|
||||
<div class="h-4 bg-gray-200 rounded w-1/2 mb-3"></div>
|
||||
<div class="flex gap-2">
|
||||
<div class="h-6 bg-gray-200 rounded-full w-16"></div>
|
||||
<div class="h-6 bg-gray-200 rounded-full w-20"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
$("#pagination-container").empty();
|
||||
const totalPages = Math.ceil(totalUsers / pageSize);
|
||||
if (totalPages <= 1) return;
|
||||
|
||||
const $pagination = $(`
|
||||
<div class="flex justify-center items-center gap-2 mt-6 mb-4">
|
||||
<button id="prev-page" class="px-3 py-2 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition" ${currentPage === 1 ? "disabled" : ""}>←</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/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
)
|
||||
.then((updatedUser) => {
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex] = updatedUser;
|
||||
}
|
||||
renderUsers();
|
||||
Utils.showToast(`Роль "${roleName}" добавлена`, "success");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка добавления роли", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function removeRoleFromUser(userId, roleName) {
|
||||
const currentUser = window.getUser();
|
||||
|
||||
if (currentUser && currentUser.id === userId && roleName === "admin") {
|
||||
Utils.showToast("Нельзя удалить свою роль администратора", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
Api.request(
|
||||
`/api/auth/users/${userId}/roles/${encodeURIComponent(roleName)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
)
|
||||
.then((updatedUser) => {
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex] = updatedUser;
|
||||
}
|
||||
renderUsers();
|
||||
Utils.showToast(`Роль "${roleName}" удалена`, "success");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
Utils.showToast(error.message || "Ошибка удаления роли", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function openEditModal(userId) {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
$("#edit-user-id").val(user.id);
|
||||
$("#edit-user-email").val(user.email);
|
||||
$("#edit-user-fullname").val(user.full_name || "");
|
||||
$("#edit-user-password").val("");
|
||||
$("#edit-user-active").prop("checked", user.is_active);
|
||||
$("#edit-user-verified").prop("checked", user.is_verified);
|
||||
|
||||
$("#edit-user-modal").removeClass("hidden");
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
$("#edit-user-modal").addClass("hidden");
|
||||
$("#edit-user-form")[0].reset();
|
||||
}
|
||||
|
||||
function saveUserChanges() {
|
||||
const userId = parseInt($("#edit-user-id").val());
|
||||
const email = $("#edit-user-email").val().trim();
|
||||
const fullName = $("#edit-user-fullname").val().trim();
|
||||
const password = $("#edit-user-password").val();
|
||||
|
||||
if (!email) {
|
||||
Utils.showToast("Email обязателен", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
email: email,
|
||||
full_name: fullName || null,
|
||||
};
|
||||
|
||||
if (password) {
|
||||
updateData.password = password;
|
||||
}
|
||||
|
||||
// Note: This uses the /api/auth/me endpoint structure
|
||||
// For admin editing other users, you might need a different endpoint
|
||||
// Here we'll simulate by updating local data
|
||||
|
||||
Api.put(`/api/auth/me`, updateData)
|
||||
.then((updatedUser) => {
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex] = { ...users[userIndex], ...updatedUser };
|
||||
}
|
||||
renderUsers();
|
||||
closeEditModal();
|
||||
Utils.showToast("Пользователь обновлён", "success");
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("API update failed, updating locally:", error);
|
||||
const userIndex = users.findIndex((u) => u.id === userId);
|
||||
if (userIndex !== -1) {
|
||||
users[userIndex].email = email;
|
||||
users[userIndex].full_name = fullName || null;
|
||||
users[userIndex].is_active = $("#edit-user-active").prop("checked");
|
||||
users[userIndex].is_verified = $("#edit-user-verified").prop(
|
||||
"checked",
|
||||
);
|
||||
}
|
||||
renderUsers();
|
||||
closeEditModal();
|
||||
Utils.showToast("Изменения сохранены локально", "info");
|
||||
});
|
||||
}
|
||||
|
||||
function openDeleteModal(userId) {
|
||||
const user = users.find((u) => u.id === userId);
|
||||
if (!user) return;
|
||||
|
||||
const currentUser = window.getUser();
|
||||
if (currentUser && currentUser.id === userId) {
|
||||
Utils.showToast("Нельзя удалить себя", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
userToDelete = user;
|
||||
$("#delete-user-name").text(user.full_name || user.username);
|
||||
$("#delete-user-modal").removeClass("hidden");
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
$("#delete-user-modal").addClass("hidden");
|
||||
userToDelete = null;
|
||||
}
|
||||
|
||||
function confirmDeleteUser() {
|
||||
if (!userToDelete) return;
|
||||
|
||||
Utils.showToast("Удаление пользователей не поддерживается API", "error");
|
||||
closeDeleteModal();
|
||||
|
||||
// When API supports deletion:
|
||||
// Api.delete(`/api/auth/users/${userToDelete.id}`)
|
||||
// .then(() => {
|
||||
// users = users.filter(u => u.id !== userToDelete.id);
|
||||
// totalUsers--;
|
||||
// $("#total-users-count").text(totalUsers);
|
||||
// renderUsers();
|
||||
// closeDeleteModal();
|
||||
// Utils.showToast("Пользователь удалён", "success");
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// console.error(error);
|
||||
// Utils.showToast(error.message || "Ошибка удаления", "error");
|
||||
// });
|
||||
}
|
||||
|
||||
$("#users-container").on("click", ".add-role-btn", function (e) {
|
||||
e.stopPropagation();
|
||||
const userId = parseInt($(this).data("user-id"));
|
||||
showRoleDropdown(this, userId);
|
||||
});
|
||||
|
||||
$("#users-container").on("click", ".remove-role-btn", function (e) {
|
||||
e.stopPropagation();
|
||||
const userId = parseInt($(this).data("user-id"));
|
||||
const roleName = $(this).data("role-name");
|
||||
|
||||
const user = users.find((u) => u.id === userId);
|
||||
const userName = user ? user.full_name || user.username : "пользователя";
|
||||
|
||||
if (confirm(`Удалить роль "${roleName}" у ${userName}?`)) {
|
||||
removeRoleFromUser(userId, roleName);
|
||||
}
|
||||
});
|
||||
|
||||
$("#users-container").on("click", ".edit-user-btn", function (e) {
|
||||
e.stopPropagation();
|
||||
const userId = parseInt($(this).data("user-id"));
|
||||
openEditModal(userId);
|
||||
});
|
||||
|
||||
$("#edit-user-form").on("submit", function (e) {
|
||||
e.preventDefault();
|
||||
saveUserChanges();
|
||||
});
|
||||
|
||||
$("#cancel-edit-btn, #modal-backdrop").on("click", closeEditModal);
|
||||
|
||||
$("#users-container").on("click", ".delete-user-btn", function (e) {
|
||||
e.stopPropagation();
|
||||
if ($(this).prop("disabled")) return;
|
||||
const userId = parseInt($(this).data("user-id"));
|
||||
openDeleteModal(userId);
|
||||
});
|
||||
|
||||
$("#confirm-delete-btn").on("click", confirmDeleteUser);
|
||||
$("#cancel-delete-btn, #delete-modal-backdrop").on("click", closeDeleteModal);
|
||||
|
||||
$(document).on("click", function (e) {
|
||||
if (!$(e.target).closest(".role-add-dropdown, .add-role-btn").length) {
|
||||
closeActiveDropdown();
|
||||
}
|
||||
});
|
||||
|
||||
let searchTimeout;
|
||||
$("#user-search-input").on("input", function () {
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
searchQuery = $(this).val().trim();
|
||||
renderUsers();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
$("#user-search-input").on("keypress", function (e) {
|
||||
if (e.which === 13) {
|
||||
clearTimeout(searchTimeout);
|
||||
searchQuery = $(this).val().trim();
|
||||
renderUsers();
|
||||
}
|
||||
});
|
||||
|
||||
$("#reset-filters-btn").on("click", function () {
|
||||
$("#user-search-input").val("");
|
||||
$("#role-filter-input").val("");
|
||||
searchQuery = "";
|
||||
selectedFilterRoles.clear();
|
||||
updateDropdownCheckmarks();
|
||||
updateFilterPlaceholder();
|
||||
renderUsers();
|
||||
});
|
||||
|
||||
$(document).on("keydown", function (e) {
|
||||
if (e.key === "Escape") {
|
||||
closeEditModal();
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
const Utils = {
|
||||
escapeHtml: (text) => {
|
||||
if (!text) return "";
|
||||
return 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 = localStorage.getItem("access_token");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config = { ...options, headers };
|
||||
|
||||
try {
|
||||
const response = await fetch(fullUrl, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
const refreshed = await Auth.tryRefresh();
|
||||
if (refreshed) {
|
||||
headers["Authorization"] =
|
||||
`Bearer ${localStorage.getItem("access_token")}`;
|
||||
const retryResponse = await fetch(fullUrl, { ...options, headers });
|
||||
if (retryResponse.ok) {
|
||||
return retryResponse.json();
|
||||
}
|
||||
}
|
||||
Auth.logout();
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `Error ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
get(endpoint) {
|
||||
return this.request(endpoint, { method: "GET" });
|
||||
},
|
||||
|
||||
post(endpoint, body) {
|
||||
return this.request(endpoint, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
put(endpoint, body) {
|
||||
return this.request(endpoint, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
},
|
||||
|
||||
delete(endpoint) {
|
||||
return this.request(endpoint, { method: "DELETE" });
|
||||
},
|
||||
|
||||
postForm(endpoint, formData) {
|
||||
return this.request(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const Auth = {
|
||||
logout: () => {
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
window.location.href = "/";
|
||||
},
|
||||
|
||||
tryRefresh: async () => {
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
if (!refreshToken) return false;
|
||||
|
||||
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();
|
||||
localStorage.setItem("access_token", data.access_token);
|
||||
localStorage.setItem("refresh_token", data.refresh_token);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Refresh failed:", e);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
init: async () => {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const refreshToken = localStorage.getItem("refresh_token");
|
||||
|
||||
if (!token && !refreshToken) {
|
||||
localStorage.removeItem("user");
|
||||
return null;
|
||||
}
|
||||
|
||||
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 " + localStorage.getItem("access_token"),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
localStorage.setItem("user", JSON.stringify(user));
|
||||
document.dispatchEvent(new CustomEvent("auth:login", { detail: user }));
|
||||
return user;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Auth check failed", e);
|
||||
}
|
||||
|
||||
localStorage.removeItem("access_token");
|
||||
localStorage.removeItem("refresh_token");
|
||||
localStorage.removeItem("user");
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
window.getUser = function () {
|
||||
const userJson = localStorage.getItem("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())
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,6 @@ block content %}
|
||||
Регистрация
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="login-form" class="p-6">
|
||||
<div class="mb-4">
|
||||
<label
|
||||
|
||||
@@ -1,16 +1,119 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Автор{% endblock %} {% block
|
||||
content %}
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<main class="flex-1 max-w-4xl mx-auto">
|
||||
<div id="author-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
{% 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="books-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-4">Книги автора</h2>
|
||||
<div id="books-container">
|
||||
<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>
|
||||
</main>
|
||||
<div id="author-content" class="hidden flex items-start">
|
||||
<div
|
||||
id="author-avatar"
|
||||
class="w-24 h-24 bg-gray-500 text-white rounded-full flex items-center justify-center text-4xl font-bold mr-6 flex-shrink-0"
|
||||
></div>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h1
|
||||
id="author-name"
|
||||
class="text-3xl font-bold text-gray-900"
|
||||
></h1>
|
||||
<span id="author-id" class="text-sm text-gray-500"></span>
|
||||
</div>
|
||||
<div class="flex items-center text-gray-600 mb-4">
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
></path>
|
||||
</svg>
|
||||
<span id="author-books-count"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="books-section">
|
||||
<h2 class="text-xl font-bold mb-4">Книги автора</h2>
|
||||
<div id="books-container" class="space-y-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
<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 type="text/javascript" src="/static/author.js"></script>
|
||||
<script src="/static/author.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,20 +1,20 @@
|
||||
{% 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 %}
|
||||
<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-xl font-semibold mb-4">Поиск</h2>
|
||||
<div class="relative mb-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">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="author-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="Поиск авторов..."
|
||||
maxlength="50"
|
||||
placeholder="Поиск автора..."
|
||||
class="border rounded-lg pl-3 pr-10 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 w-full sm:w-64"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
class="absolute right-3 top-2.5 h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -24,49 +24,100 @@ 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">
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<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="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
||||
class="hidden peer"
|
||||
/>
|
||||
<span class="ml-2 text-gray-700">По имени (А-Я)</span>
|
||||
<span
|
||||
class="text-sm text-gray-600 peer-checked:text-black peer-checked:font-bold"
|
||||
>А-Я</span
|
||||
>
|
||||
</label>
|
||||
<label class="flex items-center cursor-pointer">
|
||||
<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="w-4 h-4 text-gray-500 focus:ring-gray-500"
|
||||
class="hidden peer"
|
||||
/>
|
||||
<span class="ml-2 text-gray-700">По имени (Я-А)</span>
|
||||
<span
|
||||
class="text-sm text-gray-600 peer-checked:text-black peer-checked:font-bold"
|
||||
>Я-А</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
id="reset-filters-btn"
|
||||
class="w-full bg-white text-gray-500 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition duration-200"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
<div
|
||||
id="results-counter"
|
||||
class="mt-4 text-center text-sm text-gray-500"
|
||||
></div>
|
||||
</aside>
|
||||
<main class="flex-1">
|
||||
<div id="authors-container"></div>
|
||||
<div id="pagination-container"></div>
|
||||
</main>
|
||||
</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 type="text/javascript" src="/static/authors.js"></script>
|
||||
<script src="/static/authors.js"></script>
|
||||
{% endblock %}
|
||||
@@ -4,14 +4,30 @@
|
||||
<title>{% block title %}LiB{% endblock %}</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script async="" src="https://cdnjs.cloudflare.com/ajax/libs/js-sha256/0.11.0/sha256.min.js"></script>
|
||||
<script
|
||||
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">
|
||||
<body
|
||||
class="flex flex-col min-h-screen bg-gray-100"
|
||||
x-data="{
|
||||
user: null,
|
||||
async init() {
|
||||
document.addEventListener('auth:login', async (e) => {
|
||||
this.user = e.detail;
|
||||
this.user.avatar = await Utils.getGravatarUrl(this.user.email);
|
||||
});
|
||||
await Auth.init();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<header class="bg-gray-600 text-white p-4 shadow-md">
|
||||
<div class="mx-auto pl-5 pr-3 flex justify-between items-center">
|
||||
<a class="flex gap-4 items-center max-w-10 h-auto" href="/">
|
||||
<img class="invert" src="/static/logo.svg" />
|
||||
@@ -19,60 +35,191 @@
|
||||
</a>
|
||||
<nav>
|
||||
<ul class="flex space-x-4">
|
||||
<li><a href="/" class="hover:text-gray-200">Главная</a></li>
|
||||
<li><a href="/books" class="hover:text-gray-200">Книги</a></li>
|
||||
<li><a href="/authors" 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">
|
||||
<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
|
||||
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"
|
||||
: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 id="user-arrow" class="w-4 h-4 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="user-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden">
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 z-50 overflow-hidden text-gray-900"
|
||||
style="display: none"
|
||||
>
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<p 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')"
|
||||
>
|
||||
<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>
|
||||
</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>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
<main class="flex-grow">{% block content %}{% endblock %}</main>
|
||||
<div
|
||||
id="toast-container"
|
||||
class="fixed bottom-5 right-5 flex flex-col gap-2 z-50"
|
||||
></div>
|
||||
|
||||
<footer class="bg-gray-800 text-white p-4 mt-8">
|
||||
<div class="container mx-auto text-center">
|
||||
<p>© 2025 My Awesome Library. All rights reserved.</p>
|
||||
<p>© 2025 LiB Library. All rights reserved.</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,21 +1,150 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Книга{% endblock %} {% block
|
||||
content %}
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<main class="flex-1 max-w-4xl mx-auto">
|
||||
<div id="book-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<div class="container mx-auto p-4 max-w-4xl">
|
||||
<div id="book-card" class="bg-white rounded-lg shadow-md p-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="authors-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Авторы</h2>
|
||||
<div id="authors-container">
|
||||
<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
|
||||
class="w-40 h-56 bg-gradient-to-br from-gray-400 to-gray-600 rounded-lg flex items-center justify-center shadow-md mb-4"
|
||||
>
|
||||
<svg
|
||||
class="w-20 h-20 text-white opacity-80"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
id="book-status-container"
|
||||
class="relative w-full flex justify-center z-10"
|
||||
></div>
|
||||
<div id="book-actions-container" class="mt-4 w-full"></div>
|
||||
</div>
|
||||
<div class="flex-1 w-full">
|
||||
<div
|
||||
class="flex flex-col md:flex-row md:items-start md:justify-between mb-2"
|
||||
>
|
||||
<h1
|
||||
id="book-title"
|
||||
class="text-3xl font-bold text-gray-900 leading-tight"
|
||||
></h1>
|
||||
<span
|
||||
id="book-id"
|
||||
class="hidden md:inline-block text-xs font-mono text-gray-400 mt-2 md:mt-0 md:ml-4"
|
||||
></span>
|
||||
</div>
|
||||
<p
|
||||
id="book-authors-text"
|
||||
class="text-lg text-gray-600 font-medium mb-6"
|
||||
></p>
|
||||
|
||||
<div class="prose prose-gray max-w-none mb-8">
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Описание
|
||||
</h3>
|
||||
<p
|
||||
id="book-description"
|
||||
class="text-gray-700 leading-relaxed"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<div id="genres-section" class="mb-6 hidden">
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Жанры
|
||||
</h3>
|
||||
<div
|
||||
id="genres-container"
|
||||
class="flex flex-wrap gap-2"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div id="authors-section" class="mb-6 hidden">
|
||||
<h3
|
||||
class="text-sm font-bold text-gray-400 uppercase tracking-wider mb-2"
|
||||
>
|
||||
Авторы (детально)
|
||||
</h3>
|
||||
<div
|
||||
id="authors-container"
|
||||
class="flex flex-wrap gap-3"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="genres-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-4">Жанры</h2>
|
||||
<div id="genres-container">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/book.js"></script>
|
||||
<script src="/static/book.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,21 +1,79 @@
|
||||
{% 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 %}
|
||||
<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 +83,108 @@ 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="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-bold mb-4">Авторы</h2>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 p-2 border border-gray-300 rounded-md bg-white min-h-[42px]"
|
||||
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-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/books.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %} {% block title %}Создание автора | LiB{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container mx-auto p-4 max-w-xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
<h1
|
||||
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Добавить автора
|
||||
</h1>
|
||||
<p class="text-gray-500 mt-2 text-sm ml-11">
|
||||
Укажите имя нового автора для каталога.
|
||||
</p>
|
||||
</div>
|
||||
<form id="create-author-form" class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="author-name"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Имя автора <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="author-name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||
placeholder="Имя или псевдоним автора"
|
||||
/>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="name-counter" class="text-xs text-gray-400"
|
||||
>0/255</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span id="submit-text">Создать автора</span>
|
||||
<svg
|
||||
id="loading-spinner"
|
||||
class="hidden animate-spin ml-2 h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
href="/authors"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="success-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Автор успешно добавлен!
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Автор
|
||||
<span
|
||||
id="modal-author-name"
|
||||
class="font-bold text-gray-800"
|
||||
></span>
|
||||
сохранён в каталоге.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<a
|
||||
id="modal-link-btn"
|
||||
href="/authors"
|
||||
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
К списку авторов
|
||||
</a>
|
||||
<button
|
||||
id="modal-close-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Создать ещё
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/create_author.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %} {% block title %}Создание книги | LiB{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto p-4 max-w-3xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
<h1
|
||||
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
|
||||
/>
|
||||
</svg>
|
||||
Добавить новую книгу
|
||||
</h1>
|
||||
<p class="text-gray-500 mt-2 text-sm ml-11">
|
||||
Заполните информацию о книге, укажите авторов и жанры.
|
||||
</p>
|
||||
</div>
|
||||
<form id="create-book-form" class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="book-title"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Название книги <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="book-title"
|
||||
name="title"
|
||||
required
|
||||
maxlength="255"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||
placeholder="Название книги..."
|
||||
/>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="title-counter" class="text-xs text-gray-400"
|
||||
>0/255</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="book-description"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="book-description"
|
||||
name="description"
|
||||
rows="5"
|
||||
maxlength="2000"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none transition"
|
||||
placeholder="Краткое описание сюжета..."
|
||||
></textarea>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="desc-counter" class="text-xs text-gray-400"
|
||||
>0/2000</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">
|
||||
Авторы
|
||||
</h2>
|
||||
<div
|
||||
id="selected-authors-container"
|
||||
class="flex flex-wrap gap-2 mb-3 min-h-[0px]"
|
||||
></div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="author-search-input"
|
||||
placeholder="Поиск автора..."
|
||||
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div
|
||||
id="author-dropdown"
|
||||
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">
|
||||
Жанры
|
||||
</h2>
|
||||
<div
|
||||
id="selected-genres-container"
|
||||
class="flex flex-wrap gap-2 mb-3 min-h-[0px]"
|
||||
></div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="genre-search-input"
|
||||
placeholder="Поиск жанра..."
|
||||
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div
|
||||
id="genre-dropdown"
|
||||
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span id="submit-text">Создать книгу</span>
|
||||
<svg
|
||||
id="loading-spinner"
|
||||
class="hidden animate-spin ml-2 h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
href="/books"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="success-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Книга успешно добавлена!
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Книга
|
||||
<span
|
||||
id="modal-book-title"
|
||||
class="font-bold text-gray-800"
|
||||
></span>
|
||||
сохранена в каталоге.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<a
|
||||
id="modal-link-btn"
|
||||
href="#"
|
||||
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Перейти к книге
|
||||
</a>
|
||||
<button
|
||||
id="modal-close-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Создать ещё
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/create_book.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,162 @@
|
||||
{% extends "base.html" %} {% block title %}Создание жанра | LiB{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto p-4 max-w-xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
<h1
|
||||
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
/>
|
||||
</svg>
|
||||
Добавить жанр
|
||||
</h1>
|
||||
<p class="text-gray-500 mt-2 text-sm ml-11">
|
||||
Укажите название нового жанра для каталога.
|
||||
</p>
|
||||
</div>
|
||||
<form id="create-genre-form" class="space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="genre-name"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Название жанра <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="genre-name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="100"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||
placeholder="Например: Научная фантастика"
|
||||
/>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="name-counter" class="text-xs text-gray-400"
|
||||
>0/100</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span id="submit-text">Создать жанр</span>
|
||||
<svg
|
||||
id="loading-spinner"
|
||||
class="hidden animate-spin ml-2 h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
href="/books"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="success-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Жанр успешно добавлен!
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Жанр
|
||||
<span
|
||||
id="modal-genre-name"
|
||||
class="font-bold text-gray-800"
|
||||
></span>
|
||||
сохранён в каталоге.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<a
|
||||
id="modal-link-btn"
|
||||
href="/books"
|
||||
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
К списку книг
|
||||
</a>
|
||||
<button
|
||||
id="modal-close-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Создать ещё
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/create_genre.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,316 @@
|
||||
{% extends "base.html" %} {% block title %}Редактирование автора | LiB{%
|
||||
endblock %} {% block content %}
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
<h1
|
||||
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Редактирование автора</span>
|
||||
</h1>
|
||||
<p class="text-gray-500 mt-2 text-sm ml-11">
|
||||
Измените данные автора или удалите его из каталога.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="loader" class="animate-pulse space-y-4">
|
||||
<div class="h-12 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-24 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
|
||||
<form id="edit-author-form" class="hidden space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="author-name"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Имя автора <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="author-name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="255"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||
placeholder="Имя автора..."
|
||||
/>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="name-counter" class="text-xs text-gray-400"
|
||||
>0/255</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h2
|
||||
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
|
||||
>
|
||||
<span>Книги автора</span>
|
||||
<span
|
||||
id="books-count"
|
||||
class="text-xs text-gray-400 font-normal"
|
||||
></span>
|
||||
</h2>
|
||||
<div
|
||||
id="author-books-container"
|
||||
class="space-y-2 max-h-64 overflow-y-auto"
|
||||
>
|
||||
<div class="text-sm text-gray-500 text-center py-4">
|
||||
Загрузка...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<span id="submit-text">Сохранить изменения</span>
|
||||
<svg
|
||||
id="loading-spinner"
|
||||
class="hidden animate-spin ml-2 h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
id="cancel-btn"
|
||||
href="/authors"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
|
||||
<h3
|
||||
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
Опасная зона
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Удаление автора необратимо. Связи с книгами будут удалены, но
|
||||
сами книги сохранятся.
|
||||
</p>
|
||||
<button
|
||||
id="delete-btn"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path>
|
||||
</svg>
|
||||
Удалить автора
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="delete-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Удалить автора?
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Вы уверены, что хотите удалить автора
|
||||
<span
|
||||
id="modal-author-name"
|
||||
class="font-bold text-gray-800"
|
||||
></span
|
||||
>? Это действие нельзя отменить.
|
||||
</p>
|
||||
<p
|
||||
id="modal-books-warning"
|
||||
class="hidden text-sm text-orange-600 mt-2"
|
||||
>
|
||||
У автора есть связанные книги. Связи будут удалены.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<button
|
||||
id="confirm-delete-btn"
|
||||
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
|
||||
>
|
||||
<span>Удалить</span>
|
||||
<svg
|
||||
id="delete-spinner"
|
||||
class="hidden animate-spin ml-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="cancel-delete-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
id="success-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Изменения сохранены!
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Автор
|
||||
<span
|
||||
id="success-author-name"
|
||||
class="font-bold text-gray-800"
|
||||
></span>
|
||||
успешно обновлён.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<a
|
||||
id="success-link-btn"
|
||||
href="/authors"
|
||||
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
К списку авторов
|
||||
</a>
|
||||
<button
|
||||
id="success-close-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/edit_author.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,394 @@
|
||||
{% extends "base.html" %} {% block title %}Редактирование книги | LiB{% endblock
|
||||
%} {% block content %}
|
||||
<div class="container mx-auto p-4 max-w-3xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
<h1
|
||||
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Редактирование книги</span>
|
||||
</h1>
|
||||
<p class="text-gray-500 mt-2 text-sm ml-11">
|
||||
Измените информацию о книге, управляйте авторами и жанрами.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="loader" class="animate-pulse space-y-4">
|
||||
<div class="h-12 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-32 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-12 bg-gray-200 rounded w-1/3"></div>
|
||||
</div>
|
||||
|
||||
<form id="edit-book-form" class="hidden space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="book-title"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Название книги <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="book-title"
|
||||
name="title"
|
||||
required
|
||||
maxlength="255"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||
placeholder="Название книги..."
|
||||
/>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="title-counter" class="text-xs text-gray-400"
|
||||
>0/255</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="book-description"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="book-description"
|
||||
name="description"
|
||||
rows="5"
|
||||
maxlength="2000"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 resize-none transition"
|
||||
placeholder="Краткое описание сюжета..."
|
||||
></textarea>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="desc-counter" class="text-xs text-gray-400"
|
||||
>0/2000</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="book-status"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Статус
|
||||
</label>
|
||||
<select
|
||||
id="book-status"
|
||||
name="status"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition bg-white"
|
||||
>
|
||||
<option value="active">Доступна</option>
|
||||
<option value="borrowed">Выдана</option>
|
||||
<option value="reserved">Забронирована</option>
|
||||
<option value="restoration">На реставрации</option>
|
||||
<option value="written_off">Списана</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h2
|
||||
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
|
||||
>
|
||||
<span>Авторы</span>
|
||||
<span
|
||||
id="authors-count"
|
||||
class="text-xs text-gray-400 font-normal"
|
||||
></span>
|
||||
</h2>
|
||||
<div
|
||||
id="current-authors-container"
|
||||
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
|
||||
></div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="author-search-input"
|
||||
placeholder="Добавить автора..."
|
||||
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div
|
||||
id="author-dropdown"
|
||||
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h2
|
||||
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
|
||||
>
|
||||
<span>Жанры</span>
|
||||
<span
|
||||
id="genres-count"
|
||||
class="text-xs text-gray-400 font-normal"
|
||||
></span>
|
||||
</h2>
|
||||
<div
|
||||
id="current-genres-container"
|
||||
class="flex flex-wrap gap-2 mb-3 min-h-[32px]"
|
||||
></div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="genre-search-input"
|
||||
placeholder="Добавить жанр..."
|
||||
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div
|
||||
id="genre-dropdown"
|
||||
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<span id="submit-text">Сохранить изменения</span>
|
||||
<svg
|
||||
id="loading-spinner"
|
||||
class="hidden animate-spin ml-2 h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
id="cancel-btn"
|
||||
href="#"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
|
||||
<h3
|
||||
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
Опасная зона
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Удаление книги необратимо. Все связи с авторами и жанрами будут
|
||||
удалены.
|
||||
</p>
|
||||
<button
|
||||
id="delete-btn"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path>
|
||||
</svg>
|
||||
Удалить книгу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="delete-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Удалить книгу?
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Вы уверены, что хотите удалить книгу
|
||||
<span
|
||||
id="modal-book-title"
|
||||
class="font-bold text-gray-800"
|
||||
></span
|
||||
>? Это действие нельзя отменить.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<button
|
||||
id="confirm-delete-btn"
|
||||
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
|
||||
>
|
||||
<span>Удалить</span>
|
||||
<svg
|
||||
id="delete-spinner"
|
||||
class="hidden animate-spin ml-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="cancel-delete-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="success-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Изменения сохранены!
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Книга
|
||||
<span
|
||||
id="success-book-title"
|
||||
class="font-bold text-gray-800"
|
||||
></span>
|
||||
успешно обновлена.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<a
|
||||
id="success-link-btn"
|
||||
href="#"
|
||||
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
Перейти к книге
|
||||
</a>
|
||||
<button
|
||||
id="success-close-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/edit_book.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,317 @@
|
||||
{% extends "base.html" %} {% block title %}Редактирование жанра | LiB{% endblock
|
||||
%} {% block content %}
|
||||
<div class="container mx-auto p-4 max-w-2xl">
|
||||
<div class="bg-white rounded-lg shadow-md p-6 md:p-8">
|
||||
<div class="mb-8 border-b border-gray-100 pb-4">
|
||||
<h1
|
||||
class="text-2xl font-bold text-gray-800 flex items-center gap-3"
|
||||
>
|
||||
<svg
|
||||
class="w-8 h-8 text-gray-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Редактирование жанра</span>
|
||||
</h1>
|
||||
<p class="text-gray-500 mt-2 text-sm ml-11">
|
||||
Измените данные жанра или удалите его из каталога.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="loader" class="animate-pulse space-y-4">
|
||||
<div class="h-12 bg-gray-200 rounded w-full"></div>
|
||||
<div class="h-24 bg-gray-200 rounded w-full"></div>
|
||||
</div>
|
||||
|
||||
<form id="edit-genre-form" class="hidden space-y-6">
|
||||
<div>
|
||||
<label
|
||||
for="genre-name"
|
||||
class="block text-sm font-semibold text-gray-700 mb-2"
|
||||
>
|
||||
Название жанра <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="genre-name"
|
||||
name="name"
|
||||
required
|
||||
maxlength="100"
|
||||
class="w-full border border-gray-300 rounded-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||
placeholder="Название жанра..."
|
||||
/>
|
||||
<div class="flex justify-end mt-1">
|
||||
<span id="name-counter" class="text-xs text-gray-400"
|
||||
>0/100</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-200">
|
||||
<h2
|
||||
class="text-sm font-semibold text-gray-700 mb-3 flex items-center justify-between"
|
||||
>
|
||||
<span>Книги в жанре</span>
|
||||
<span
|
||||
id="books-count"
|
||||
class="text-xs text-gray-400 font-normal"
|
||||
></span>
|
||||
</h2>
|
||||
<div
|
||||
id="genre-books-container"
|
||||
class="space-y-2 max-h-64 overflow-y-auto"
|
||||
>
|
||||
<div class="text-sm text-gray-500 text-center py-4">
|
||||
Загрузка...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col sm:flex-row gap-3 pt-6 border-t border-gray-100"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
id="submit-btn"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-green-600 text-white font-bold rounded-lg hover:bg-green-700 focus:outline-none focus:ring-4 focus:ring-green-300 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<span id="submit-text">Сохранить изменения</span>
|
||||
<svg
|
||||
id="loading-spinner"
|
||||
class="hidden animate-spin ml-2 h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<a
|
||||
id="cancel-btn"
|
||||
href="/"
|
||||
class="flex-1 flex justify-center items-center px-6 py-3 bg-white border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition text-center"
|
||||
>
|
||||
Отмена
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="danger-zone" class="hidden mt-8 pt-6 border-t border-red-200">
|
||||
<h3
|
||||
class="text-lg font-bold text-red-600 mb-2 flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
Опасная зона
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-4">
|
||||
Удаление жанра необратимо. Связи с книгами будут удалены, но
|
||||
сами книги сохранятся.
|
||||
</p>
|
||||
<button
|
||||
id="delete-btn"
|
||||
class="inline-flex items-center px-4 py-2 bg-white border border-red-300 text-red-600 font-medium rounded-lg hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-300 transition"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path>
|
||||
</svg>
|
||||
Удалить жанр
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="delete-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-red-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Удалить жанр?
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Вы уверены, что хотите удалить жанр
|
||||
<span
|
||||
id="modal-genre-name"
|
||||
class="font-bold text-gray-800"
|
||||
></span
|
||||
>? Это действие нельзя отменить.
|
||||
</p>
|
||||
<p
|
||||
id="modal-books-warning"
|
||||
class="hidden text-sm text-orange-600 mt-2"
|
||||
>
|
||||
В этом жанре есть книги. Связи будут удалены.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<button
|
||||
id="confirm-delete-btn"
|
||||
class="px-4 py-2 bg-red-600 text-white text-base font-medium rounded-lg hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 flex items-center"
|
||||
>
|
||||
<span>Удалить</span>
|
||||
<svg
|
||||
id="delete-spinner"
|
||||
class="hidden animate-spin ml-2 h-4 w-4 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
id="cancel-delete-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="success-modal"
|
||||
class="hidden fixed inset-0 bg-gray-900 bg-opacity-60 overflow-y-auto h-full w-full z-50 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="relative p-5 border w-full max-w-md shadow-lg rounded-lg bg-white"
|
||||
>
|
||||
<div class="mt-3 text-center">
|
||||
<div
|
||||
class="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100 mb-4"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-green-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||
Изменения сохранены!
|
||||
</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
Жанр
|
||||
<span
|
||||
id="success-genre-name"
|
||||
class="font-bold text-gray-800"
|
||||
></span>
|
||||
успешно обновлён.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-4 justify-center">
|
||||
<a
|
||||
id="success-link-btn"
|
||||
href="/"
|
||||
class="px-4 py-2 bg-gray-600 text-white text-base font-medium rounded-lg hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
На главную
|
||||
</a>
|
||||
<button
|
||||
id="success-close-btn"
|
||||
class="px-4 py-2 bg-white text-gray-700 border border-gray-300 text-base font-medium rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-200"
|
||||
>
|
||||
Продолжить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/edit_genre.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,69 +1,159 @@
|
||||
{% extends "base.html" %} {% block title %}LiB - Профиль{% endblock %} {% block
|
||||
content %}
|
||||
|
||||
<div class="flex flex-1 mt-4 p-4">
|
||||
<main class="flex-1 max-w-2xl mx-auto">
|
||||
<div id="profile-card" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
</div>
|
||||
<div id="account-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Информация об аккаунте</h2>
|
||||
<div id="account-container">
|
||||
{% extends "base.html" %} {% block content %}
|
||||
<div
|
||||
class="container mx-auto p-4 max-w-2xl"
|
||||
x-data="{ showPasswordModal: false }"
|
||||
@close-modal.window="showPasswordModal = false"
|
||||
>
|
||||
<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="roles-section" class="bg-white p-6 rounded-lg shadow-md mb-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Роли и права</h2>
|
||||
<div id="roles-container">
|
||||
<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 id="actions-section" class="bg-white p-6 rounded-lg shadow-md">
|
||||
<h2 class="text-xl font-semibold mb-4">Действия</h2>
|
||||
<div id="actions-container">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<div id="password-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md mx-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xl font-semibold">Смена пароля</h3>
|
||||
<button id="close-password-modal" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" 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"/>
|
||||
<div class="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="space-y-3">
|
||||
<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"
|
||||
>
|
||||
<span class="text-gray-700 font-medium">Сменить пароль</span>
|
||||
<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"
|
||||
>
|
||||
<span class="text-red-700 font-medium">Выйти из аккаунта</span>
|
||||
<svg
|
||||
class="w-5 h-5 text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<form id="password-form">
|
||||
</div>
|
||||
<div
|
||||
x-show="showPasswordModal"
|
||||
class="fixed inset-0 z-50 overflow-y-auto"
|
||||
style="display: none"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0"
|
||||
>
|
||||
<div
|
||||
x-show="showPasswordModal"
|
||||
x-transition.opacity
|
||||
class="fixed inset-0 transition-opacity"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gray-500 opacity-75"
|
||||
@click="showPasswordModal = false"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
x-show="showPasswordModal"
|
||||
x-transition.scale
|
||||
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">
|
||||
<h3
|
||||
class="text-lg leading-6 font-medium text-gray-900 mb-4"
|
||||
>
|
||||
Смена пароля
|
||||
</h3>
|
||||
<form id="change-password-form">
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Текущий пароль</label>
|
||||
<input type="password" id="current-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
required minlength="6">
|
||||
<label
|
||||
class="block text-gray-700 text-sm font-bold mb-2"
|
||||
>Текущий пароль</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="current-password"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Новый пароль</label>
|
||||
<input type="password" id="new-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
required minlength="6">
|
||||
<label
|
||||
class="block text-gray-700 text-sm font-bold mb-2"
|
||||
>Новый пароль</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="new-password"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-gray-700 text-sm font-medium mb-2">Подтвердите новый пароль</label>
|
||||
<input type="password" id="confirm-password"
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-gray-500"
|
||||
required minlength="6">
|
||||
</div>
|
||||
<div id="password-error" class="mb-4 text-red-600 text-sm hidden"></div>
|
||||
<div class="flex gap-3">
|
||||
<button type="submit" class="flex-1 bg-gray-500 text-white py-2 px-4 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
Сменить пароль
|
||||
</button>
|
||||
<button type="button" id="cancel-password" class="flex-1 bg-white text-gray-700 py-2 px-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors">
|
||||
Отмена
|
||||
</button>
|
||||
<div class="mb-4">
|
||||
<label
|
||||
class="block text-gray-700 text-sm font-bold mb-2"
|
||||
>Подтвердите пароль</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="confirm-password"
|
||||
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="submit-password-btn"
|
||||
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-base font-medium text-white hover:bg-gray-700 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Сменить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="showPasswordModal = false"
|
||||
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script type="text/javascript" src="/static/profile.js"></script>
|
||||
<script src="/static/profile.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,433 @@
|
||||
{% extends "base.html" %} {% block title %}Пользователи - LiB{% endblock %} {%
|
||||
block content %}
|
||||
<div class="container mx-auto p-4">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-800">
|
||||
Управление пользователями
|
||||
</h1>
|
||||
<div class="text-sm text-gray-500">
|
||||
Всего: <span id="total-users-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white p-4 rounded-lg shadow-md mb-6">
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
id="user-search-input"
|
||||
placeholder="Поиск по имени, username или email..."
|
||||
class="w-full border rounded-lg pl-10 pr-4 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400 transition"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-3 top-2.5 h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="role-filter-input"
|
||||
placeholder="Фильтр по роли..."
|
||||
class="w-full md:w-56 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div
|
||||
id="role-filter-dropdown"
|
||||
class="hidden absolute z-50 w-full bg-white border border-gray-200 rounded-lg shadow-lg max-h-48 overflow-y-auto mt-1"
|
||||
></div>
|
||||
</div>
|
||||
<button
|
||||
id="reset-filters-btn"
|
||||
class="px-4 py-2 bg-white text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="users-container" class="space-y-4"></div>
|
||||
|
||||
<div id="pagination-container"></div>
|
||||
</div>
|
||||
|
||||
<template id="user-card-template">
|
||||
<div
|
||||
class="bg-white p-4 rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 user-card"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<img
|
||||
class="user-avatar w-14 h-14 rounded-full border-2 border-gray-200 object-cover bg-gray-100"
|
||||
src=""
|
||||
alt=""
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:justify-between sm:items-start gap-2"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3
|
||||
class="user-fullname text-lg font-bold text-gray-900 truncate"
|
||||
></h3>
|
||||
<span
|
||||
class="user-verified-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3 mr-1"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
Подтвержден
|
||||
</span>
|
||||
</div>
|
||||
<p class="user-username text-sm text-gray-500"></p>
|
||||
<p
|
||||
class="user-email text-sm text-gray-600 truncate"
|
||||
></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="user-active-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800"
|
||||
>
|
||||
Активен
|
||||
</span>
|
||||
<span
|
||||
class="user-inactive-badge hidden inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800"
|
||||
>
|
||||
Неактивен
|
||||
</span>
|
||||
<button
|
||||
class="edit-user-btn p-2 text-gray-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="delete-user-btn p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<span class="text-sm font-medium text-gray-700"
|
||||
>Роли:</span
|
||||
>
|
||||
<div class="user-roles flex flex-wrap gap-1"></div>
|
||||
<div class="relative inline-block">
|
||||
<button
|
||||
class="add-role-btn p-1 text-gray-400 hover:text-green-600 hover:bg-green-50 rounded transition-colors"
|
||||
title="Добавить роль"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="role-badge-template">
|
||||
<span
|
||||
class="role-badge inline-flex items-center bg-gray-600 text-white text-xs font-medium px-2.5 py-1 rounded-full"
|
||||
>
|
||||
<span class="role-name"></span>
|
||||
<button
|
||||
type="button"
|
||||
class="remove-role-btn ml-1.5 inline-flex items-center justify-center text-gray-300 hover:text-white hover:bg-red-500 rounded-full w-4 h-4 transition-colors"
|
||||
title="Удалить роль"
|
||||
>
|
||||
<svg
|
||||
class="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template id="empty-state-template">
|
||||
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-gray-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">
|
||||
Пользователи не найдены
|
||||
</h3>
|
||||
<p class="text-gray-500">Попробуйте изменить параметры поиска</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="access-denied-template">
|
||||
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
||||
<svg
|
||||
class="mx-auto h-12 w-12 text-red-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">Доступ запрещён</h3>
|
||||
<p class="text-gray-500">У вас нет прав для просмотра этой страницы</p>
|
||||
<a
|
||||
href="/"
|
||||
class="inline-block mt-4 px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition"
|
||||
>
|
||||
На главную
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="edit-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
id="modal-backdrop"
|
||||
></div>
|
||||
<div
|
||||
class="relative inline-block bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full"
|
||||
>
|
||||
<form id="edit-user-form">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||
Редактирование пользователя
|
||||
</h3>
|
||||
<input type="hidden" id="edit-user-id" />
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Email</label
|
||||
>
|
||||
<input
|
||||
type="email"
|
||||
id="edit-user-email"
|
||||
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Полное имя</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="edit-user-fullname"
|
||||
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Новый пароль (оставьте пустым, чтобы не
|
||||
менять)</label
|
||||
>
|
||||
<input
|
||||
type="password"
|
||||
id="edit-user-password"
|
||||
class="w-full border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-gray-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-user-active"
|
||||
class="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700"
|
||||
>Активен</span
|
||||
>
|
||||
</label>
|
||||
<label
|
||||
class="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="edit-user-verified"
|
||||
class="w-4 h-4 text-green-600 rounded focus:ring-green-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700"
|
||||
>Подтверждён</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2"
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-gray-600 text-white font-medium hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-edit-btn"
|
||||
class="mt-3 sm:mt-0 w-full sm:w-auto inline-flex justify-center rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-gray-700 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="delete-user-modal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
class="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:p-0"
|
||||
>
|
||||
<div
|
||||
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
id="delete-modal-backdrop"
|
||||
></div>
|
||||
<div
|
||||
class="relative inline-block bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:max-w-lg sm:w-full"
|
||||
>
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div
|
||||
class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Удаление пользователя
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
Вы уверены, что хотите удалить пользователя
|
||||
<strong id="delete-user-name"></strong>? Это
|
||||
действие необратимо.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse gap-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="confirm-delete-btn"
|
||||
class="w-full sm:w-auto inline-flex justify-center rounded-lg border border-transparent shadow-sm px-4 py-2 bg-red-600 text-white font-medium hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition"
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="cancel-delete-btn"
|
||||
class="mt-3 sm:mt-0 w-full sm:w-auto inline-flex justify-center rounded-lg border border-gray-300 shadow-sm px-4 py-2 bg-white text-gray-700 font-medium hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block scripts %}
|
||||
<script src="/static/users.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""role payroll
|
||||
|
||||
Revision ID: a8e40ab24138
|
||||
Revises: 02ed6e775351
|
||||
Create Date: 2025-12-20 13:44:13.807704
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
import sqlmodel
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a8e40ab24138'
|
||||
down_revision: Union[str, None] = '02ed6e775351'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.alter_column('book', 'status',
|
||||
existing_type=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
|
||||
type_=sa.String(),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'active'::bookstatus"))
|
||||
op.add_column('roles', sa.Column('payroll', sa.Integer(), nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('roles', 'payroll')
|
||||
op.alter_column('book', 'status',
|
||||
existing_type=sa.String(),
|
||||
type_=postgresql.ENUM('active', 'borrowed', 'reserved', 'restoration', 'written_off', name='bookstatus'),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text("'active'::bookstatus"))
|
||||
# ### end Alembic commands ###
|
||||
Generated
+6
-6
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -1969,14 +1969,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.34.3"
|
||||
version = "0.40.0"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "uvicorn-0.34.3-py3-none-any.whl", hash = "sha256:16246631db62bdfbf069b0645177d6e8a77ba950cfedbfd093acef9444e4d885"},
|
||||
{file = "uvicorn-0.34.3.tar.gz", hash = "sha256:35919a9a979d7a59334b6b10e05d77c1d0d574c50e0fc98b8b1a0f165708b55a"},
|
||||
{file = "uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee"},
|
||||
{file = "uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -2247,4 +2247,4 @@ files = [
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "a8d44f0decfa3ba437e998207c16ca7429ee42e930e8aa1d40f87231e71f219f"
|
||||
content-hash = "6048c7b120fbeb4533a1e858a87eb09aec68e5da569ca821b9e41ecabf220a9e"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "LibraryAPI"
|
||||
version = "0.2.0"
|
||||
version = "0.3.0"
|
||||
description = "Это простое API для управления авторами, книгами и их жанрами."
|
||||
authors = ["wowlikon"]
|
||||
readme = "README.md"
|
||||
@@ -9,11 +9,11 @@ packages = [{ include = "library_service" }]
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12"
|
||||
fastapi = { extras = ["all"], version = "^0.115.12" }
|
||||
uvicorn = { extras = ["standard"], version = "^0.40.0" }
|
||||
psycopg2-binary = "^2.9.10"
|
||||
alembic = "^1.16.1"
|
||||
python-dotenv = "^0.21.0"
|
||||
sqlmodel = "^0.0.24"
|
||||
uvicorn = "^0.34.3"
|
||||
jinja2 = "^3.1.6"
|
||||
toml = "^0.10.2"
|
||||
python-jose = {extras = ["cryptography"], version = "^3.5.0"}
|
||||
|
||||
Reference in New Issue
Block a user