diff --git a/CLAUDE.md b/CLAUDE.md index 6f0d0fb..d04d8af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,21 +8,24 @@ F1tness Parser is a FastAPI-based web application that parses fitness training d - **Obsidian Notes**: Markdown tables with `# DD.MM.YYYY (trainer-session)` headers - **Apple Notes**: Bold markdown tables with `**DD.MM.YYYY (trainer-session)**` headers -The application uses a layered architecture with parsers, models, services, and API endpoints. +The project is split into two applications in one repository: +- `backend/` - FastAPI JSON API (parsers, models, services, PostgreSQL) +- `frontend/` - static site on plain HTML + CSS + JS (ES modules, no build step), served by nginx, which also proxies `/api/` to the backend ## Development Commands -The project is developed and run **only in Docker**: there is no local Python setup. Everything below runs inside the `fastapi-app` container (`.env` must have `POSTGRES_DATABASE_HOST=postgres`, see `.env.example`). +The project is developed and run **only in Docker**: there is no local Python setup. Everything below runs inside the `fastapi-app` container (backend code lives in `backend/`, inside the container it is `/app`) (`.env` must have `POSTGRES_DATABASE_HOST=postgres`, see `.env.example`). ### Docker Development ```bash -# Start the app + PostgreSQL (applies migrations on start, hot reload for app/) +# Start backend (:8000) + frontend (:8080) + PostgreSQL (applies migrations on start, hot reload for backend/app/) +# Open http://localhost:8080 docker compose up --build # Apply migrations manually (Alembic; the app itself does NOT migrate on startup) docker compose exec fastapi-app alembic upgrade head -# Create a migration after changing models in app/core/database/models/ +# Create a migration after changing tables in app/core/database/tables.py # (-u keeps the generated file owned by you instead of root) docker compose exec -u "$(id -u):$(id -g)" fastapi-app alembic revision --autogenerate -m "describe change" @@ -57,39 +60,41 @@ docker compose exec fastapi-app mypy app/ ### Core Components -**Parser Architecture**: The system uses a base parser class (`app/core/parsers/base.py`) with format-specific implementations: +**Parser Architecture**: The system uses a base parser class (`backend/app/core/parsers/base.py`) with format-specific implementations: - `ObsidianNotesParser` - handles Obsidian markdown format - `AppleNotesParser` - handles Apple notes format - Each parser includes exercise name mapping for normalization -**Data Models**: Located in `app/core/dto/training.py`: +**Data Models**: Located in `backend/app/core/dto/training.py`: - `Training` - represents a workout session - `Exercise` - individual exercise with multiple approaches - `Approach` - single set with weight and reps -**API Structure**: -- `/api/v1/` - REST API endpoints -- `/app/` - Web interface endpoints -- Static files served from `app/static/` -- Templates in `app/templates/` +**API Structure** (backend, JSON only): +- `/api/v1/trainings/`, `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/parser/parse/`, `/api/v1/health/` + +**Frontend** (`frontend/`): +- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (search/sort/paging), one module per page +- `vendor/` - Bootstrap CSS and Chart.js (the only third-party code) +- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS) ### Database -The application uses PostgreSQL through async SQLAlchemy (asyncpg). ORM models live in `app/core/database/models/`, `TrainingRepository` (`app/core/database/repository.py`) is the data access layer, and a session is injected into endpoints with `Depends(get_session)`. Schema migrations are handled by Alembic (`alembic.ini`, `migrations/`); the URL is taken from `app/config.py`. +The backend uses PostgreSQL through async SQLAlchemy (asyncpg). tables are described with SQLAlchemy Core (no ORM) in `backend/app/core/database/tables.py`, `TrainingRepository` (`backend/app/core/database/repository.py`) is the data access layer, and a session is injected into endpoints with `Depends(get_session)`. Schema migrations are handled by Alembic (`alembic.ini`, `migrations/`); the URL is taken from `app/config.py`. -Configuration is managed through `app/config.py` using Pydantic settings with `.env` file support. +Configuration is managed through `backend/app/config.py` using Pydantic settings with `.env` file support. ### Key Files -- `app/main.py` - FastAPI application setup with lifespan management -- `app/config.py` - Application configuration and database settings -- `run.py` - Standalone uvicorn runner (unused: Docker starts uvicorn from `compose.yaml`) -- `migrations/` - Alembic environment and versions -- `compose.yaml` - Docker Compose setup with PostgreSQL +- `backend/app/main.py` - FastAPI application setup with lifespan management +- `backend/app/config.py` - Application configuration and database settings +- `backend/run.py` - Standalone uvicorn runner (unused: Docker starts uvicorn from `compose.yaml`) +- `backend/migrations/` - Alembic environment and versions +- `compose.yaml` - Docker Compose setup: backend, frontend, PostgreSQL ## Environment Setup -Copy `.env.example` to `.env` and configure: +Copy `.env.example` to `.env` (repo root) and configure: - PostgreSQL connection settings - Debug mode - PYTHONPATH (for Docker) diff --git a/README.md b/README.md index 4a28bc8..752dd70 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,17 @@ Приложение для парсинга текстовых файлов тренировок +## Структура +- `backend/` - FastAPI, JSON API (`/api/v1/...`), PostgreSQL +- `frontend/` - статический сайт на HTML + CSS + JS, раздаётся nginx, запросы `/api/` проксирует на бэкенд + +## Запуск +```bash +docker compose up --build +docker compose exec fastapi-app python -m app.cli import # загрузить заметки в базу +``` +Сайт: http://localhost:8080, API и документация: http://localhost:8000/docs + ## Поддерживаемые записи Работает с двумя категорями записей: - Obsidian notes diff --git a/app/api/v1/web.py b/app/api/v1/web.py deleted file mode 100644 index 5cd5d1b..0000000 --- a/app/api/v1/web.py +++ /dev/null @@ -1,124 +0,0 @@ -import calendar -import logging -from typing import Optional - -from fastapi import APIRouter, Depends, Request, Form -from fastapi.responses import HTMLResponse -from fastapi.templating import Jinja2Templates - -from app.api.deps import get_training_repository -from app.core.database.repository import TrainingRepository -from app.core.metrics import ExerciseProgress, summarize -from app.core.parsers.obsidian import ObsidianNotesParser, parse_training_data -from app.core.templates.templates import BaseTemplateResponse - -web_router = APIRouter() -templates = Jinja2Templates(directory="app/templates") -template_renderer = BaseTemplateResponse(templates) - - -def build_progress_chart(progress: ExerciseProgress) -> dict: - """Data for the progress chart: two series over time, x is a UTC timestamp in ms.""" - if progress.weighted: - series = [ - ("e1RM, кг", lambda metrics: metrics.e1rm), - ("Тоннаж, кг", lambda metrics: metrics.volume or None), - ] - else: - series = [ - ("Лучший подход, повторы", lambda metrics: metrics.best_reps), - ("Всего повторов за тренировку", lambda metrics: metrics.total_reps), - ] - - chronological = list(reversed(progress.rows)) - return { - "series": [ - { - "label": label, - "points": [ - [calendar.timegm(row.session.date.timetuple()) * 1000, round(value, 1)] - for row in chronological - if (value := metric(row.metrics)) is not None - ], - } - for label, metric in series - ] - } - - -@web_router.get("/", response_class=HTMLResponse) -async def home( - request: Request, - repository: TrainingRepository = Depends(get_training_repository), -): - """Home page: all trainings from the database in a single table""" - - return template_renderer.render( - request, - "index.html", - context={"trainings": await repository.list()}, - ) - - -@web_router.get("/exercises/", response_class=HTMLResponse) -async def exercises( - request: Request, - name: Optional[str] = None, - repository: TrainingRepository = Depends(get_training_repository), -): - """Exercise search box and the whole history of the chosen exercise""" - - query = (name or "").strip() - exercise_names = await repository.list_exercise_names() - - # Exact name, but case-insensitive: what was typed may differ in case - names_by_casefold = {item.name.casefold(): item.name for item in exercise_names} - selected = names_by_casefold.get(query.casefold()) - history = await repository.exercise_history(selected) if selected else [] - # Nothing matched exactly: offer the names containing the typed text - suggestions = ( - [item for item in exercise_names if query.casefold() in item.name.casefold()] - if query and not selected - else [] - ) - - progress = summarize(history) if history else None - - return template_renderer.render( - request, - "exercises.html", - context={ - "exercise_names": exercise_names, - "query": query, - "selected": selected, - "suggestions": suggestions, - "progress": progress, - "chart": build_progress_chart(progress) if progress else None, - }, - ) - - -@web_router.get("/obsidian/list/", response_class=HTMLResponse) -async def obsidian_list(request: Request): - data = parse_training_data() - return template_renderer.render( - request=request, template_name="trainings.html", context={"trainings": data} - ) - - -@web_router.get("/obsidian/parse", response_class=HTMLResponse) -async def obsidian_parse_form(request: Request): - return template_renderer.render( - request=request, template_name="obsidian/parser.html", context={"trainings": {}} - ) - - -@web_router.post("/obsidian/parse", response_class=HTMLResponse) -async def obsidian_parse(request: Request, training_data: str = Form(...)): - logging.error(training_data) - op = ObsidianNotesParser() - result = op.parse(data=training_data) - logging.error(result) - return template_renderer.render( - request=request, template_name="trainings.html", context={"trainings": result} - ) diff --git a/app/core/database/models/__init__.py b/app/core/database/models/__init__.py deleted file mode 100644 index d4563a6..0000000 --- a/app/core/database/models/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from app.core.database.models.approach import ApproachModel -from app.core.database.models.base import Base -from app.core.database.models.exercise import ExerciseModel -from app.core.database.models.training import TrainingModel - -__all__ = ["ApproachModel", "Base", "ExerciseModel", "TrainingModel"] diff --git a/app/core/database/models/approach.py b/app/core/database/models/approach.py deleted file mode 100644 index 6651849..0000000 --- a/app/core/database/models/approach.py +++ /dev/null @@ -1,26 +0,0 @@ -from datetime import datetime -from typing import TYPE_CHECKING - -from sqlalchemy import DateTime, Float, ForeignKey, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.core.database.models.base import Base - -if TYPE_CHECKING: - from app.core.database.models.exercise import ExerciseModel - - -class ApproachModel(Base): - __tablename__ = "approaches" - - id: Mapped[int] = mapped_column(primary_key=True) - exercise_id: Mapped[int] = mapped_column( - ForeignKey("exercises.id", ondelete="CASCADE"), index=True - ) - weight: Mapped[float] = mapped_column(Float) - reps: Mapped[int] - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - exercise: Mapped["ExerciseModel"] = relationship(back_populates="approaches") diff --git a/app/core/database/models/base.py b/app/core/database/models/base.py deleted file mode 100644 index fa2b68a..0000000 --- a/app/core/database/models/base.py +++ /dev/null @@ -1,5 +0,0 @@ -from sqlalchemy.orm import DeclarativeBase - - -class Base(DeclarativeBase): - pass diff --git a/app/core/database/models/exercise.py b/app/core/database/models/exercise.py deleted file mode 100644 index f03c676..0000000 --- a/app/core/database/models/exercise.py +++ /dev/null @@ -1,33 +0,0 @@ -from datetime import datetime -from typing import TYPE_CHECKING, List - -from sqlalchemy import Boolean, DateTime, ForeignKey, String, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.core.database.models.base import Base - -if TYPE_CHECKING: - from app.core.database.models.approach import ApproachModel - from app.core.database.models.training import TrainingModel - - -class ExerciseModel(Base): - __tablename__ = "exercises" - - id: Mapped[int] = mapped_column(primary_key=True) - training_id: Mapped[int] = mapped_column( - ForeignKey("trainings.id", ondelete="CASCADE"), index=True - ) - name: Mapped[str] = mapped_column(String(255), index=True) - splitted_weight: Mapped[bool] = mapped_column(Boolean, server_default="false") - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - training: Mapped["TrainingModel"] = relationship(back_populates="exercises") - approaches: Mapped[List["ApproachModel"]] = relationship( - back_populates="exercise", - cascade="all, delete-orphan", - passive_deletes=True, - order_by="ApproachModel.id", - ) diff --git a/app/core/database/models/training.py b/app/core/database/models/training.py deleted file mode 100644 index 865f371..0000000 --- a/app/core/database/models/training.py +++ /dev/null @@ -1,28 +0,0 @@ -from datetime import date as date_type, datetime -from typing import TYPE_CHECKING, List, Optional - -from sqlalchemy import Date, DateTime, String, func -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.core.database.models.base import Base - -if TYPE_CHECKING: - from app.core.database.models.exercise import ExerciseModel - - -class TrainingModel(Base): - __tablename__ = "trainings" - - id: Mapped[int] = mapped_column(primary_key=True) - date: Mapped[date_type] = mapped_column(Date, index=True) - trainer: Mapped[Optional[str]] = mapped_column(String(100)) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now() - ) - - exercises: Mapped[List["ExerciseModel"]] = relationship( - back_populates="training", - cascade="all, delete-orphan", - passive_deletes=True, - order_by="ExerciseModel.id", - ) diff --git a/app/core/database/repository.py b/app/core/database/repository.py deleted file mode 100644 index a5e8b97..0000000 --- a/app/core/database/repository.py +++ /dev/null @@ -1,114 +0,0 @@ -from typing import List, Sequence - -from sqlalchemy import delete, func, select, text -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import contains_eager, selectinload - -from app.core.database.models import ApproachModel, ExerciseModel, TrainingModel -from app.core.dto.training import ( - ApproachDTO, - ExerciseNameDTO, - ExerciseSessionDTO, - TrainingDTO, -) - - -class TrainingRepository: - """Storage for trainings together with their exercises and approaches.""" - - def __init__(self, session: AsyncSession) -> None: - self._session = session - - async def save(self, training: TrainingDTO) -> int: - return (await self.save_many([training]))[0] - - async def save_many(self, trainings: Sequence[TrainingDTO]) -> List[int]: - """Save all trainings in a single transaction, returns their ids.""" - models = [self._to_model(training) for training in trainings] - self._session.add_all(models) - await self._session.commit() - return [model.id for model in models] - - async def list(self) -> List[TrainingDTO]: - query = ( - select(TrainingModel) - .options( - selectinload(TrainingModel.exercises).selectinload( - ExerciseModel.approaches - ) - ) - .order_by(TrainingModel.date.desc(), TrainingModel.id.desc()) - ) - result = await self._session.scalars(query) - return [TrainingDTO.model_validate(model) for model in result] - - async def list_exercise_names(self) -> List[ExerciseNameDTO]: - """Distinct exercise names with the number of times each was done.""" - query = select(ExerciseModel.name, func.count(ExerciseModel.id)).group_by( - ExerciseModel.name - ) - result = await self._session.execute(query) - names = [ExerciseNameDTO(name=name, count=count) for name, count in result] - # Sorted here, not in SQL: the order must not depend on DB collation - return sorted(names, key=lambda item: item.name.casefold()) - - async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]: - """Every occurrence of the exercise, newest first.""" - query = ( - select(ExerciseModel) - .join(ExerciseModel.training) - .where(ExerciseModel.name == name) - .options( - contains_eager(ExerciseModel.training), - selectinload(ExerciseModel.approaches), - ) - .order_by(TrainingModel.date.desc(), ExerciseModel.id.desc()) - ) - result = await self._session.scalars(query) - return [ - ExerciseSessionDTO( - date=exercise.training.date, - trainer=exercise.training.trainer, - splitted_weight=exercise.splitted_weight, - approaches=[ - ApproachDTO.model_validate(approach) - for approach in exercise.approaches - ], - ) - for exercise in result - ] - - async def count(self) -> int: - return await self._session.scalar(select(func.count(TrainingModel.id))) or 0 - - async def delete(self, list_of_ids: List[int]) -> int: - """Delete trainings by ids (exercises and approaches go with them).""" - result = await self._session.execute( - delete(TrainingModel).where(TrainingModel.id.in_(list_of_ids)) - ) - await self._session.commit() - return result.rowcount # type: ignore[attr-defined] - - async def delete_all(self) -> None: - await self._session.execute( - text("TRUNCATE trainings RESTART IDENTITY CASCADE") - ) - await self._session.commit() - - @staticmethod - def _to_model(training: TrainingDTO) -> TrainingModel: - return TrainingModel( - date=training.date, - trainer=training.trainer, - exercises=[ - ExerciseModel( - name=exercise.name, - splitted_weight=exercise.splitted_weight, - approaches=[ - ApproachModel(weight=approach.weight, reps=approach.reps) - for approach in exercise.approaches - ], - ) - for exercise in training.exercises - ], - ) diff --git a/app/core/templates/templates.py b/app/core/templates/templates.py deleted file mode 100644 index 42c8f16..0000000 --- a/app/core/templates/templates.py +++ /dev/null @@ -1,29 +0,0 @@ -from typing import Optional -from fastapi import Request -from fastapi.templating import Jinja2Templates - - -class BaseTemplateResponse: - """Базовый метод для рендеринга Jinja2 темплейтов. - - Нужен в первую очередь для того, чтобы подставлять какие-то дефолтные значения (которые есть - на всех страницах) в переменные темплейтов - - Attributes: - templates: Формат темплейтов, используемых в проекте - """ - def __init__(self, templates: Jinja2Templates): - self.templates = templates - - def render( - self, request: Request, template_name: str, context: Optional[dict] = None - ): - base_context = { - "title": "Fitness stats", - "app_name": "Fitness parser" - } - - if context: - base_context.update(context) - - return self.templates.TemplateResponse(request, template_name, base_context) diff --git a/app/static/css/dataTables.bootstrap5.min.css b/app/static/css/dataTables.bootstrap5.min.css deleted file mode 100644 index 0eacc52..0000000 --- a/app/static/css/dataTables.bootstrap5.min.css +++ /dev/null @@ -1,5 +0,0 @@ -:root{--dt-row-selected: 13, 110, 253;--dt-row-selected-text: 255, 255, 255;--dt-row-selected-link: 9, 10, 11;--dt-row-stripe: 0, 0, 0;--dt-row-hover: 0, 0, 0;--dt-column-ordering: 0, 0, 0;--dt-html-background: white}:root.dark{--dt-html-background: rgb(33, 37, 41)}table.dataTable td.dt-control{text-align:center;cursor:pointer}table.dataTable td.dt-control:before{display:inline-block;box-sizing:border-box;content:"";border-top:5px solid transparent;border-left:10px solid rgba(0, 0, 0, 0.5);border-bottom:5px solid transparent;border-right:0px solid transparent}table.dataTable tr.dt-hasChild td.dt-control:before{border-top:10px solid rgba(0, 0, 0, 0.5);border-left:5px solid transparent;border-bottom:0px solid transparent;border-right:5px solid transparent}html.dark table.dataTable td.dt-control:before,:root[data-bs-theme=dark] table.dataTable td.dt-control:before,:root[data-theme=dark] table.dataTable td.dt-control:before{border-left-color:rgba(255, 255, 255, 0.5)}html.dark table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before{border-top-color:rgba(255, 255, 255, 0.5);border-left-color:transparent}div.dt-scroll{width:100%}div.dt-scroll-body thead tr,div.dt-scroll-body tfoot tr{height:0}div.dt-scroll-body thead tr th,div.dt-scroll-body thead tr td,div.dt-scroll-body tfoot tr th,div.dt-scroll-body tfoot tr td{height:0 !important;padding-top:0px !important;padding-bottom:0px !important;border-top-width:0px !important;border-bottom-width:0px !important}div.dt-scroll-body thead tr th div.dt-scroll-sizing,div.dt-scroll-body thead tr td div.dt-scroll-sizing,div.dt-scroll-body tfoot tr th div.dt-scroll-sizing,div.dt-scroll-body tfoot tr td div.dt-scroll-sizing{height:0 !important;overflow:hidden !important}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before{position:absolute;display:block;bottom:50%;content:"▲";content:"▲"/""}table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{position:absolute;display:block;top:50%;content:"▼";content:"▼"/""}table.dataTable thead>tr>th.dt-orderable-asc,table.dataTable thead>tr>th.dt-orderable-desc,table.dataTable thead>tr>th.dt-ordering-asc,table.dataTable thead>tr>th.dt-ordering-desc,table.dataTable thead>tr>td.dt-orderable-asc,table.dataTable thead>tr>td.dt-orderable-desc,table.dataTable thead>tr>td.dt-ordering-asc,table.dataTable thead>tr>td.dt-ordering-desc{position:relative;padding-right:30px}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order{position:absolute;right:12px;top:0;bottom:0;width:12px}table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-asc span.dt-column-order:after,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc span.dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{left:0;opacity:.125;line-height:9px;font-size:.8em}table.dataTable thead>tr>th.dt-orderable-asc,table.dataTable thead>tr>th.dt-orderable-desc,table.dataTable thead>tr>td.dt-orderable-asc,table.dataTable thead>tr>td.dt-orderable-desc{cursor:pointer}table.dataTable thead>tr>th.dt-orderable-asc:hover,table.dataTable thead>tr>th.dt-orderable-desc:hover,table.dataTable thead>tr>td.dt-orderable-asc:hover,table.dataTable thead>tr>td.dt-orderable-desc:hover{outline:2px solid rgba(0, 0, 0, 0.05);outline-offset:-2px}table.dataTable thead>tr>th.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-desc span.dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-asc span.dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-desc span.dt-column-order:after{opacity:.6}table.dataTable thead>tr>th.sorting_desc_disabled span.dt-column-order:after,table.dataTable thead>tr>th.sorting_asc_disabled span.dt-column-order:before,table.dataTable thead>tr>td.sorting_desc_disabled span.dt-column-order:after,table.dataTable thead>tr>td.sorting_asc_disabled span.dt-column-order:before{display:none}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}div.dt-scroll-body>table.dataTable>thead>tr>th,div.dt-scroll-body>table.dataTable>thead>tr>td{overflow:hidden}:root.dark table.dataTable thead>tr>th.dt-orderable-asc:hover,:root.dark table.dataTable thead>tr>th.dt-orderable-desc:hover,:root.dark table.dataTable thead>tr>td.dt-orderable-asc:hover,:root.dark table.dataTable thead>tr>td.dt-orderable-desc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>th.dt-orderable-asc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>th.dt-orderable-desc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>td.dt-orderable-asc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>td.dt-orderable-desc:hover{outline:2px solid rgba(255, 255, 255, 0.05)}div.dt-processing{position:absolute;top:50%;left:50%;width:200px;margin-left:-100px;margin-top:-22px;text-align:center;padding:2px;z-index:10}div.dt-processing>div:last-child{position:relative;width:80px;height:15px;margin:1em auto}div.dt-processing>div:last-child>div{position:absolute;top:0;width:13px;height:13px;border-radius:50%;background:rgb(13, 110, 253);background:rgb(var(--dt-row-selected));animation-timing-function:cubic-bezier(0, 1, 1, 0)}div.dt-processing>div:last-child>div:nth-child(1){left:8px;animation:datatables-loader-1 .6s infinite}div.dt-processing>div:last-child>div:nth-child(2){left:8px;animation:datatables-loader-2 .6s infinite}div.dt-processing>div:last-child>div:nth-child(3){left:32px;animation:datatables-loader-2 .6s infinite}div.dt-processing>div:last-child>div:nth-child(4){left:56px;animation:datatables-loader-3 .6s infinite}@keyframes datatables-loader-1{0%{transform:scale(0)}100%{transform:scale(1)}}@keyframes datatables-loader-3{0%{transform:scale(1)}100%{transform:scale(0)}}@keyframes datatables-loader-2{0%{transform:translate(0, 0)}100%{transform:translate(24px, 0)}}table.dataTable.nowrap th,table.dataTable.nowrap td{white-space:nowrap}table.dataTable th,table.dataTable td{box-sizing:border-box}table.dataTable th.dt-left,table.dataTable td.dt-left{text-align:left}table.dataTable th.dt-center,table.dataTable td.dt-center{text-align:center}table.dataTable th.dt-right,table.dataTable td.dt-right{text-align:right}table.dataTable th.dt-justify,table.dataTable td.dt-justify{text-align:justify}table.dataTable th.dt-nowrap,table.dataTable td.dt-nowrap{white-space:nowrap}table.dataTable th.dt-empty,table.dataTable td.dt-empty{text-align:center;vertical-align:top}table.dataTable th.dt-type-numeric,table.dataTable th.dt-type-date,table.dataTable td.dt-type-numeric,table.dataTable td.dt-type-date{text-align:right}table.dataTable thead th,table.dataTable thead td,table.dataTable tfoot th,table.dataTable tfoot td{text-align:left}table.dataTable thead th.dt-head-left,table.dataTable thead td.dt-head-left,table.dataTable tfoot th.dt-head-left,table.dataTable tfoot td.dt-head-left{text-align:left}table.dataTable thead th.dt-head-center,table.dataTable thead td.dt-head-center,table.dataTable tfoot th.dt-head-center,table.dataTable tfoot td.dt-head-center{text-align:center}table.dataTable thead th.dt-head-right,table.dataTable thead td.dt-head-right,table.dataTable tfoot th.dt-head-right,table.dataTable tfoot td.dt-head-right{text-align:right}table.dataTable thead th.dt-head-justify,table.dataTable thead td.dt-head-justify,table.dataTable tfoot th.dt-head-justify,table.dataTable tfoot td.dt-head-justify{text-align:justify}table.dataTable thead th.dt-head-nowrap,table.dataTable thead td.dt-head-nowrap,table.dataTable tfoot th.dt-head-nowrap,table.dataTable tfoot td.dt-head-nowrap{white-space:nowrap}table.dataTable tbody th.dt-body-left,table.dataTable tbody td.dt-body-left{text-align:left}table.dataTable tbody th.dt-body-center,table.dataTable tbody td.dt-body-center{text-align:center}table.dataTable tbody th.dt-body-right,table.dataTable tbody td.dt-body-right{text-align:right}table.dataTable tbody th.dt-body-justify,table.dataTable tbody td.dt-body-justify{text-align:justify}table.dataTable tbody th.dt-body-nowrap,table.dataTable tbody td.dt-body-nowrap{white-space:nowrap}/*! Bootstrap 5 integration for DataTables - * - * ©2020 SpryMedia Ltd, all rights reserved. - * License: MIT datatables.net/license/mit - */table.table.dataTable{clear:both;margin-bottom:0;max-width:none;border-spacing:0}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1)>*{box-shadow:none}table.table.dataTable>:not(caption)>*>*{background-color:var(--bs-table-bg)}table.table.dataTable>tbody>tr{background-color:transparent}table.table.dataTable>tbody>tr.selected>*{box-shadow:inset 0 0 0 9999px rgb(13, 110, 253);box-shadow:inset 0 0 0 9999px rgb(var(--dt-row-selected));color:rgb(255, 255, 255);color:rgb(var(--dt-row-selected-text))}table.table.dataTable>tbody>tr.selected a{color:rgb(9, 10, 11);color:rgb(var(--dt-row-selected-link))}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1)>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-stripe), 0.05)}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1).selected>*{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.95);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.95)}table.table.dataTable.table-hover>tbody>tr:hover>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-hover), 0.075)}table.table.dataTable.table-hover>tbody>tr.selected:hover>*{box-shadow:inset 0 0 0 9999px rgba(13, 110, 253, 0.975);box-shadow:inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975)}div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:1em}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:1em}div.dt-container div.dt-layout-full{width:100%}div.dt-container div.dt-layout-full>*:only-child{margin-left:auto;margin-right:auto}div.dt-container div.dt-layout-table>div{display:block !important}@media screen and (max-width: 767px){div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:0}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:0}}div.dt-container div.dt-length label{font-weight:normal;text-align:left;white-space:nowrap}div.dt-container div.dt-length select{width:auto;display:inline-block;margin-right:.5em}div.dt-container div.dt-search{text-align:right}div.dt-container div.dt-search label{font-weight:normal;white-space:nowrap;text-align:left}div.dt-container div.dt-search input{margin-left:.5em;display:inline-block;width:auto}div.dt-container div.dt-paging{margin:0}div.dt-container div.dt-paging ul.pagination{margin:2px 0;flex-wrap:wrap}div.dt-container div.dt-row{position:relative}div.dt-scroll-head table.dataTable{margin-bottom:0 !important}div.dt-scroll-body{border-bottom-color:var(--bs-border-color);border-bottom-width:var(--bs-border-width);border-bottom-style:solid}div.dt-scroll-body>table{border-top:none;margin-top:0 !important;margin-bottom:0 !important}div.dt-scroll-body>table>tbody>tr:first-child{border-top-width:0}div.dt-scroll-body>table>thead>tr{border-width:0 !important}div.dt-scroll-body>table>tbody>tr:last-child>*{border-bottom:none}div.dt-scroll-foot>.dt-scroll-footInner{box-sizing:content-box}div.dt-scroll-foot>.dt-scroll-footInner>table{margin-top:0 !important;border-top:none}div.dt-scroll-foot>.dt-scroll-footInner>table>tfoot>tr:first-child{border-top-width:0 !important}@media screen and (max-width: 767px){div.dt-container div.dt-length,div.dt-container div.dt-search,div.dt-container div.dt-info,div.dt-container div.dt-paging{text-align:center}div.dt-container .row{--bs-gutter-y: 0.5rem}div.dt-container div.dt-paging ul.pagination{justify-content:center !important}}table.dataTable.table-sm>thead>tr th.dt-orderable-asc,table.dataTable.table-sm>thead>tr th.dt-orderable-desc,table.dataTable.table-sm>thead>tr th.dt-ordering-asc,table.dataTable.table-sm>thead>tr th.dt-ordering-desc,table.dataTable.table-sm>thead>tr td.dt-orderable-asc,table.dataTable.table-sm>thead>tr td.dt-orderable-desc,table.dataTable.table-sm>thead>tr td.dt-ordering-asc,table.dataTable.table-sm>thead>tr td.dt-ordering-desc{padding-right:20px}table.dataTable.table-sm>thead>tr th.dt-orderable-asc span.dt-column-order,table.dataTable.table-sm>thead>tr th.dt-orderable-desc span.dt-column-order,table.dataTable.table-sm>thead>tr th.dt-ordering-asc span.dt-column-order,table.dataTable.table-sm>thead>tr th.dt-ordering-desc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-orderable-asc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-orderable-desc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-ordering-asc span.dt-column-order,table.dataTable.table-sm>thead>tr td.dt-ordering-desc span.dt-column-order{right:5px}div.dt-scroll-head table.table-bordered{border-bottom-width:0}div.table-responsive>div.dt-container>div.row{margin:0}div.table-responsive>div.dt-container>div.row>div[class^=col-]:first-child{padding-left:0}div.table-responsive>div.dt-container>div.row>div[class^=col-]:last-child{padding-right:0}:root[data-bs-theme=dark]{--dt-row-hover: 255, 255, 255;--dt-row-stripe: 255, 255, 255;--dt-column-ordering: 255, 255, 255} diff --git a/app/static/js/bootstrap.bundle.min.js b/app/static/js/bootstrap.bundle.min.js deleted file mode 100644 index b1999d9..0000000 --- a/app/static/js/bootstrap.bundle.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Bootstrap v5.3.2 (https://getbootstrap.com/) - * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function M(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function j(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${j(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${j(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=M(t.dataset[n])}return e},getDataAttribute:(t,e)=>M(t.getAttribute(`data-bs-${j(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.2"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?n(i.trim()):null}return e},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",Mt="collapsing",jt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(Mt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(Mt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(Mt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(Mt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(jt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function Me(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const je={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:Me(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:Me(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},M=p?3:1;M>0&&"break"!==P(M);M--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],M=f?-T[$]/2:0,j=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-M-q-z-O.mainAxis:j-q-z-O.mainAxis,K=v?-E[$]/2+M+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,Mn=`hide${xn}`,jn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,Mn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,jn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,jn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",Ms="Home",js="End",Fs="active",Hs="fade",Ws="show",Bs=".dropdown-toggle",zs=`:not(${Bs})`,Rs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',qs=`.nav-link${zs}, .list-group-item${zs}, [role="tab"]${zs}, ${Rs}`,Vs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Ks extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,Ms,js].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([Ms,js].includes(t.key))i=e[t.key===Ms?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Ks.getOrCreateInstance(i).show())}_getChildren(){return z.find(qs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Bs,Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(qs)?t:z.findOne(qs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Ks.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,Rs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Ks.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(Vs))Ks.getOrCreateInstance(t)})),m(Ks);const Qs=".bs.toast",Xs=`mouseover${Qs}`,Ys=`mouseout${Qs}`,Us=`focusin${Qs}`,Gs=`focusout${Qs}`,Js=`hide${Qs}`,Zs=`hidden${Qs}`,to=`show${Qs}`,eo=`shown${Qs}`,io="hide",no="show",so="showing",oo={animation:"boolean",autohide:"boolean",delay:"number"},ro={animation:!0,autohide:!0,delay:5e3};class ao extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return ro}static get DefaultType(){return oo}static get NAME(){return"toast"}show(){N.trigger(this._element,to).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(io),d(this._element),this._element.classList.add(no,so),this._queueCallback((()=>{this._element.classList.remove(so),N.trigger(this._element,eo),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Js).defaultPrevented||(this._element.classList.add(so),this._queueCallback((()=>{this._element.classList.add(io),this._element.classList.remove(so,no),N.trigger(this._element,Zs)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(no),super.dispose()}isShown(){return this._element.classList.contains(no)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Xs,(t=>this._onInteraction(t,!0))),N.on(this._element,Ys,(t=>this._onInteraction(t,!1))),N.on(this._element,Us,(t=>this._onInteraction(t,!0))),N.on(this._element,Gs,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ao.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ao),m(ao),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Ks,Toast:ao,Tooltip:cs}})); -//# sourceMappingURL=bootstrap.bundle.min.js.map \ No newline at end of file diff --git a/app/static/js/dataTables.bootstrap5.min.js b/app/static/js/dataTables.bootstrap5.min.js deleted file mode 100644 index 5f94c43..0000000 --- a/app/static/js/dataTables.bootstrap5.min.js +++ /dev/null @@ -1,4 +0,0 @@ -/*! DataTables Bootstrap 5 integration - * © SpryMedia Ltd - datatables.net/license - */ -!function(n){var o,r;"function"==typeof define&&define.amd?define(["jquery","datatables.net"],function(t){return n(t,window,document)}):"object"==typeof exports?(o=require("jquery"),r=function(t,e){e.fn.dataTable||require("datatables.net")(t,e)},"undefined"==typeof window?module.exports=function(t,e){return t=t||window,e=e||o(t),r(t,e),n(e,0,t.document)}:(r(window,o),module.exports=n(o,window,window.document))):n(jQuery,window,document)}(function(d,t,e){"use strict";var n=d.fn.dataTable;return d.extend(!0,n.defaults,{renderer:"bootstrap"}),d.extend(!0,n.ext.classes,{container:"dt-container dt-bootstrap5",search:{input:"form-control form-control-sm"},length:{select:"form-select form-select-sm"},processing:{container:"dt-processing card"},layout:{row:"row mt-2 justify-content-between",cell:"d-md-flex justify-content-between align-items-center",tableCell:"col-12",start:"dt-layout-start col-md-auto me-auto",end:"dt-layout-end col-md-auto ms-auto",full:"dt-layout-full col-md"}}),n.ext.renderer.pagingButton.bootstrap=function(t,e,n,o,r){var a=["dt-paging-button","page-item"],o=(o&&a.push("active"),r&&a.push("disabled"),d("
  • ").addClass(a.join(" ")));return{display:o,clicker:d(" - - - {% for item in exercise_names %}{% endfor %} - - - -{% if selected %} - {% set rows = progress.rows %} -

    {{ selected }}

    -

    - Тренировок: {{ rows|length }}, - первая: {{ rows[-1].session.date.strftime('%d.%m.%Y') }}, - последняя: {{ rows[0].session.date.strftime('%d.%m.%Y') }} -

    - -
    - {% if progress.weighted %} - {{ stat_card('Рекорд e1RM', num(progress.best_primary.value) ~ ' кг', progress.best_primary.date.strftime('%d.%m.%Y'), hints.record_e1rm) }} - {{ stat_card('Последний e1RM', num(progress.last_primary) ~ ' кг', - change(progress.primary_change, progress.primary_change_percent, 'кг') if rows|length > 1 else '', - hints.last_e1rm) }} - {{ stat_card('Рекорд тоннажа', num(progress.best_secondary.value) ~ ' кг', progress.best_secondary.date.strftime('%d.%m.%Y'), hints.record_volume) }} - {{ stat_card('Максимальный вес', num(progress.max_weight) ~ ' кг', '', hints.max_weight) }} - {% else %} - {{ stat_card('Лучший подход', num(progress.best_primary.value) ~ ' повт.', progress.best_primary.date.strftime('%d.%m.%Y'), hints.best_reps) }} - {{ stat_card('Лучший подход в последний раз', num(progress.last_primary) ~ ' повт.', - change(progress.primary_change, progress.primary_change_percent, 'повт.') if rows|length > 1 else '', - hints.last_best_reps) }} - {{ stat_card('Рекорд суммы повторов', num(progress.best_secondary.value), progress.best_secondary.date.strftime('%d.%m.%Y'), hints.record_total_reps) }} - {% endif %} -
    - -
    -
    -
    -
    -
    - -

    - {% if progress.weighted %} - e1RM — расчётный максимум на один повтор по формуле Эпли: вес × (1 + повторы / 30), берётся лучший подход - тренировки; подходы без веса не учитываются. Тоннаж — сумма вес × повторы по подходам с весом. - {% else %} - Упражнение выполняется без дополнительного веса, поэтому прогресс считается по повторам. - {% endif %} -

    - -
    - - - - - - - {% if progress.weighted %} - - - - {% else %} - - - {% endif %} - - - - {% for row in rows %} - {% set item = row.session %} - {% set m = row.metrics %} - - {# data-order: ISO date and plain numbers, so sorting is chronological/numeric #} - - - - {% if progress.weighted %} - - - - {% else %} - - - {% endif %} - - {% endfor %} - -
    ДатаТренерПодходы (вес × повторения)Макс. вес{{ tip('e1RM', hints.e1rm) }}{{ tip('Тоннаж', hints.volume) }}Лучший подходВсего повторов
    {{ item.date.strftime('%d.%m.%Y') }}{{ item.trainer or '' }} - {{ approach_chips(item.approaches) }} - {% if item.splitted_weight %} - на руку - {% endif %} - {% if m.max_weight %}{{ num(m.max_weight) }} кг{% else %}—{% endif %}{% if m.e1rm %}{{ num(m.e1rm) }} кг{% else %}—{% endif %}{% if m.volume %}{{ num(m.volume) }} кг{% else %}—{% endif %}{{ m.best_reps }}{{ m.total_reps }}
    -
    -{% elif query %} -
    - Упражнение «{{ query }}» не найдено. - {% if suggestions %}Возможно, вы искали:{% endif %} -
    - {% if suggestions %} -
    - {% for item in suggestions %} - - {{ item.name }} - {{ item.count }} - - {% endfor %} -
    - {% endif %} -{% elif not exercise_names %} -
    - В базе пока нет тренировок. Загрузи их из заметок командой: -
    docker compose exec fastapi-app python -m app.cli import -
    -{% endif %} -{% endblock %} - -{% block extra_scripts %} -{% if chart %}{% endif %} -{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index df497e4..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base.html" %} -{% from "_trainings_table.html" import trainings_table %} - -{% block content %} -

    Все тренировки

    - -{% if trainings %} - {{ trainings_table(trainings) }} -{% else %} -
    - В базе пока нет тренировок. Загрузи их из заметок командой: -
    docker compose exec fastapi-app python -m app.cli import -
    -{% endif %} -{% endblock %} diff --git a/app/templates/obsidian/parser.html b/app/templates/obsidian/parser.html deleted file mode 100644 index 8360bf2..0000000 --- a/app/templates/obsidian/parser.html +++ /dev/null @@ -1,49 +0,0 @@ -{% extends "base.html" %} {% block content %} -

    Parse training data

    -
    -
    - - -
    - -
    - -
    -
    -
    -{% if result %} -
    -

    Result:

    -
    {{ result }}
    -
    -{% endif %} - - -{% endblock %} diff --git a/app/templates/trainings.html b/app/templates/trainings.html deleted file mode 100644 index 451000b..0000000 --- a/app/templates/trainings.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "base.html" %} -{% from "_trainings_table.html" import trainings_table %} - -{% block content %} -

    Результат разбора

    - -{% if trainings %} - {{ trainings_table(trainings) }} -{% else %} -

    Тренировки не найдены

    -{% endif %} -{% endblock %} diff --git a/.dockerignore b/backend/.dockerignore similarity index 100% rename from .dockerignore rename to backend/.dockerignore diff --git a/Dockerfile b/backend/Dockerfile similarity index 100% rename from Dockerfile rename to backend/Dockerfile diff --git a/alembic.ini b/backend/alembic.ini similarity index 100% rename from alembic.ini rename to backend/alembic.ini diff --git a/app/__init__.py b/backend/app/__init__.py similarity index 100% rename from app/__init__.py rename to backend/app/__init__.py diff --git a/app/api/__init__.py b/backend/app/api/__init__.py similarity index 100% rename from app/api/__init__.py rename to backend/app/api/__init__.py diff --git a/app/api/deps.py b/backend/app/api/deps.py similarity index 100% rename from app/api/deps.py rename to backend/app/api/deps.py diff --git a/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py similarity index 100% rename from app/api/v1/__init__.py rename to backend/app/api/v1/__init__.py diff --git a/backend/app/api/v1/exercises.py b/backend/app/api/v1/exercises.py new file mode 100644 index 0000000..1a81bd2 --- /dev/null +++ b/backend/app/api/v1/exercises.py @@ -0,0 +1,71 @@ +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException + +from app.api.deps import get_training_repository +from app.core.database.repository import TrainingRepository +from app.core.dto.training import ExerciseNameDTO +from app.core.metrics import Record, summarize +from app.schemas.exercises import ( + ExerciseProgressResponse, + RecordResponse, + SessionMetricsResponse, + SessionRowResponse, +) + +router = APIRouter() + + +def record_response(record: Optional[Record]) -> Optional[RecordResponse]: + return RecordResponse(value=record.value, date=record.date) if record else None + + +@router.get("/", response_model=List[ExerciseNameDTO]) +async def list_exercise_names( + repository: TrainingRepository = Depends(get_training_repository), +): + """Distinct exercise names with the number of times each was done.""" + return await repository.list_exercise_names() + + +@router.get("/progress/", response_model=ExerciseProgressResponse) +async def exercise_progress( + name: str, + repository: TrainingRepository = Depends(get_training_repository), +): + """The whole history of the exercise with progress metrics. + + The name is matched exactly, but case-insensitively: what was typed may differ in case. + """ + names_by_casefold = { + item.name.casefold(): item.name + for item in await repository.list_exercise_names() + } + exact_name = names_by_casefold.get(name.strip().casefold()) + if exact_name is None: + raise HTTPException(status_code=404, detail="Exercise not found") + + progress = summarize(await repository.exercise_history(exact_name)) + return ExerciseProgressResponse( + name=exact_name, + weighted=progress.weighted, + max_weight=progress.max_weight, + best_primary=record_response(progress.best_primary), + best_secondary=record_response(progress.best_secondary), + first_primary=progress.first_primary, + last_primary=progress.last_primary, + primary_change=progress.primary_change, + primary_change_percent=progress.primary_change_percent, + rows=[ + SessionRowResponse( + date=row.session.date, + trainer=row.session.trainer, + splitted_weight=row.session.splitted_weight, + approaches=row.session.approaches, + metrics=SessionMetricsResponse.model_validate( + row.metrics, from_attributes=True + ), + ) + for row in progress.rows + ], + ) diff --git a/app/api/v1/endpoints/health.py b/backend/app/api/v1/health.py similarity index 100% rename from app/api/v1/endpoints/health.py rename to backend/app/api/v1/health.py diff --git a/app/api/v1/endpoints/parser.py b/backend/app/api/v1/parser.py similarity index 90% rename from app/api/v1/endpoints/parser.py rename to backend/app/api/v1/parser.py index 3646519..e998f69 100644 --- a/app/api/v1/endpoints/parser.py +++ b/backend/app/api/v1/parser.py @@ -1,5 +1,4 @@ from fastapi import APIRouter, HTTPException -from typing import List from app.schemas.parser import ParseRequest, ParseResponse from app.core.services.parser_service import ParserService @@ -7,7 +6,7 @@ router = APIRouter() parser_service = ParserService() -@router.post("/parse/", response_model=List[ParseResponse]) +@router.post("/parse/", response_model=list[ParseResponse]) async def parse_training_data(request: ParseRequest): try: result = await parser_service.parse_data(request) diff --git a/app/api/v1/api.py b/backend/app/api/v1/router.py similarity index 66% rename from app/api/v1/api.py rename to backend/app/api/v1/router.py index 5cc7f25..01c7990 100644 --- a/app/api/v1/api.py +++ b/backend/app/api/v1/router.py @@ -1,8 +1,9 @@ from fastapi import APIRouter -from app.api.v1.endpoints import parser, health, trainings +from app.api.v1 import exercises, parser, health, trainings api_router = APIRouter() api_router.include_router(health.router, prefix="/health", tags=["health"]) api_router.include_router(parser.router, prefix="/parser", tags=["parser"]) api_router.include_router(trainings.router, prefix="/trainings", tags=["trainings"]) +api_router.include_router(exercises.router, prefix="/exercises", tags=["exercises"]) diff --git a/app/api/v1/endpoints/trainings.py b/backend/app/api/v1/trainings.py similarity index 100% rename from app/api/v1/endpoints/trainings.py rename to backend/app/api/v1/trainings.py diff --git a/app/cli.py b/backend/app/cli.py similarity index 100% rename from app/cli.py rename to backend/app/cli.py diff --git a/app/config.py b/backend/app/config.py similarity index 100% rename from app/config.py rename to backend/app/config.py diff --git a/app/api/v1/endpoints/__init__.py b/backend/app/core/__init__.py similarity index 100% rename from app/api/v1/endpoints/__init__.py rename to backend/app/core/__init__.py diff --git a/app/core/__init__.py b/backend/app/core/database/__init__.py similarity index 100% rename from app/core/__init__.py rename to backend/app/core/database/__init__.py diff --git a/app/core/database/connection.py b/backend/app/core/database/connection.py similarity index 100% rename from app/core/database/connection.py rename to backend/app/core/database/connection.py diff --git a/backend/app/core/database/repository.py b/backend/app/core/database/repository.py new file mode 100644 index 0000000..38dd4dd --- /dev/null +++ b/backend/app/core/database/repository.py @@ -0,0 +1,169 @@ +from collections import defaultdict +from typing import Dict, List, Optional, Sequence + +from sqlalchemy import delete, func, insert, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database.tables import approaches, exercises, trainings +from app.core.dto.training import ( + ApproachDTO, + ExerciseDTO, + ExerciseNameDTO, + ExerciseSessionDTO, + TrainingDTO, +) + + +class TrainingRepository: + """Storage for trainings together with their exercises and approaches (SQLAlchemy Core).""" + + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def save(self, training: TrainingDTO) -> int: + return (await self.save_many([training]))[0] + + async def save_many(self, items: Sequence[TrainingDTO]) -> List[int]: + """Save all trainings in a single transaction, returns their ids.""" + if not items: + return [] + training_ids = await self._insert_returning_ids( + trainings, + [{"date": item.date, "trainer": item.trainer} for item in items], + ) + + exercise_rows = [] + exercise_dtos = [] + for training_id, item in zip(training_ids, items): + for exercise in item.exercises: + exercise_rows.append( + { + "training_id": training_id, + "name": exercise.name, + "splitted_weight": exercise.splitted_weight, + } + ) + exercise_dtos.append(exercise) + if exercise_rows: + exercise_ids = await self._insert_returning_ids(exercises, exercise_rows) + approach_rows = [ + {"exercise_id": exercise_id, "weight": a.weight, "reps": a.reps} + for exercise_id, exercise in zip(exercise_ids, exercise_dtos) + for a in exercise.approaches + ] + if approach_rows: + await self._session.execute(insert(approaches), approach_rows) + + await self._session.commit() + return training_ids + + async def list(self) -> List[TrainingDTO]: + training_rows = ( + await self._session.execute( + select(trainings).order_by(trainings.c.date.desc(), trainings.c.id.desc()) + ) + ).all() + approaches_by_exercise = await self._approaches_by_exercise() + exercises_by_training: Dict[int, List[ExerciseDTO]] = defaultdict(list) + exercise_rows = await self._session.execute( + select(exercises).order_by(exercises.c.id) + ) + for row in exercise_rows: + exercises_by_training[row.training_id].append( + ExerciseDTO( + id=row.id, + training_id=row.training_id, + name=row.name, + splitted_weight=row.splitted_weight, + approaches=approaches_by_exercise[row.id], + ) + ) + return [ + TrainingDTO( + id=row.id, + date=row.date, + trainer=row.trainer, + exercises=exercises_by_training[row.id], + ) + for row in training_rows + ] + + async def list_exercise_names(self) -> List[ExerciseNameDTO]: + """Distinct exercise names with the number of times each was done.""" + query = select( + exercises.c.name, func.count(exercises.c.id).label("times") + ).group_by(exercises.c.name) + result = await self._session.execute(query) + names = [ExerciseNameDTO(name=row.name, count=row.times) for row in result] + # Sorted here, not in SQL: the order must not depend on DB collation + return sorted(names, key=lambda item: item.name.casefold()) + + async def exercise_history(self, name: str) -> List[ExerciseSessionDTO]: + """Every occurrence of the exercise, newest first.""" + query = ( + select( + exercises.c.id, + exercises.c.splitted_weight, + trainings.c.date, + trainings.c.trainer, + ) + .join(trainings, trainings.c.id == exercises.c.training_id) + .where(exercises.c.name == name) + .order_by(trainings.c.date.desc(), exercises.c.id.desc()) + ) + rows = (await self._session.execute(query)).all() + approaches_by_exercise = await self._approaches_by_exercise( + [row.id for row in rows] + ) + return [ + ExerciseSessionDTO( + date=row.date, + trainer=row.trainer, + splitted_weight=row.splitted_weight, + approaches=approaches_by_exercise[row.id], + ) + for row in rows + ] + + async def count(self) -> int: + return await self._session.scalar(select(func.count(trainings.c.id))) or 0 + + async def delete(self, list_of_ids: List[int]) -> int: + """Delete trainings by ids (exercises and approaches go with them).""" + result = await self._session.execute( + delete(trainings).where(trainings.c.id.in_(list_of_ids)) + ) + await self._session.commit() + return result.rowcount # type: ignore[attr-defined] + + async def delete_all(self) -> None: + await self._session.execute( + text("TRUNCATE trainings RESTART IDENTITY CASCADE") + ) + await self._session.commit() + + async def _insert_returning_ids(self, table, rows: List[dict]) -> List[int]: + """Bulk insert, ids come back in the order of `rows`.""" + result = await self._session.execute( + insert(table).returning(table.c.id, sort_by_parameter_order=True), rows + ) + return list(result.scalars()) + + async def _approaches_by_exercise( + self, exercise_ids: Optional[Sequence[int]] = None + ) -> Dict[int, List[ApproachDTO]]: + """Approaches grouped by exercise id (all of them when no ids are given).""" + query = select(approaches).order_by(approaches.c.id) + if exercise_ids is not None: + query = query.where(approaches.c.exercise_id.in_(exercise_ids)) + grouped: Dict[int, List[ApproachDTO]] = defaultdict(list) + for row in await self._session.execute(query): + grouped[row.exercise_id].append( + ApproachDTO( + id=row.id, + exercise_id=row.exercise_id, + weight=row.weight, + reps=row.reps, + ) + ) + return grouped diff --git a/backend/app/core/database/tables.py b/backend/app/core/database/tables.py new file mode 100644 index 0000000..6b66284 --- /dev/null +++ b/backend/app/core/database/tables.py @@ -0,0 +1,54 @@ +from sqlalchemy import ( + Boolean, + Column, + Date, + DateTime, + Float, + ForeignKey, + Integer, + MetaData, + String, + Table, + func, +) + +metadata = MetaData() + +trainings = Table( + "trainings", + metadata, + Column("id", Integer, primary_key=True), + Column("date", Date, nullable=False, index=True), + Column("trainer", String(100)), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), +) + +exercises = Table( + "exercises", + metadata, + Column("id", Integer, primary_key=True), + Column( + "training_id", + ForeignKey("trainings.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + Column("name", String(255), nullable=False, index=True), + Column("splitted_weight", Boolean, nullable=False, server_default="false"), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), +) + +approaches = Table( + "approaches", + metadata, + Column("id", Integer, primary_key=True), + Column( + "exercise_id", + ForeignKey("exercises.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + Column("weight", Float, nullable=False), + Column("reps", Integer, nullable=False), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()), +) diff --git a/app/core/database/__init__.py b/backend/app/core/dto/__init__.py similarity index 100% rename from app/core/database/__init__.py rename to backend/app/core/dto/__init__.py diff --git a/app/core/dto/training.py b/backend/app/core/dto/training.py similarity index 59% rename from app/core/dto/training.py rename to backend/app/core/dto/training.py index 88979ce..c9e10e8 100644 --- a/app/core/dto/training.py +++ b/backend/app/core/dto/training.py @@ -1,4 +1,3 @@ -from typing import List, Optional from pydantic import BaseModel, ConfigDict, Field from datetime import date @@ -9,25 +8,25 @@ class DTOBase(BaseModel): class ApproachDTO(DTOBase): - id: Optional[int] = None - exercise_id: Optional[int] = None + id: int | None = None + exercise_id: int | None = None weight: float reps: int class ExerciseDTO(DTOBase): - id: Optional[int] = None - training_id: Optional[int] = None + id: int | None = None + training_id: int | None = None name: str splitted_weight: bool = False - approaches: List[ApproachDTO] = Field(default_factory=list) + approaches: list[ApproachDTO] = Field(default_factory=list) class TrainingDTO(DTOBase): - id: Optional[int] = None + id: int | None = None date: date - trainer: Optional[str] = None - exercises: List[ExerciseDTO] = Field(default_factory=list) + trainer: str | None = None + exercises: list[ExerciseDTO] = Field(default_factory=list) class ExerciseNameDTO(DTOBase): @@ -41,6 +40,6 @@ class ExerciseSessionDTO(DTOBase): """One occurrence of an exercise in the history of that exercise.""" date: date - trainer: Optional[str] = None + trainer: str | None = None splitted_weight: bool = False - approaches: List[ApproachDTO] = Field(default_factory=list) + approaches: list[ApproachDTO] = Field(default_factory=list) diff --git a/app/core/metrics.py b/backend/app/core/metrics.py similarity index 100% rename from app/core/metrics.py rename to backend/app/core/metrics.py diff --git a/app/core/dto/__init__.py b/backend/app/core/parsers/__init__.py similarity index 100% rename from app/core/dto/__init__.py rename to backend/app/core/parsers/__init__.py diff --git a/app/core/parsers/apple.py b/backend/app/core/parsers/apple.py similarity index 100% rename from app/core/parsers/apple.py rename to backend/app/core/parsers/apple.py diff --git a/app/core/parsers/apple_mapper.py b/backend/app/core/parsers/apple_mapper.py similarity index 100% rename from app/core/parsers/apple_mapper.py rename to backend/app/core/parsers/apple_mapper.py diff --git a/app/core/parsers/base.py b/backend/app/core/parsers/base.py similarity index 100% rename from app/core/parsers/base.py rename to backend/app/core/parsers/base.py diff --git a/app/core/parsers/obsidian.py b/backend/app/core/parsers/obsidian.py similarity index 100% rename from app/core/parsers/obsidian.py rename to backend/app/core/parsers/obsidian.py diff --git a/app/core/parsers/obsidian_mapper.py b/backend/app/core/parsers/obsidian_mapper.py similarity index 100% rename from app/core/parsers/obsidian_mapper.py rename to backend/app/core/parsers/obsidian_mapper.py diff --git a/app/core/parsers/text_data.py b/backend/app/core/parsers/text_data.py similarity index 100% rename from app/core/parsers/text_data.py rename to backend/app/core/parsers/text_data.py diff --git a/app/core/parsers/__init__.py b/backend/app/core/services/__init__.py similarity index 100% rename from app/core/parsers/__init__.py rename to backend/app/core/services/__init__.py diff --git a/app/core/services/parser_service.py b/backend/app/core/services/parser_service.py similarity index 100% rename from app/core/services/parser_service.py rename to backend/app/core/services/parser_service.py diff --git a/app/core/services/__init__.py b/backend/app/core/utils/__init__.py similarity index 100% rename from app/core/services/__init__.py rename to backend/app/core/utils/__init__.py diff --git a/app/core/utils/date_refactor.py b/backend/app/core/utils/date_refactor.py similarity index 100% rename from app/core/utils/date_refactor.py rename to backend/app/core/utils/date_refactor.py diff --git a/app/core/utils/enums.py b/backend/app/core/utils/enums.py similarity index 100% rename from app/core/utils/enums.py rename to backend/app/core/utils/enums.py diff --git a/app/main.py b/backend/app/main.py similarity index 75% rename from app/main.py rename to backend/app/main.py index 9c96fe8..051a8bc 100644 --- a/app/main.py +++ b/backend/app/main.py @@ -2,11 +2,8 @@ import logging from contextlib import asynccontextmanager from fastapi import FastAPI -from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from app.api.v1.api import api_router -from app.api.v1.web import web_router +from app.api.v1.router import api_router from app.config import settings from app.core.database.connection import create_engine, create_session_factory @@ -33,13 +30,8 @@ app = FastAPI( lifespan=lifespan, ) -app.mount("/static", StaticFiles(directory="app/static"), name="static") - -templates = Jinja2Templates(directory="app/templates") - # Include routers app.include_router(api_router, prefix="/api/v1") -app.include_router(web_router, prefix="/app") @app.get("/") diff --git a/app/sandbox.py b/backend/app/sandbox.py similarity index 100% rename from app/sandbox.py rename to backend/app/sandbox.py diff --git a/app/core/templates/__init__.py b/backend/app/schemas/__init__.py similarity index 100% rename from app/core/templates/__init__.py rename to backend/app/schemas/__init__.py diff --git a/backend/app/schemas/exercises.py b/backend/app/schemas/exercises.py new file mode 100644 index 0000000..a9c39ab --- /dev/null +++ b/backend/app/schemas/exercises.py @@ -0,0 +1,40 @@ +from datetime import date +from typing import List, Optional + +from pydantic import BaseModel + +from app.core.dto.training import ApproachDTO + + +class SessionMetricsResponse(BaseModel): + max_weight: float + e1rm: Optional[float] + volume: float + total_reps: int + best_reps: int + + +class SessionRowResponse(BaseModel): + date: date + trainer: Optional[str] + splitted_weight: bool + approaches: List[ApproachDTO] + metrics: SessionMetricsResponse + + +class RecordResponse(BaseModel): + value: float + date: date + + +class ExerciseProgressResponse(BaseModel): + name: str + weighted: bool + max_weight: float + best_primary: Optional[RecordResponse] + best_secondary: Optional[RecordResponse] + first_primary: Optional[float] + last_primary: Optional[float] + primary_change: Optional[float] + primary_change_percent: Optional[float] + rows: List[SessionRowResponse] # newest first diff --git a/app/schemas/parser.py b/backend/app/schemas/parser.py similarity index 86% rename from app/schemas/parser.py rename to backend/app/schemas/parser.py index a9492c6..1ffcdea 100644 --- a/app/schemas/parser.py +++ b/backend/app/schemas/parser.py @@ -28,4 +28,5 @@ class ExerciseResponse(BaseModel): class ParseResponse(BaseModel): date: date + trainer: Optional[str] = None exercises: Optional[List[ExerciseResponse]] \ No newline at end of file diff --git a/data/Anomalies.md b/backend/data/Anomalies.md similarity index 100% rename from data/Anomalies.md rename to backend/data/Anomalies.md diff --git a/data/apple.md b/backend/data/apple.md similarity index 100% rename from data/apple.md rename to backend/data/apple.md diff --git a/data/apple_2.md b/backend/data/apple_2.md similarity index 100% rename from data/apple_2.md rename to backend/data/apple_2.md diff --git a/data/apple_3.md b/backend/data/apple_3.md similarity index 100% rename from data/apple_3.md rename to backend/data/apple_3.md diff --git a/data/apple_4.md b/backend/data/apple_4.md similarity index 100% rename from data/apple_4.md rename to backend/data/apple_4.md diff --git a/data/apple_5.md b/backend/data/apple_5.md similarity index 100% rename from data/apple_5.md rename to backend/data/apple_5.md diff --git a/data/apple_full.md b/backend/data/apple_full.md similarity index 100% rename from data/apple_full.md rename to backend/data/apple_full.md diff --git a/data/apple_original_backup.md b/backend/data/apple_original_backup.md similarity index 100% rename from data/apple_original_backup.md rename to backend/data/apple_original_backup.md diff --git a/data/complete_data/apple.md b/backend/data/complete_data/apple.md similarity index 100% rename from data/complete_data/apple.md rename to backend/data/complete_data/apple.md diff --git a/data/complete_data/obsidian.md b/backend/data/complete_data/obsidian.md similarity index 100% rename from data/complete_data/obsidian.md rename to backend/data/complete_data/obsidian.md diff --git a/data/obsidian.md b/backend/data/obsidian.md similarity index 100% rename from data/obsidian.md rename to backend/data/obsidian.md diff --git a/data/obsidian_original_backup.md b/backend/data/obsidian_original_backup.md similarity index 100% rename from data/obsidian_original_backup.md rename to backend/data/obsidian_original_backup.md diff --git a/data/test.py b/backend/data/test.py similarity index 100% rename from data/test.py rename to backend/data/test.py diff --git a/dump_trainings.py b/backend/dump_trainings.py similarity index 100% rename from dump_trainings.py rename to backend/dump_trainings.py diff --git a/migrations/README b/backend/migrations/README similarity index 100% rename from migrations/README rename to backend/migrations/README diff --git a/migrations/env.py b/backend/migrations/env.py similarity index 97% rename from migrations/env.py rename to backend/migrations/env.py index 123bfca..115e949 100644 --- a/migrations/env.py +++ b/backend/migrations/env.py @@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context from app.config import settings -from app.core.database.models import Base +from app.core.database.tables import metadata # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -27,7 +27,7 @@ if config.config_file_name is not None: # add your model's MetaData object here # for 'autogenerate' support -target_metadata = Base.metadata +target_metadata = metadata # other values from the config, defined by the needs of env.py, # can be acquired: diff --git a/migrations/script.py.mako b/backend/migrations/script.py.mako similarity index 100% rename from migrations/script.py.mako rename to backend/migrations/script.py.mako diff --git a/migrations/versions/2026_10_22_2248-08ba8bee6328_initial_schema.py b/backend/migrations/versions/2026_10_22_2248-08ba8bee6328_initial_schema.py similarity index 100% rename from migrations/versions/2026_10_22_2248-08ba8bee6328_initial_schema.py rename to backend/migrations/versions/2026_10_22_2248-08ba8bee6328_initial_schema.py diff --git a/pyproject.toml b/backend/pyproject.toml similarity index 100% rename from pyproject.toml rename to backend/pyproject.toml diff --git a/requirements-dev.txt b/backend/requirements-dev.txt similarity index 100% rename from requirements-dev.txt rename to backend/requirements-dev.txt diff --git a/requirements.txt b/backend/requirements.txt similarity index 80% rename from requirements.txt rename to backend/requirements.txt index 38e5b5a..582eb64 100644 --- a/requirements.txt +++ b/backend/requirements.txt @@ -3,8 +3,6 @@ uvicorn[standard]==0.35.0 pydantic==2.11.7 pydantic-settings==2.10.1 python-dotenv==1.1.1 -jinja2==3.1.6 -python-multipart==0.0.20 sqlalchemy[asyncio]==2.1.6 asyncpg==0.32.0 alembic==1.20.1 diff --git a/run.py b/backend/run.py similarity index 100% rename from run.py rename to backend/run.py diff --git a/app/core/utils/__init__.py b/backend/tests/__init__.py similarity index 100% rename from app/core/utils/__init__.py rename to backend/tests/__init__.py diff --git a/tests/conftest.py b/backend/tests/conftest.py similarity index 100% rename from tests/conftest.py rename to backend/tests/conftest.py diff --git a/app/schemas/__init__.py b/backend/tests/test_core/__init__.py similarity index 100% rename from app/schemas/__init__.py rename to backend/tests/test_core/__init__.py diff --git a/tests/test_core/test_metrics.py b/backend/tests/test_core/test_metrics.py similarity index 100% rename from tests/test_core/test_metrics.py rename to backend/tests/test_core/test_metrics.py diff --git a/tests/test_core/test_notes_parsing.py b/backend/tests/test_core/test_notes_parsing.py similarity index 100% rename from tests/test_core/test_notes_parsing.py rename to backend/tests/test_core/test_notes_parsing.py diff --git a/tests/test_core/test_obsidian_parser.py b/backend/tests/test_core/test_obsidian_parser.py similarity index 100% rename from tests/test_core/test_obsidian_parser.py rename to backend/tests/test_core/test_obsidian_parser.py diff --git a/tests/test_core/test_repository.py b/backend/tests/test_core/test_repository.py similarity index 95% rename from tests/test_core/test_repository.py rename to backend/tests/test_core/test_repository.py index 482f7fa..122fffc 100644 --- a/tests/test_core/test_repository.py +++ b/backend/tests/test_core/test_repository.py @@ -12,7 +12,7 @@ from alembic import command from sqlalchemy import func, select from sqlalchemy.exc import DBAPIError -from app.core.database.models import ApproachModel, ExerciseModel +from app.core.database.tables import approaches, exercises from app.core.database.repository import TrainingRepository from app.core.dto.training import ApproachDTO, ExerciseDTO, TrainingDTO from app.core.parsers.apple import AppleNotesParser @@ -114,8 +114,8 @@ async def test_delete_cascades_to_exercises_and_approaches(session): assert await repository.delete([delete_id]) == 1 assert [t.id for t in await repository.list()] == [keep_id] - assert await session.scalar(select(func.count(ExerciseModel.id))) == 2 - assert await session.scalar(select(func.count(ApproachModel.id))) == 3 + assert await session.scalar(select(func.count(exercises.c.id))) == 2 + assert await session.scalar(select(func.count(approaches.c.id))) == 3 async def test_save_many_is_atomic(session): diff --git a/compose.override.yaml b/compose.override.yaml index ee2ef0a..4826c2a 100644 --- a/compose.override.yaml +++ b/compose.override.yaml @@ -7,9 +7,9 @@ services: develop: watch: - action: sync - path: ./app + path: ./backend/app target: /app/app - action: rebuild - path: ./requirements.txt + path: ./backend/requirements.txt - action: rebuild - path: ./requirements-dev.txt + path: ./backend/requirements-dev.txt diff --git a/compose.yaml b/compose.yaml index 58261ee..f51b544 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,16 +1,16 @@ services: fastapi-app: build: - context: . + context: ./backend dockerfile: Dockerfile ports: - "8000:8000" volumes: - - ./app:/app/app:ro - - ./data:/app/data:ro - - ./tests:/app/tests:ro + - ./backend/app:/app/app:ro + - ./backend/data:/app/data:ro + - ./backend/tests:/app/tests:ro # Writable: `alembic revision --autogenerate` creates files in migrations/versions - - ./migrations:/app/migrations + - ./backend/migrations:/app/migrations env_file: - .env environment: @@ -21,6 +21,21 @@ services: depends_on: postgres: condition: service_healthy + frontend: + build: + context: ./frontend + ports: + - "8080:80" + volumes: + # Static files are served as they are: edits show up on page reload + - ./frontend/index.html:/usr/share/nginx/html/index.html:ro + - ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro + - ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro + - ./frontend/css:/usr/share/nginx/html/css:ro + - ./frontend/js:/usr/share/nginx/html/js:ro + - ./frontend/vendor:/usr/share/nginx/html/vendor:ro + depends_on: + - fastapi-app postgres: image: postgres:15-alpine env_file: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..fac5ce7 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +.git +*.md diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..4abba62 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,7 @@ +FROM nginx:1.27-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY index.html exercises.html parse.html /usr/share/nginx/html/ +COPY css /usr/share/nginx/html/css +COPY js /usr/share/nginx/html/js +COPY vendor /usr/share/nginx/html/vendor diff --git a/app/static/css/main.css b/frontend/css/main.css similarity index 100% rename from app/static/css/main.css rename to frontend/css/main.css diff --git a/frontend/exercises.html b/frontend/exercises.html new file mode 100644 index 0000000..a6ab423 --- /dev/null +++ b/frontend/exercises.html @@ -0,0 +1,43 @@ + + + + + + Упражнения - Fitness Parser + + + + + + +
    + + + +
    +

    История упражнения

    +
    + +
    + + +
    + +
    +
    +
    +
    + + + + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5b3f623 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,34 @@ + + + + + + Тренировки - Fitness Parser + + + + + + +
    + + + +
    +

    Все упражнения

    +
    Загрузка…
    +
    +
    + + + + + + diff --git a/frontend/js/common.js b/frontend/js/common.js new file mode 100644 index 0000000..276653d --- /dev/null +++ b/frontend/js/common.js @@ -0,0 +1,62 @@ +// Shared helpers: API access, DOM building, formatting + +const API = '/api/v1'; + +export async function api(path, options) { + const response = await fetch(API + path, options); + if (!response.ok) { + const error = new Error(`HTTP ${response.status}`); + error.status = response.status; + throw error; + } + return response.json(); +} + +/** h('div', {class: 'x'}, 'text', childNode) -> element; strings become text nodes */ +export function h(tag, attrs = {}, ...children) { + const element = document.createElement(tag); + for (const [name, value] of Object.entries(attrs)) { + if (value !== null && value !== undefined && value !== false) { + element.setAttribute(name, value === true ? '' : value); + } + } + element.append(...children.filter((child) => child !== null && child !== undefined)); + return element; +} + +export function alert(kind, ...children) { + return h('div', { class: `alert alert-${kind}` }, ...children); +} + +/** "2026-10-21" -> "21.10.2026" (no Date: a timezone must not shift the day) */ +export function formatDate(iso) { + const [year, month, day] = iso.split('-'); + return `${day}.${month}.${year}`; +} + +/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */ +export function num(value) { + return String(Math.round(value * 10) / 10); +} + +/** Same with an explicit sign: "+12", "-3.5", "+0" */ +export function signed(value) { + const rounded = Math.round(value * 10) / 10; + return (rounded > 0 ? '+' : '') + rounded; +} + +/** Approaches as "weight × reps" chips */ +export function approachChips(approaches) { + return approaches.map((approach) => + h('span', { class: 'approach' }, + `${approach.weight ? num(approach.weight) + ' кг' : 'без веса'} × ${approach.reps}`) + ); +} + +export function perArmBadge() { + return h('span', { class: 'badge text-bg-light border', title: 'Вес указан на каждую руку (гантель)' }, 'на руку'); +} + +export function tip(text, hint) { + return h('span', { class: 'has-tip', title: hint }, text); +} diff --git a/frontend/js/exercises.js b/frontend/js/exercises.js new file mode 100644 index 0000000..90dee4d --- /dev/null +++ b/frontend/js/exercises.js @@ -0,0 +1,187 @@ +import { alert, api, approachChips, formatDate, h, num, perArmBadge, signed, tip } from './common.js'; +import { createTable } from './table.js'; + +const HINTS = { + recordE1rm: 'e1RM — расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Рекорд — наибольшее значение за всю историю.', + lastE1rm: 'e1RM лучшего подхода последней тренировки. Ниже — изменение относительно первой тренировки с этим упражнением.', + recordVolume: 'Тоннаж — суммарная работа за тренировку: сумма вес × повторы по всем подходам с весом. Рекорд — тренировка с наибольшим тоннажем.', + maxWeight: 'Самый тяжёлый вес в одном подходе за всю историю, без учёта повторов.', + bestReps: 'Наибольшее число повторов в одном подходе за всю историю.', + lastBestReps: 'Наибольшее число повторов в одном подходе последней тренировки. Ниже — изменение относительно первой тренировки.', + recordTotalReps: 'Наибольшая сумма повторов по всем подходам за одну тренировку.', + e1rm: 'Расчётный максимум на один повтор по лучшему подходу тренировки (формула Эпли: вес × (1 + повторы / 30)). Подходы без веса не учитываются.', + volume: 'Тоннаж: сумма вес × повторы по подходам с весом за тренировку.', +}; + +const input = document.getElementById('exercise-input'); +const container = document.getElementById('exercise'); +const query = (new URLSearchParams(location.search).get('name') || '').trim(); + +let names = []; +try { + names = await api('/exercises/'); +} catch (error) { + container.replaceChildren(alert('danger', `Не удалось загрузить упражнения: ${error.message}`)); +} + +document.getElementById('exercise-names').replaceChildren(...names.map((item) => h('option', { value: item.name }))); +input.value = query; +if (!query) { + input.focus(); +} + +// Open the history right after a suggestion is picked from the list. Typing, pasting and +// deleting have their own inputType, so a half-typed name that equals a shorter exercise +// name doesn't submit early; Enter/button always work. +const knownNames = new Set(names.map((item) => item.name)); +input.addEventListener('input', (event) => { + const picked = !event.inputType || event.inputType === 'insertReplacementText'; + if (picked && knownNames.has(input.value)) { + input.form.submit(); + } +}); + +if (query) { + await showProgress(); +} else if (!names.length && !container.children.length) { + container.replaceChildren(alert('info', + 'В базе пока нет тренировок. Загрузи их из заметок командой:', + h('br'), h('code', {}, 'docker compose exec fastapi-app python -m app.cli import'))); +} + +async function showProgress() { + let progress; + try { + progress = await api(`/exercises/progress/?name=${encodeURIComponent(query)}`); + } catch (error) { + if (error.status !== 404) { + container.replaceChildren(alert('danger', `Не удалось загрузить историю: ${error.message}`)); + return; + } + // Nothing matched exactly: offer the names containing the typed text + const suggestions = names.filter((item) => item.name.toLowerCase().includes(query.toLowerCase())); + container.replaceChildren( + alert('warning', `Упражнение «${query}» не найдено. ${suggestions.length ? 'Возможно, вы искали:' : ''}`), + ...(suggestions.length ? [h('div', { class: 'list-group' }, ...suggestions.map((item) => + h('a', { class: 'list-group-item list-group-item-action d-flex justify-content-between align-items-center', href: `exercises.html?name=${encodeURIComponent(item.name)}` }, + item.name, h('span', { class: 'badge text-bg-secondary rounded-pill' }, `${item.count}`))))] : [])); + return; + } + + input.value = progress.name; + container.replaceChildren(...progressView(progress)); + if (progress.rows.length) { + drawChart(progress); + } +} + +function progressView(progress) { + const { rows, weighted } = progress; + const first = rows[rows.length - 1]; + const last = rows[0]; + + const change = (unit) => h('span', {}, + h('span', { class: progress.primary_change > 0 ? 'text-success' : progress.primary_change < 0 ? 'text-danger' : 'text-muted' }, + `${signed(progress.primary_change)} ${unit}`, + progress.primary_change_percent !== null ? ` (${progress.primary_change_percent >= 0 ? '+' : ''}${progress.primary_change_percent.toFixed(1)}%)` : ''), + ' к первой тренировке'); + + const card = (title, value, note, hint) => h('div', { class: 'col' }, + h('div', { class: 'card h-100 mb-0' }, h('div', { class: 'card-body' }, + h('div', { class: 'text-muted small' }, tip(title, hint)), + h('div', { class: 'fs-4 fw-semibold' }, value), + h('div', { class: 'text-muted small' }, note)))); + + const cards = weighted ? [ + card('Рекорд e1RM', `${num(progress.best_primary.value)} кг`, formatDate(progress.best_primary.date), HINTS.recordE1rm), + card('Последний e1RM', `${num(progress.last_primary)} кг`, rows.length > 1 ? change('кг') : '', HINTS.lastE1rm), + card('Рекорд тоннажа', `${num(progress.best_secondary.value)} кг`, formatDate(progress.best_secondary.date), HINTS.recordVolume), + card('Максимальный вес', `${num(progress.max_weight)} кг`, '', HINTS.maxWeight), + ] : [ + card('Лучший подход', `${num(progress.best_primary.value)} повт.`, formatDate(progress.best_primary.date), HINTS.bestReps), + card('Лучший подход в последний раз', `${num(progress.last_primary)} повт.`, rows.length > 1 ? change('повт.') : '', HINTS.lastBestReps), + card('Рекорд суммы повторов', num(progress.best_secondary.value), formatDate(progress.best_secondary.date), HINTS.recordTotalReps), + ]; + + const columns = [ + { title: 'Дата', className: 'text-nowrap date', sort: (row) => row.date, cell: (row) => formatDate(row.date) }, + { title: 'Тренер', className: 'text-nowrap', sort: (row) => row.trainer || '', cell: (row) => row.trainer || '' }, + { title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...approachChips(row.approaches), row.splitted_weight ? perArmBadge() : null) }, + ...(weighted ? [ + { title: 'Макс. вес', className: 'text-nowrap text-end', sort: (row) => row.metrics.max_weight, cell: (row) => row.metrics.max_weight ? `${num(row.metrics.max_weight)} кг` : '—' }, + { title: tip('e1RM', HINTS.e1rm), className: 'text-nowrap text-end', sort: (row) => row.metrics.e1rm || 0, cell: (row) => row.metrics.e1rm ? `${num(row.metrics.e1rm)} кг` : '—' }, + { title: tip('Тоннаж', HINTS.volume), className: 'text-nowrap text-end', sort: (row) => row.metrics.volume, cell: (row) => row.metrics.volume ? `${num(row.metrics.volume)} кг` : '—' }, + ] : [ + { title: 'Лучший подход', className: 'text-nowrap text-end', sort: (row) => row.metrics.best_reps, cell: (row) => `${row.metrics.best_reps}` }, + { title: 'Всего повторов', className: 'text-nowrap text-end', sort: (row) => row.metrics.total_reps, cell: (row) => `${row.metrics.total_reps}` }, + ]), + ]; + + return [ + h('h2', { class: 'h4' }, progress.name), + h('p', { class: 'text-muted' }, + 'Тренировок: ', h('strong', {}, `${rows.length}`), + ', первая: ', h('strong', {}, formatDate(first.date)), + ', последняя: ', h('strong', {}, formatDate(last.date))), + h('div', { class: 'row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4' }, ...cards), + h('div', { class: 'card mb-2' }, h('div', { class: 'card-body' }, + h('div', { style: 'height: 320px' }, h('canvas', { id: 'progress-chart' })))), + h('p', { class: 'text-muted small mb-4' }, weighted + ? 'e1RM — расчётный максимум на один повтор по формуле Эпли: вес × (1 + повторы / 30), берётся лучший подход тренировки; подходы без веса не учитываются. Тоннаж — сумма вес × повторы по подходам с весом.' + : 'Упражнение выполняется без дополнительного веса, поэтому прогресс считается по повторам.'), + createTable({ id: 'exercise-history-table', columns, rows }), + ]; +} + +/** Two series over time, each on its own y axis. The x axis is linear so gaps between + * trainings are proportional to the real time. */ +function drawChart(progress) { + const series = progress.weighted + ? [['e1RM, кг', (row) => row.metrics.e1rm], ['Тоннаж, кг', (row) => row.metrics.volume || null]] + : [['Лучший подход, повторы', (row) => row.metrics.best_reps], ['Всего повторов за тренировку', (row) => row.metrics.total_reps]]; + const chronological = [...progress.rows].reverse(); + const datasets = series.map(([label, metric]) => ({ + label, + points: chronological + .map((row) => ({ x: Date.parse(row.date), y: metric(row) })) // "YYYY-MM-DD" parses as UTC + .filter((point) => point.y !== null && point.y !== undefined) + .map((point) => ({ x: point.x, y: Math.round(point.y * 10) / 10 })), + })); + + const colors = ['#0d6efd', '#fd7e14']; + const formatTick = (timestamp, options) => + new Date(timestamp).toLocaleDateString('ru-RU', { ...options, timeZone: 'UTC' }); + + const scales = { + x: { type: 'linear', ticks: { maxTicksLimit: 8, callback: (value) => formatTick(value, { month: 'short', year: 'numeric' }) } }, + }; + datasets.forEach((item, index) => { + scales['y' + index] = { + position: index === 0 ? 'left' : 'right', + grid: { drawOnChartArea: index === 0 }, + title: { display: true, text: item.label, color: colors[index] }, + }; + }); + + new Chart(document.getElementById('progress-chart'), { + type: 'line', + data: { + datasets: datasets.map((item, index) => ({ + label: item.label, + data: item.points, + yAxisID: 'y' + index, + borderColor: colors[index], + backgroundColor: colors[index], + borderWidth: 2, + pointRadius: 3, + tension: 0.2, + })), + }, + options: { + maintainAspectRatio: false, + interaction: { mode: 'nearest', axis: 'x', intersect: false }, + scales, + plugins: { tooltip: { callbacks: { title: (items) => formatTick(items[0].parsed.x, {}) } } }, + }, + }); +} diff --git a/frontend/js/parse.js b/frontend/js/parse.js new file mode 100644 index 0000000..37e1a0c --- /dev/null +++ b/frontend/js/parse.js @@ -0,0 +1,23 @@ +import { alert, api } from './common.js'; +import { trainingsView } from './trainings-view.js'; + +const form = document.getElementById('parse-form'); +const result = document.getElementById('result'); + +form.addEventListener('submit', async (event) => { + event.preventDefault(); + result.replaceChildren(alert('info', 'Разбираю…')); + try { + const trainings = await api('/parser/parse/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + data: document.getElementById('training-data').value, + format: document.getElementById('format').value, + }), + }); + result.replaceChildren(trainings.length ? trainingsView(trainings) : alert('warning', 'Тренировки не найдены')); + } catch (error) { + result.replaceChildren(alert('danger', `Не удалось разобрать заметку: ${error.message}`)); + } +}); diff --git a/frontend/js/table.js b/frontend/js/table.js new file mode 100644 index 0000000..0462fc1 --- /dev/null +++ b/frontend/js/table.js @@ -0,0 +1,33 @@ +// Table with search, sorting and paging by DataTables (no jQuery: DataTables 3.x) + +import { h } from './common.js'; + +/** + * columns: [{ title, cell(row) -> Node | string, sort?(row) -> number | string, className? }] + * A column without `sort` is not sortable. Rows keep the order they were given in until a + * header is clicked. + */ +export function createTable({ id, columns, rows, pageLength = 50 }) { + const head = h('tr', {}, ...columns.map((column) => + h('th', { scope: 'col', class: column.className || '' }, column.title))); + + // data-order: plain numbers and ISO dates, so sorting is numeric/chronological + const body = h('tbody', {}, ...rows.map((row) => + h('tr', {}, ...columns.map((column) => + h('td', { class: column.className || '', 'data-order': column.sort ? column.sort(row) : null }, + column.cell(row)))))); + + const table = h('table', { class: 'table table-sm table-hover align-middle', id }, h('thead', {}, head), body); + const wrapper = h('div', {}, table); + + new DataTable(table, { + pageLength, + lengthMenu: [25, 50, 100, 200], + order: [], // keep the order from the server: newest first + columnDefs: columns.flatMap((column, index) => + column.sort ? [] : [{ targets: index, orderable: false }]), + language: { url: 'vendor/datatables-ru.json' }, + }); + + return wrapper; +} diff --git a/frontend/js/trainings-view.js b/frontend/js/trainings-view.js new file mode 100644 index 0000000..d487c22 --- /dev/null +++ b/frontend/js/trainings-view.js @@ -0,0 +1,39 @@ +// One table for all trainings: a row per exercise + +import { alert, approachChips, formatDate, h, perArmBadge } from './common.js'; +import { createTable } from './table.js'; + +export function trainingsView(trainings) { + if (!trainings.length) { + return alert('info', 'Тренировок нет.'); + } + + // A training without exercises still gets a row, so it doesn't disappear from the list + const rows = trainings.flatMap((training) => + training.exercises?.length + ? training.exercises.map((exercise) => ({ training, exercise })) + : [{ training, exercise: null }]); + + const exerciseCount = rows.filter((row) => row.exercise).length; + + return h('div', {}, + h('p', { class: 'text-muted' }, + 'Тренировок: ', h('strong', {}, `${trainings.length}`), + ', упражнений: ', h('strong', {}, `${exerciseCount}`)), + createTable({ + id: 'trainings-table', + rows, + columns: [ + { title: 'Дата', className: 'text-nowrap date', sort: (row) => row.training.date, cell: (row) => formatDate(row.training.date) }, + { title: 'Тренер', className: 'text-nowrap', sort: (row) => row.training.trainer || '', cell: (row) => row.training.trainer || '' }, + { + title: 'Упражнение', + sort: (row) => row.exercise?.name || '', + cell: (row) => row.exercise + ? h('span', {}, row.exercise.name, ' ', row.exercise.splitted_weight ? perArmBadge() : null) + : h('span', { class: 'text-muted fst-italic' }, 'Упражнения не записаны'), + }, + { title: 'Подходы (вес × повторения)', cell: (row) => h('span', {}, ...(row.exercise ? approachChips(row.exercise.approaches) : [])) }, + ], + })); +} diff --git a/frontend/js/trainings.js b/frontend/js/trainings.js new file mode 100644 index 0000000..c2b30a4 --- /dev/null +++ b/frontend/js/trainings.js @@ -0,0 +1,10 @@ +import { alert, api } from './common.js'; +import { trainingsView } from './trainings-view.js'; + +const container = document.getElementById('trainings'); + +try { + container.replaceChildren(trainingsView(await api('/trainings/'))); +} catch (error) { + container.replaceChildren(alert('danger', `Не удалось загрузить тренировки: ${error.message}`)); +} diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..c2c1872 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + # The API lives in the backend container; going through the same origin means no CORS + location /api/ { + proxy_pass http://fastapi-app:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/frontend/parse.html b/frontend/parse.html new file mode 100644 index 0000000..44e78d4 --- /dev/null +++ b/frontend/parse.html @@ -0,0 +1,48 @@ + + + + + + Разбор заметки - Fitness Parser + + + + + + +
    + + + +
    +

    Разобрать заметку

    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    +
    + + + + + + diff --git a/app/static/css/bootstrap.min.css b/frontend/vendor/bootstrap.min.css similarity index 100% rename from app/static/css/bootstrap.min.css rename to frontend/vendor/bootstrap.min.css diff --git a/app/static/js/chart.umd.js b/frontend/vendor/chart.umd.js similarity index 100% rename from app/static/js/chart.umd.js rename to frontend/vendor/chart.umd.js diff --git a/frontend/vendor/dataTables.bootstrap5.min.css b/frontend/vendor/dataTables.bootstrap5.min.css new file mode 100644 index 0000000..81bd749 --- /dev/null +++ b/frontend/vendor/dataTables.bootstrap5.min.css @@ -0,0 +1,7 @@ +/*! DataTables Bootstrap 5 integration + * © SpryMedia Ltd - datatables.net/license + */:root{--dt_background-selected: 13, 110, 253;--dt_color-selected: 255, 255, 255;--dt_link_color-selected: 228, 228, 228;--dt-row_background: transparent;--dt-row_background-selected: var(--dt_background-selected);--dt-row-text_color-selected: var(--dt_color-selected);--dt-row-link_color-selected: var(--dt_link_color-selected);--dt-row_background-stripe: 0, 0, 0;--dt-row_background-hover: 0, 0, 0;--dt-column-ordering_background: 0, 0, 0;--dt-header-cell_align-items: center;--dt-header-cell_vertical-align: middle;--dt-html_background: white}:root.dark{--dt-html_background: rgb(33, 37, 41)}table.dataTable tbody td.dt-control{text-align:center;cursor:pointer}table.dataTable tbody td.dt-control:before{display:inline-block;box-sizing:border-box;content:"";border-top:5px solid transparent;border-left:10px solid rgba(0, 0, 0, 0.5);border-bottom:5px solid transparent;border-right:0px solid transparent}table.dataTable tbody tr.dt-hasChild td.dt-control:before{border-top:10px solid rgba(0, 0, 0, 0.5);border-left:5px solid transparent;border-bottom:0px solid transparent;border-right:5px solid transparent}:root.dark table.dataTable td.dt-control:before,:root[data-bs-theme=dark] table.dataTable td.dt-control:before,:root[data-theme=dark] table.dataTable td.dt-control:before{border-left-color:rgba(255, 255, 255, 0.5)}:root.dark table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before{border-top-color:rgba(255, 255, 255, 0.5);border-left-color:transparent}div.dt-scroll{width:100%}div.dt-scroll-body thead tr,div.dt-scroll-body tfoot tr{height:0}div.dt-scroll-body thead tr th,div.dt-scroll-body thead tr td,div.dt-scroll-body tfoot tr th,div.dt-scroll-body tfoot tr td{height:0 !important;padding-top:0px !important;padding-bottom:0px !important;border-top-width:0px !important;border-bottom-width:0px !important}div.dt-scroll-body thead tr th div.dt-scroll-sizing,div.dt-scroll-body thead tr td div.dt-scroll-sizing,div.dt-scroll-body tfoot tr th div.dt-scroll-sizing,div.dt-scroll-body tfoot tr td div.dt-scroll-sizing{height:0 !important;overflow:hidden !important}/*! DataTables Bootstrap 5 integration + * © SpryMedia Ltd - datatables.net/license + */:root{--dt-order-arrow_color: rgb(51, 51, 51);--dt-order-arrow_color-current: rgb(51, 51, 51);--dt-order-arrow-height: 7px;--dt-order-arrow_opacity: 0.125;--dt-order-arrow_opacity-current: 0.65;--dt-order-arrow-width: 8px;--dt-order-arrow-gap: 1px;--dt-order-header_outline-hover: 2px solid rgba(0, 0, 0, 0.05)}:root.dark,:root[data-bs-theme=dark],:root[data-theme=dark]{--dt-order-arrow_color: rgb(229, 233, 238);--dt-order-arrow_color-current: rgb(229, 233, 238);--dt-order-header_outline-hover: 2px solid rgba(255, 255, 255, 0.05)}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}table.dataTable thead>tr>th.dt-orderable-asc .dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc .dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc .dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc .dt-column-order:before{bottom:calc(50% + var(--dt-order-arrow-gap));border-bottom:var(--dt-order-arrow-height) solid var(--dt-order-arrow_color);border-left:calc(var(--dt-order-arrow-width)/2) solid transparent;border-right:calc(var(--dt-order-arrow-width)/2) solid transparent}table.dataTable thead>tr>th.dt-orderable-desc .dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc .dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc .dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc .dt-column-order:after{top:calc(50% + 1px);border-top:var(--dt-order-arrow-height) solid var(--dt-order-arrow_color);border-left:calc(var(--dt-order-arrow-width)/2) solid transparent;border-right:calc(var(--dt-order-arrow-width)/2) solid transparent}table.dataTable thead>tr>th.dt-orderable-asc .dt-column-order,table.dataTable thead>tr>th.dt-orderable-desc .dt-column-order,table.dataTable thead>tr>th.dt-ordering-asc .dt-column-order,table.dataTable thead>tr>th.dt-ordering-desc .dt-column-order,table.dataTable thead>tr>td.dt-orderable-asc .dt-column-order,table.dataTable thead>tr>td.dt-orderable-desc .dt-column-order,table.dataTable thead>tr>td.dt-ordering-asc .dt-column-order,table.dataTable thead>tr>td.dt-ordering-desc .dt-column-order{position:relative;width:var(--dt-order-arrow-width);align-self:stretch}table.dataTable thead>tr>th.dt-orderable-asc .dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-asc .dt-column-order:after,table.dataTable thead>tr>th.dt-orderable-desc .dt-column-order:before,table.dataTable thead>tr>th.dt-orderable-desc .dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-asc .dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-asc .dt-column-order:after,table.dataTable thead>tr>th.dt-ordering-desc .dt-column-order:before,table.dataTable thead>tr>th.dt-ordering-desc .dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-asc .dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-asc .dt-column-order:after,table.dataTable thead>tr>td.dt-orderable-desc .dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-desc .dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-asc .dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc .dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc .dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-desc .dt-column-order:after{position:absolute;display:block;content:" ";height:0;width:0;left:0;color:var(--dt-order-arrow_color);opacity:var(--dt-order-arrow_opacity)}table.dataTable thead>tr>th.dt-orderable-asc,table.dataTable thead>tr>th.dt-orderable-desc,table.dataTable thead>tr>td.dt-orderable-asc,table.dataTable thead>tr>td.dt-orderable-desc{cursor:pointer}table.dataTable thead>tr>th.dt-orderable-asc:hover,table.dataTable thead>tr>th.dt-orderable-desc:hover,table.dataTable thead>tr>td.dt-orderable-asc:hover,table.dataTable thead>tr>td.dt-orderable-desc:hover{outline:var(--dt-order-header_outline-hover);outline-offset:-2px}table.dataTable thead>tr>th.dt-ordering-asc .dt-column-order:before,table.dataTable thead>tr>td.dt-ordering-asc .dt-column-order:before{border-bottom-color:var(--dt-order-arrow_color-current);opacity:var(--dt-order-arrow_opacity-current)}table.dataTable thead>tr>th.dt-ordering-desc .dt-column-order:after,table.dataTable thead>tr>td.dt-ordering-desc .dt-column-order:after{border-top-color:var(--dt-order-arrow_color-current);opacity:var(--dt-order-arrow_opacity-current)}table.dataTable thead>tr>th.dt-orderable-none:not(.dt-ordering-asc,.dt-ordering-desc) .dt-column-order:empty,table.dataTable thead>tr>th.sorting_desc_disabled .dt-column-order:after,table.dataTable thead>tr>th.sorting_asc_disabled .dt-column-order:before,table.dataTable thead>tr>td.dt-orderable-none:not(.dt-ordering-asc,.dt-ordering-desc) .dt-column-order:empty,table.dataTable thead>tr>td.sorting_desc_disabled .dt-column-order:after,table.dataTable thead>tr>td.sorting_asc_disabled .dt-column-order:before{display:none}table.dataTable thead>tr>th:active,table.dataTable thead>tr>td:active{outline:none}table.dataTable thead>tr>th div.dt-column-header,table.dataTable thead>tr>th div.dt-column-footer,table.dataTable thead>tr>td div.dt-column-header,table.dataTable thead>tr>td div.dt-column-footer,table.dataTable tfoot>tr>th div.dt-column-header,table.dataTable tfoot>tr>th div.dt-column-footer,table.dataTable tfoot>tr>td div.dt-column-header,table.dataTable tfoot>tr>td div.dt-column-footer{display:flex;justify-content:space-between;align-items:var(--dt-header-cell_align-items);gap:4px}table.dataTable thead>tr>th div.dt-column-header .dt-column-title,table.dataTable thead>tr>th div.dt-column-footer .dt-column-title,table.dataTable thead>tr>td div.dt-column-header .dt-column-title,table.dataTable thead>tr>td div.dt-column-footer .dt-column-title,table.dataTable tfoot>tr>th div.dt-column-header .dt-column-title,table.dataTable tfoot>tr>th div.dt-column-footer .dt-column-title,table.dataTable tfoot>tr>td div.dt-column-header .dt-column-title,table.dataTable tfoot>tr>td div.dt-column-footer .dt-column-title{flex-grow:1}table.dataTable thead>tr>th div.dt-column-header .dt-column-title:empty,table.dataTable thead>tr>th div.dt-column-footer .dt-column-title:empty,table.dataTable thead>tr>td div.dt-column-header .dt-column-title:empty,table.dataTable thead>tr>td div.dt-column-footer .dt-column-title:empty,table.dataTable tfoot>tr>th div.dt-column-header .dt-column-title:empty,table.dataTable tfoot>tr>th div.dt-column-footer .dt-column-title:empty,table.dataTable tfoot>tr>td div.dt-column-header .dt-column-title:empty,table.dataTable tfoot>tr>td div.dt-column-footer .dt-column-title:empty{display:none}div.dt-scroll-body>table.dataTable>thead>tr>th,div.dt-scroll-body>table.dataTable>thead>tr>td{overflow:hidden}:root.dark table.dataTable thead>tr>th.dt-orderable-asc:hover,:root.dark table.dataTable thead>tr>th.dt-orderable-desc:hover,:root.dark table.dataTable thead>tr>td.dt-orderable-asc:hover,:root.dark table.dataTable thead>tr>td.dt-orderable-desc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>th.dt-orderable-asc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>th.dt-orderable-desc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>td.dt-orderable-asc:hover,:root[data-bs-theme=dark] table.dataTable thead>tr>td.dt-orderable-desc:hover{outline:var(--dt-order-header_outline-hover)}/*! DataTables Bootstrap 5 integration + * © SpryMedia Ltd - datatables.net/license + */:root{--dt-processing-circle_background: var(--dt_background-selected)}div.dt-processing{position:absolute;top:50%;left:50%;width:200px;margin-left:-100px;margin-top:-22px;text-align:center;padding:2px;z-index:10}div.dt-processing>div:last-child{position:relative;width:80px;height:15px;margin:1em auto}div.dt-processing>div:last-child>div{position:absolute;top:0;width:13px;height:13px;border-radius:50%;background:rgb(var(--dt-processing-circle_background));animation-timing-function:cubic-bezier(0, 1, 1, 0)}div.dt-processing>div:last-child>div:nth-child(1){left:8px;animation:datatables-loader-1 .6s infinite}div.dt-processing>div:last-child>div:nth-child(2){left:8px;animation:datatables-loader-2 .6s infinite}div.dt-processing>div:last-child>div:nth-child(3){left:32px;animation:datatables-loader-2 .6s infinite}div.dt-processing>div:last-child>div:nth-child(4){left:56px;animation:datatables-loader-3 .6s infinite}@keyframes datatables-loader-1{0%{transform:scale(0)}100%{transform:scale(1)}}@keyframes datatables-loader-3{0%{transform:scale(1)}100%{transform:scale(0)}}@keyframes datatables-loader-2{0%{transform:translate(0, 0)}100%{transform:translate(24px, 0)}}table.dataTable.nowrap th,table.dataTable.nowrap td{white-space:nowrap}table.dataTable th,table.dataTable td{box-sizing:border-box}table.dataTable th.dt-type-numeric,table.dataTable th.dt-type-date,table.dataTable td.dt-type-numeric,table.dataTable td.dt-type-date{text-align:right}table.dataTable th.dt-type-numeric div.dt-column-header,table.dataTable th.dt-type-numeric div.dt-column-footer,table.dataTable th.dt-type-date div.dt-column-header,table.dataTable th.dt-type-date div.dt-column-footer,table.dataTable td.dt-type-numeric div.dt-column-header,table.dataTable td.dt-type-numeric div.dt-column-footer,table.dataTable td.dt-type-date div.dt-column-header,table.dataTable td.dt-type-date div.dt-column-footer{flex-direction:row-reverse}table.dataTable th.dt-left,table.dataTable td.dt-left{text-align:left}table.dataTable th.dt-left div.dt-column-header,table.dataTable th.dt-left div.dt-column-footer,table.dataTable td.dt-left div.dt-column-header,table.dataTable td.dt-left div.dt-column-footer{flex-direction:row}table.dataTable th.dt-center,table.dataTable td.dt-center{text-align:center}table.dataTable th.dt-right,table.dataTable td.dt-right{text-align:right}table.dataTable th.dt-right div.dt-column-header,table.dataTable th.dt-right div.dt-column-footer,table.dataTable td.dt-right div.dt-column-header,table.dataTable td.dt-right div.dt-column-footer{flex-direction:row-reverse}table.dataTable th.dt-justify,table.dataTable td.dt-justify{text-align:justify}table.dataTable th.dt-justify div.dt-column-header,table.dataTable th.dt-justify div.dt-column-footer,table.dataTable td.dt-justify div.dt-column-header,table.dataTable td.dt-justify div.dt-column-footer{flex-direction:row}table.dataTable th.dt-nowrap,table.dataTable td.dt-nowrap{white-space:nowrap}table.dataTable th.dt-empty,table.dataTable td.dt-empty{text-align:center;vertical-align:top}table.dataTable thead th,table.dataTable thead td,table.dataTable tfoot th,table.dataTable tfoot td{text-align:left;vertical-align:var(--dt-header-cell_vertical-align)}table.dataTable thead th.dt-head-left,table.dataTable thead td.dt-head-left,table.dataTable tfoot th.dt-head-left,table.dataTable tfoot td.dt-head-left{text-align:left}table.dataTable thead th.dt-head-left div.dt-column-header,table.dataTable thead th.dt-head-left div.dt-column-footer,table.dataTable thead td.dt-head-left div.dt-column-header,table.dataTable thead td.dt-head-left div.dt-column-footer,table.dataTable tfoot th.dt-head-left div.dt-column-header,table.dataTable tfoot th.dt-head-left div.dt-column-footer,table.dataTable tfoot td.dt-head-left div.dt-column-header,table.dataTable tfoot td.dt-head-left div.dt-column-footer{flex-direction:row}table.dataTable thead th.dt-head-center,table.dataTable thead td.dt-head-center,table.dataTable tfoot th.dt-head-center,table.dataTable tfoot td.dt-head-center{text-align:center}table.dataTable thead th.dt-head-right,table.dataTable thead td.dt-head-right,table.dataTable tfoot th.dt-head-right,table.dataTable tfoot td.dt-head-right{text-align:right}table.dataTable thead th.dt-head-right div.dt-column-header,table.dataTable thead th.dt-head-right div.dt-column-footer,table.dataTable thead td.dt-head-right div.dt-column-header,table.dataTable thead td.dt-head-right div.dt-column-footer,table.dataTable tfoot th.dt-head-right div.dt-column-header,table.dataTable tfoot th.dt-head-right div.dt-column-footer,table.dataTable tfoot td.dt-head-right div.dt-column-header,table.dataTable tfoot td.dt-head-right div.dt-column-footer{flex-direction:row-reverse}table.dataTable thead th.dt-head-justify,table.dataTable thead td.dt-head-justify,table.dataTable tfoot th.dt-head-justify,table.dataTable tfoot td.dt-head-justify{text-align:justify}table.dataTable thead th.dt-head-justify div.dt-column-header,table.dataTable thead th.dt-head-justify div.dt-column-footer,table.dataTable thead td.dt-head-justify div.dt-column-header,table.dataTable thead td.dt-head-justify div.dt-column-footer,table.dataTable tfoot th.dt-head-justify div.dt-column-header,table.dataTable tfoot th.dt-head-justify div.dt-column-footer,table.dataTable tfoot td.dt-head-justify div.dt-column-header,table.dataTable tfoot td.dt-head-justify div.dt-column-footer{flex-direction:row}table.dataTable thead th.dt-head-nowrap,table.dataTable thead td.dt-head-nowrap,table.dataTable tfoot th.dt-head-nowrap,table.dataTable tfoot td.dt-head-nowrap{white-space:nowrap}table.dataTable tbody th.dt-body-left,table.dataTable tbody td.dt-body-left{text-align:left}table.dataTable tbody th.dt-body-center,table.dataTable tbody td.dt-body-center{text-align:center}table.dataTable tbody th.dt-body-right,table.dataTable tbody td.dt-body-right{text-align:right}table.dataTable tbody th.dt-body-justify,table.dataTable tbody td.dt-body-justify{text-align:justify}table.dataTable tbody th.dt-body-nowrap,table.dataTable tbody td.dt-body-nowrap{white-space:nowrap}:root{--dt_background-selected: 13, 110, 253}:root[data-bs-theme=dark]{--dt-row_background-hover: 255, 255, 255;--dt-row_background-stripe: 255, 255, 255;--dt-column-ordering_background: 255, 255, 255}table.table.dataTable{clear:both;margin-bottom:0;max-width:none;border-spacing:0}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1)>*{box-shadow:none}table.table.dataTable>:not(caption)>*>*{background-color:var(--bs-table-bg)}table.table.dataTable>tbody>tr{background-color:var(--dt-row_background)}table.table.dataTable>tbody>tr.selected>*{box-shadow:inset 0 0 0 9999px rgb(var(--dt-row_background-selected));color:rgb(var(--dt-row-text_color-selected))}table.table.dataTable>tbody>tr.selected a{color:rgb(var(--dt-row-link_color-selected))}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1)>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row_background-stripe), 0.05)}table.table.dataTable.table-striped>tbody>tr:nth-of-type(2n+1).selected>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.95)}table.table.dataTable.table-hover>tbody>tr:hover>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row_background-hover), 0.075)}table.table.dataTable.table-hover>tbody>tr.selected:hover>*{box-shadow:inset 0 0 0 9999px rgba(var(--dt-row_background-selected), 0.975)}div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:1em}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:1em}div.dt-container div.dt-layout-full{width:100%}div.dt-container div.dt-layout-full>*:only-child{margin-left:auto;margin-right:auto}div.dt-container div.dt-layout-table>div{display:block !important}@media screen and (max-width: 767px){div.dt-container div.dt-layout-start>*:not(:last-child){margin-right:0}div.dt-container div.dt-layout-end>*:not(:first-child){margin-left:0}}div.dt-container{position:relative}div.dt-container div.dt-length label{font-weight:normal;text-align:left;white-space:nowrap}div.dt-container div.dt-length select{width:auto;display:inline-block;margin-right:.5em}div.dt-container div.dt-search{text-align:right}div.dt-container div.dt-search label{font-weight:normal;white-space:nowrap;text-align:left}div.dt-container div.dt-search input{margin-left:.5em;display:inline-block;width:auto}div.dt-container div.dt-paging{margin:0}div.dt-container div.dt-paging ul.pagination{margin:2px 0;flex-wrap:wrap}div.dt-container div.dt-row{position:relative}div.dt-scroll-head table.dataTable{margin-bottom:0 !important}div.dt-scroll-body{border-bottom-color:var(--bs-border-color);border-bottom-width:var(--bs-border-width);border-bottom-style:solid}div.dt-scroll-body>table{border-top:none;margin-top:0 !important;margin-bottom:0 !important}div.dt-scroll-body>table>tbody>tr:first-child{border-top-width:0}div.dt-scroll-body>table>thead>tr{border-width:0 !important}div.dt-scroll-body>table>tbody>tr:last-child>*{border-bottom:none}div.dt-scroll-foot>.dt-scroll-footInner{box-sizing:content-box}div.dt-scroll-foot>.dt-scroll-footInner>table{margin-top:0 !important;border-top:none}div.dt-scroll-foot>.dt-scroll-footInner>table>tfoot>tr:first-child{border-top-width:0 !important}@media screen and (max-width: 767px){div.dt-container div.dt-length,div.dt-container div.dt-search,div.dt-container div.dt-info,div.dt-container div.dt-paging{text-align:center}div.dt-container .row{--bs-gutter-y: 0.5rem}div.dt-container div.dt-paging ul.pagination{justify-content:center !important}}table.dataTable.table-sm>thead>tr th.dt-orderable-asc,table.dataTable.table-sm>thead>tr th.dt-orderable-desc,table.dataTable.table-sm>thead>tr th.dt-ordering-asc,table.dataTable.table-sm>thead>tr th.dt-ordering-desc,table.dataTable.table-sm>thead>tr td.dt-orderable-asc,table.dataTable.table-sm>thead>tr td.dt-orderable-desc,table.dataTable.table-sm>thead>tr td.dt-ordering-asc,table.dataTable.table-sm>thead>tr td.dt-ordering-desc{padding-right:.25rem}table.dataTable.table-sm>thead>tr th.dt-orderable-asc .dt-column-order,table.dataTable.table-sm>thead>tr th.dt-orderable-desc .dt-column-order,table.dataTable.table-sm>thead>tr th.dt-ordering-asc .dt-column-order,table.dataTable.table-sm>thead>tr th.dt-ordering-desc .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-orderable-asc .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-orderable-desc .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-ordering-asc .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-ordering-desc .dt-column-order{right:.25rem}table.dataTable.table-sm>thead>tr th.dt-type-date .dt-column-order,table.dataTable.table-sm>thead>tr th.dt-type-numeric .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-type-date .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-type-numeric .dt-column-order{left:.25rem}table.dataTable.table-sm>thead>tr th.dt-left .dt-column-order,table.dataTable.table-sm>thead>tr th.dt-head-left .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-left .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-head-left .dt-column-order{left:auto;right:.25rem}table.dataTable.table-sm>thead>tr th.dt-right .dt-column-order,table.dataTable.table-sm>thead>tr th.dt-head-right .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-right .dt-column-order,table.dataTable.table-sm>thead>tr td.dt-head-right .dt-column-order{left:.25rem;right:auto}div.dt-scroll-head table.table-bordered{border-bottom-width:0}div.table-responsive>div.dt-container>div.row{margin-left:0;margin-right:0}div.table-responsive>div.dt-container>div.row>div[class^=col-]:first-child{padding-left:0}div.table-responsive>div.dt-container>div.row>div[class^=col-]:last-child{padding-right:0} diff --git a/frontend/vendor/dataTables.bootstrap5.min.js b/frontend/vendor/dataTables.bootstrap5.min.js new file mode 100644 index 0000000..e909e19 --- /dev/null +++ b/frontend/vendor/dataTables.bootstrap5.min.js @@ -0,0 +1,4 @@ +/*! DataTables Bootstrap 5 integration + * © SpryMedia Ltd - datatables.net/license + */ +(e=>{var n;"function"==typeof define&&define.amd?define(["datatables.net"],function(t){return e(window,document,t)}):"object"==typeof exports?(n=function(t){t.DataTable||require("datatables.net")(t)},"undefined"==typeof window?module.exports=function(t){return t=t||window,n(t),e(0,t.document,t.DataTable)}:(n(window),module.exports=e(window,window.document,window.DataTable))):e(window,document,window.DataTable)})(function(t,e,r){return r.util.object.assignDeep(r.defaults,{renderer:"bootstrap"}),r.util.object.assignDeep(r.ext.classes,{container:"dt-container dt-bootstrap5",search:{input:"form-control form-control-sm"},length:{select:"form-select form-select-sm"},processing:{container:"dt-processing card"},layout:{row:"row mt-2 justify-content-between",cell:"d-md-flex justify-content-between align-items-center",tableCell:"col-12",start:"dt-layout-start col-md-auto me-auto",end:"dt-layout-end col-md-auto ms-auto",full:"dt-layout-full col-md"}}),r.ext.renderer.pagingButton.bootstrap=function(t,e,n,o,a){var d=["dt-paging-button","page-item"],o=(o&&d.push("active"),a&&d.push("disabled"),r.Dom.c("li").classAdd(d.join(" "))),a=r.Dom.c("button").classAdd("page-link").attr("role","link").attr("type","button").html(n).appendTo(o);return{display:o.get(0),clicker:a.get(0)}},r.ext.renderer.pagingContainer.bootstrap=function(t,e){return r.Dom.c("ul").classAdd("pagination").append(e).get(0)},r}); \ No newline at end of file diff --git a/frontend/vendor/dataTables.min.js b/frontend/vendor/dataTables.min.js new file mode 100644 index 0000000..2b5d16a --- /dev/null +++ b/frontend/vendor/dataTables.min.js @@ -0,0 +1,4 @@ +/*! DataTables 3.1.4 + * Copyright (c) SpryMedia Ltd - datatables.net/license + */ +(t=>{"function"==typeof define&&define.amd?define([],function(){return t(window,document)}):"object"==typeof exports?"undefined"==typeof window?module.exports=function(e){return e=e||window,t(e,e.document)}:module.exports=t(window,window.document):window.DataTable=t(window,document)})(function(A,T){let _=/['\u00A0,$£€¥%\u2009\u202F\u20BD\u20a9\u20BArfkɃΞ]/gi,j=/<([^>]*>)/g,C=new RegExp("(\\"+["/",".","*","+","?","|","(",")","[","]","{","}","\\","$","^","-"].join("|\\")+")","g");var O=Object.freeze({__proto__:null,isoTimezone:/[T\s]\d{2}.*?(Z|[+-]\d{2}(?::?\d{2})?)$/,reDate:/^\d{2,4}[./-]\d{1,2}[./-]\d{1,2}([T ]{1}\d{1,2}[:.]\d{2}([.:]\d{2})?)?$/,reFormattedNumeric:_,reHtml:j,reNewLines:/[\r\n\u2028]/g,reRegexCharacters:C});let S=Math.pow(2,28),N=function(e,t){var r;return"string"!=typeof e?e:(r=e.normalize?e.normalize("NFD"):e).length!==e.length?(!0===t?e+" ":"")+r.replace(/[\u0300-\u036f]/g,""):r},R=function(e,t=""){if(!e||"string"!=typeof e)return e;if(e.length>S)throw new Error("Exceeded max str len");let r,n=e.replace(j,t);for(;(n=(r=n).replace(/