Compare commits

..

4 Commits

Author SHA1 Message Date
arswarog b52b1f278b docuservix chat 2026-06-19 18:34:45 +03:00
arswarog 03f7302317 docuservix/widgets/chat: добавлен компонент чата
Reviewed-on: #6
Co-authored-by: Arswarog <arswarog@yandex.ru>
Co-committed-by: Arswarog <arswarog@yandex.ru>
2026-06-19 18:28:07 +03:00
arswarog f6436d0c83 lint: добавление линтера
Reviewed-on: #5
Co-authored-by: Arswarog <arswarog@yandex.ru>
Co-committed-by: Arswarog <arswarog@yandex.ru>
2026-06-18 13:33:29 +03:00
arswarog 156f3ebe47 feat(action): добавление параметра prefix
Reviewed-on: #3
Co-authored-by: Arswarog <arswarog@yandex.ru>
Co-committed-by: Arswarog <arswarog@yandex.ru>
2026-06-16 15:14:36 +03:00
104 changed files with 3845 additions and 4218 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
title: "Title example"
title: 'Title example'
project:
org: "example"
repo: "example"
org: 'example'
repo: 'example'
+1
View File
@@ -0,0 +1 @@
yarn lint-staged
+7
View File
@@ -0,0 +1,7 @@
dist
coverage
*.d.ts
node_modules
.idea
logs
report
+29
View File
@@ -0,0 +1,29 @@
{
"printWidth": 100,
"useTabs": false,
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"bracketSpacing": true,
"singleAttributePerLine": true,
"overrides": [
{
"files": [
"*.json"
],
"options": {
"printWidth": 10
}
},
{
"files": [
"*.md",
"*.mdx"
],
"options": {
"proseWrap": "always"
}
}
]
}
+47
View File
@@ -0,0 +1,47 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this
repository.
## Project Overview
Docuservix docs — шаблон документационного сайта на Docusaurus 3.10 (React 19, TypeScript 6).
Конфигурация сайта читается из `.docuservix.yml` (title, project.org, project.repo, dirs). Локаль —
русский (`ru`).
## Commands
Используется **yarn**.
- `yarn start` — dev-сервер
- `yarn build` — production-сборка в `build/`
- `yarn typecheck` — проверка типов (tsc)
- `yarn prettier:check` — проверка форматирования
- `yarn prettier:fix` — автоформатирование
## Architecture
- `docusaurus.config.ts` — главный конфиг; читает `.docuservix.yml` через `js-yaml`
- `src/pages/` — кастомные страницы (index.tsx — главная)
- `src/css/custom.css` — глобальные CSS-переменные (`--ifm-*`)
- `docs/` — Markdown/MDX-документация
- `blog/` — блог (опционально, включается через `dirs.blog` в `.docuservix.yml`)
- Mermaid-диаграммы включены (`@docusaurus/theme-mermaid`)
- Docusaurus future v4 compatibility flag включён
## Code Style
- Prettier: 4 пробела, single quotes, trailing commas, `printWidth: 100`,
`singleAttributePerLine: true`
- JSON: `printWidth: 10` (каждое свойство на отдельной строке)
- Markdown/MDX: `proseWrap: always`
- Husky + lint-staged: prettier запускается автоматически на pre-commit
- CSS Modules (`*.module.css`) с camelCase именами классов
- **Без default export** в shared/UI компонентах; default export допустим только для Docusaurus
route-компонентов (page components)
## Environment
- Node >= 20
- Env vars: `DOCUSERVIX_URL` (production URL), `DOCUSERVIX_ON_BROKEN_LINKS` (override onBrokenLinks)
- Gitea instance: `git.jt4d.ru`
+4 -2
View File
@@ -14,7 +14,8 @@ yarn
yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
This command starts a local development server and opens up a browser window. Most changes are
reflected live without having to restart the server.
## Build
@@ -22,4 +23,5 @@ This command starts a local development server and opens up a browser window. Mo
yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
This command generates static content into the `build` directory and can be served using any static
contents hosting service.
+105 -97
View File
@@ -2,112 +2,120 @@ name: 'Docusaurus Deploy'
description: 'Builds Docusaurus docs from repo and deploys to S3'
inputs:
docs-path:
description: 'Path to docs directory in calling repo'
default: 'docs'
on-broken-links:
description: 'Behavior on broken links: throw, warn, or ignore'
default: 'throw'
docs-path:
description: 'Path to docs directory in calling repo'
default: 'docs'
on-broken-links:
description: 'Behavior on broken links: throw, warn, or ignore'
default: 'throw'
prefix:
description: 'Prefix for S3 path'
default: ''
runs:
using: 'composite'
steps:
- name: Compute target URL
shell: bash
run: |
REF="${{ github.head_ref || github.ref_name }}"
REPO="${{ github.event.repository.name }}"
ORG="${{ github.repository_owner }}"
using: 'composite'
steps:
- name: Compute target URL
shell: bash
run: |
REF="${{ github.head_ref || github.ref_name }}"
REPO="${{ github.event.repository.name }}"
ORG="${{ github.repository_owner }}"
if [[ "$REF" == "main" || "$REF" == "master" ]]; then
URL="http://${REPO}.${ORG}.jt4d-wiki.ru.net"
S3_PATH="${ORG}.${REPO}"
else
URL="http://${REF}.${REPO}.${ORG}.jt4d-wiki.ru.net"
S3_PATH="${ORG}.${REPO}.${REF}"
fi
if [[ "$REF" == "main" || "$REF" == "master" ]]; then
URL="http://${REPO}.${ORG}.jt4d-wiki.ru.net"
S3_PATH="${ORG}.${REPO}"
else
URL="http://${REF}.${REPO}.${ORG}.jt4d-wiki.ru.net"
S3_PATH="${ORG}.${REPO}.${REF}"
fi
echo "TARGET_URL=$URL" >> $GITHUB_ENV
echo "S3_PATH=$S3_PATH" >> $GITHUB_ENV
PREFIX="${{ inputs.prefix }}"
if [[ -n "$PREFIX" ]]; then
S3_PATH="${PREFIX}/${S3_PATH}"
fi
- name: Set docs status pending
shell: bash
run: |
curl -s -X POST \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"state": "pending",
"context": "Docs",
"description": "building",
"target_url": "${{ env.TARGET_URL }}"
}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
echo "TARGET_URL=$URL" >> $GITHUB_ENV
echo "S3_PATH=$S3_PATH" >> $GITHUB_ENV
- name: Copy docs into Docusaurus
shell: bash
run: |
DOCUSAURUS_DIR="${{ github.action_path }}"
rm -rf "${DOCUSAURUS_DIR}/docs"
cp -r "${{ inputs.docs-path }}" "${DOCUSAURUS_DIR}/docs"
cp "${{ github.workspace }}/.docuservix.yml" "${DOCUSAURUS_DIR}"
- name: Set docs status pending
shell: bash
run: |
curl -s -X POST \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"state": "pending",
"context": "Docs",
"description": "building",
"target_url": "${{ env.TARGET_URL }}"
}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
- name: Prepare docs
shell: bash
working-directory: ${{ github.action_path }}
run: node scripts/prepare-docs.mjs
- name: Copy docs into Docusaurus
shell: bash
run: |
DOCUSAURUS_DIR="${{ github.action_path }}"
rm -rf "${DOCUSAURUS_DIR}/docs"
cp -r "${{ inputs.docs-path }}" "${DOCUSAURUS_DIR}/docs"
cp "${{ github.workspace }}/.docuservix.yml" "${DOCUSAURUS_DIR}"
- name: Install Docusaurus dependencies
shell: bash
working-directory: ${{ github.action_path }}
run: yarn install --frozen-lockfile
- name: Prepare docs
shell: bash
working-directory: ${{ github.action_path }}
run: node scripts/prepare-docs.mjs
- name: Build docs
shell: bash
working-directory: ${{ github.action_path }}
env:
DOCUSERVIX_ON_BROKEN_LINKS: ${{ inputs.on-broken-links }}
DOCUSERVIX_URL: ${{ env.TARGET_URL }}
run: yarn docusaurus build --out-dir ${{ github.workspace }}/generated-docs
- name: Install Docusaurus dependencies
shell: bash
working-directory: ${{ github.action_path }}
run: yarn install --frozen-lockfile
- name: Upload to S3
shell: bash
env:
AWS_ACCESS_KEY_ID: ${{ vars.DOCUSERVIX_S3_ACCESS }}
AWS_SECRET_ACCESS_KEY: ${{ vars.DOCUSERVIX_S3_SECRET }}
run: |
aws s3 sync generated-docs/ \
s3://${{ vars.DOCUSERVIX_S3_BUCKET }}/${{ env.S3_PATH }}\
--endpoint-url ${{ vars.DOCUSERVIX_S3_URL }} \
--acl public-read \
--delete
- name: Build docs
shell: bash
working-directory: ${{ github.action_path }}
env:
DOCUSERVIX_ON_BROKEN_LINKS: ${{ inputs.on-broken-links }}
DOCUSERVIX_URL: ${{ env.TARGET_URL }}
run: yarn docusaurus build --out-dir ${{ github.workspace }}/generated-docs
- name: Set docs status success
if: success()
shell: bash
run: |
curl -s -X POST \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"state": "success",
"context": "Docs",
"description": "deployed",
"target_url": "${{ env.TARGET_URL }}"
}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
- name: Upload to S3
shell: bash
env:
AWS_ACCESS_KEY_ID: ${{ vars.DOCUSERVIX_S3_ACCESS }}
AWS_SECRET_ACCESS_KEY: ${{ vars.DOCUSERVIX_S3_SECRET }}
run: |
aws s3 sync generated-docs/ \
s3://${{ vars.DOCUSERVIX_S3_BUCKET }}/${{ env.S3_PATH }}\
--endpoint-url ${{ vars.DOCUSERVIX_S3_URL }} \
--acl public-read \
--delete
- name: Set docs status failure
if: failure()
shell: bash
run: |
curl -s -X POST \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"state": "failure",
"context": "Docs",
"description": "build failed",
"target_url": "${{ env.TARGET_URL }}"
}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
- name: Set docs status success
if: success()
shell: bash
run: |
curl -s -X POST \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"state": "success",
"context": "Docs",
"description": "deployed",
"target_url": "${{ env.TARGET_URL }}"
}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
- name: Set docs status failure
if: failure()
shell: bash
run: |
curl -s -X POST \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"state": "failure",
"context": "Docs",
"description": "build failed",
"target_url": "${{ env.TARGET_URL }}"
}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/statuses/${{ github.sha }}"
-51
View File
@@ -1,51 +0,0 @@
# Общее описание
Платформа для грантов предназначена для упрощения и автоматизации процесса распределения грантовых
средств. Она предоставляет удобный инструмент как для организаций, желающих вложиться в общественно
полезные проекты, так и для участников, нуждающихся в финансировании. Основные задачи платформы:
- **Прозрачность:** Платформа обеспечивает открытый процесс подачи заявок, их оценки и выбора
победителей.
- **Доступность:** Участники могут легко находить актуальные конкурсы и подавать заявки, а
компании — запускать собственные грантовые программы.
- **Эффективность:** Процесс отбора и реализации проектов автоматизирован, что экономит время
участников и организаторов.
## Основные пользователи платформы:
- **Компании:** Организуют конкурсы, финансируют проекты.
- **Участники:** Представляют свои проекты, чтобы получить финансирование. При подаче заявки
участники выбирают категорию, что влияет на структуру заявки и условия участия.
- **Модераторы:** Проверяют заявки, следят за корректностью информации.
- **Эксперты:** Оценивают проекты, прошедшие модерацию, с учетом их категории.
- **Комиссия:** Принимает окончательные решения о финансировании.
- **Администраторы:** Управляют платформой и обеспечивают её стабильную работу, включая создание и
настройку категорий.
## Основные этапы работы платформы:
1. Регистрация пользователей (компании, организации, волонтеры).
2. Публикация конкурсов
3. Сбор заявок. На этапе подачи заявки пользователь выбирает категорию, что определяет дальнейшую
структуру заявки и условия участия.
4. Модерация заявок и внесение исправлений участниками.
5. Оценка проектов экспертами с учетом специфики категории.
6. Выбор победителей комиссией.
7. Реализация проектов победителями, включая публикацию отчетов.
Платформа также включает инструменты для контроля исполнения проектов, анализа их результатов и
формирования отчетности. Это позволяет компаниям видеть, как эффективно используются их средства, а
участникам — демонстрировать успешность своих инициатив.
-42
View File
@@ -1,42 +0,0 @@
# Основные функции платформы
## Для участников
- **Поиск конкурсов:** Участники могут просматривать доступные конкурсы.
- **Подача заявок:** Удобный интерфейс для создания и отправки заявок на участие в конкурсе.
- **Управление проектами:** Ведение проектов, отслеживание их статуса и предоставление отчетности.
- **Финансовая отчетность:** Предоставление данных о расходах по проекту через встроенные формы.
## Для организаторов
- **Создание конкурсов:** Возможность настроить параметры конкурса (цели, требования, сроки и
др.).
- **Управление заявками:** Просмотр всех поступивших заявок, их модерация и одобрение.
- **Работа с экспертами:** Назначение экспертов для оценки заявок, управление их доступами.
- **Мониторинг реализации проектов:** Контроль выполнения грантовых обязательств победителей.
## Для экспертов
- **Оценка заявок:** Просмотр и оценивание заявок участников по заданным критериям. Возможность
оставлять комментарии и замечания.
## Для модераторов
- **Проверка заявок:** Проверка заявок участников на соответствие требованиям конкурса.
- **Коммуникация с участниками:** Возможность запрашивать доработки заявок и уведомлять участников
об изменениях статуса.
## Для администраторов платформы
- **Управление пользователями:** Добавление, редактирование и удаление пользователей.
- **Мониторинг активности:** Анализ активности на платформе, выявление проблемных мест.
- **Настройка глобальных параметров:** Конфигурация технических аспектов работы системы.
-28
View File
@@ -1,28 +0,0 @@
# Роли пользователей
## Глобальные роли
Эти роли может выдать только `root` пользователь:
- `root` Максимальный уровень доступа. С этим уровнем доступа можно обходить некоторые системы
сайта. Предполагается, что этот пользователь знает, что делает.
- `support` Имеет доступ к большому количеству данных и функций, может влиять на них, но с
ограничениями в критически важных областях.
## Сотрудники конкурса
Эти роли могут быть выданы как на отдельные конкурсы, так и глобально на все конкурсы:
- `admin` Администратор конкурса. Может настраивать конкурс и сотрудников, имеет права `moderator`
- `moderator` Просмотр и модерирование проектов, просмотр статистики конкурса, возможность
блокировки пользователей.
- `expert` Оценка проектов без доступа к настройкам или модерации.
## Сотрудники организатора
Эти роли выдаются только на конкретные организации:
- `orgAdminRole` Руководитель организации. Может настраивать организацию и её сотрудников.
- `orgMemberRole` Ответственный за заполнение проектов и отчетов.
- `orgReportedRole` Публикация новостей от имени организации.
- `orgReaderRole` Подписчик, имеет только права на просмотр информации.
-60
View File
@@ -1,60 +0,0 @@
# Разделы сайта
## Кабинет участника
Доступен всем авторизованным пользователям.
Предназначен для сотрудников организаций, участвующих в конкурсах.
### Точка входа `/cabinet`
- `/cabinet/projects` Текущие проекты
- `/cabinet/projects/create` Создание нового проекта
- `/cabinet/orgs` Список организаций, в которых пользователь является сотрудником
---
## Управление конкурсом
Имеют доступ только сотрудники конкурса, `admin` и `support`.
### Точка входа `/contests`
- `/contests` выбор конкурса, отображает те конкурсы, в которых пользователь является сотрудником
- `/contests/[contestId]` Панель управления конкурсом. Во вложенных страницах можно управлять
проектами, заявками, периодами и областями конкурса
- `/contests/[contestId]/moderation` Модерация заявок
---
## Администрирование сайта
Имеют доступ только `admin` и `support`.
### Точка входа `/admin`
- `/admin` Панель администратора
### Управление пользователями
- `/admin/users` Список и поиск пользователей
---
## Общие страницы
На такие страницы может попасть любой пользователь, если есть разрешение.
### Авторизация и регистрация
- `/auth/login` Страница авторизации
- `/auth/reg` Страница регистрации
- `/profile` Страница профиля текущего пользователя
### Пользователи
- `/users/[userId]` Страница отдельного пользователя
### Проекты
- `/projects/[projectId]` Просмотр и редактирование проекта
-1
View File
@@ -1 +0,0 @@
label: 'Процесс работы'
-1
View File
@@ -1 +0,0 @@
label: 'Настройка и подключение лендингов'
@@ -1,38 +0,0 @@
# Создание конкурса
Доступно создание конкурса с нуля или путём копирования ранее проведённого конкурса.
## Создание черновика конкурса
Создание черновика конкурса требует указания следующих сведений:
- **Название конкурса**
- **Администратор конкурса**
После создания черновика назначенный администратор получит доступ к панели управления конкурса и к
мастеру настройки.
## Мастер настройки конкурса
Мастер настройки обеспечивает последовательность действий и визуальный контроль прогресса:
- **Статус модулей**. Индикация того, какие модули уже настроены, а какие остаются в работе.
- **Краткая информация**. Краткое описание каждого модуля и текущий статус настройки.
- **Переход к настройке**. Быстрый переход к настройке конкретного модуля.
> **Важно:** Запуск конкурса становится доступным только после того, как все модули будут отмечены
> как "настроенные". После старта мастер настройки завершает работу и закрывается.
## Варианты создания конкурса
### Настройка с нуля
Для некоторых модулей мастер может предоставлять инструменты для быстрого первого заполнения,
позволяя задать основные параметры и оставить детали на более поздний этап. Если такие инструменты
недоступны, настройка осуществляется стандартными средствами модуля.
### Копирование существующего конкурса
Для некоторых модулей мастер может предложить выбор элементов для переноса из ранее проведённого
конкурса. Это ускоряет первоначальную конфигурацию. Если инструмент копирования недоступен,
применяются обычные средства модуля для настройки существующего конкурса.
@@ -1,59 +0,0 @@
# Категории проектов в конкурсах
Категории позволяют учитывать специфику проектов и их участников. Пользователи сами выбирают
категорию, в которой хотят участвовать и заполняют соответствующую форму заявки.
#### Примеры категорий и их отличий:
- **Волонтерские проекты:** Ограничения по срокам проведения, максимальная сумма грантов,
упрощенные формы заявки.
- **Школьные проекты:** Участниками могут быть только группы школьников, дополнительные требования
для школ.
- **Проекты организаций:** Более сложные формы заявки, большие максимальные суммы грантов.
### Функционал категорий
- **Создание и управление категориями:**
- Только администраторы конкурса могут управлять категориями.
- Администраторы могут добавлять, изменять и удалять категории.
- **Параметры категории:**
- Условия участия (например, ограничения по типу участников, возрасту, региону).
- Формы заявки (разделы, поля, инструкции).
- Параметры конкурсов (сроки подачи, суммы грантов).
- И так далее.
- **Гибкость изменения:** Возможность адаптации категорий для конкретных конкурсов.
### Технические особенности
- У категорий нет версионирования.
- Изменять категории можно в любой момент до завершения конкурса.
- Категория относится только к одному конкурсу.
- Участники должны видеть только public категории.
- Участник не может сменить категории после ее выбора.
- Проект может относиться только к одной категории.
### Отчетность по категориям
В разделе аналитики предоставляется возможность фильтровать заявки и результаты по категориям,
анализировать успешность проектов в каждой категории и их соответствие целям конкурсов.
### Доступность категорий
Для управления доступностью категории для создания проектов с этой категорией используется поле
`access`.
Любую категорию можно включить или выключить для создания нового проекта с этой категорией.
- `enabled: true` Участники могут создавать проекты в этой категории.
- `enabled: false` Участники не могут создавать проекты в этой категории.
Флаг `manualControl` управляет способом изменения поля `enabled`:
- `true` - включается и выключается только вручную.
- `false` - включается и выключается автоматически, на основании других настроек категории.
@@ -1,13 +0,0 @@
# Функционал категорий
Категории предоставляют гибкость настройки условий для разных типов проектов. Каждая категория может
изменять следующие параметры:
- Максимальная сумма гранта
- Максимальный фонд гранта
- Условия участия (текст и документ)
- Требования к участникам (текст и документ)
- Сроки подачи заявок для данной категории
- Направления проектов
- Форма заявки
- Список документов, которые могут потребоваться участникам от организатора конкурса
@@ -1 +0,0 @@
label: 'Категории проектов'
@@ -1 +0,0 @@
label: 'Конкурсы'
-13
View File
@@ -1,13 +0,0 @@
# Общая схема взаимодействия модулей
```mermaid
erDiagram
Contest ||--o{ Project : "Проект участвует в конкурсе"
Organizator ||--o{ Contest : "Организатор может создать конкурс"
Organizator ||--o{ Project : "Организатор может создать проект"
Project ||--o{ Event: "Мероприятие проходит в рамках проекта"
Project ||--o{ News: "У проекта есть новости"
Project ||--o{ Application: "Данные проекта изменяются через заявки"
```
@@ -1 +0,0 @@
label: 'Организации и волонтеры'
@@ -1,26 +0,0 @@
# Организаторы проектов
```mermaid
classDiagram
class Organizator {
<<interface>>
id: pk
type: "Organization" | "Volunteers"
title: string
leader: OrgParticipant
}
class Organization {
type: "Organization"
urData: UrData
recvezits: any
}
Organization --|> Organizator
class Volunteers {
type: "Volunteers"
}
Volunteers --|> Organizator
```
- В случае волонтеров названием организатора будет являться ФИО руководителя (предварительно)
@@ -1,45 +0,0 @@
# Общая последовательность работы над проектом
- Любое изменение проекта проходит через создание заявки на изменение проекта
- Изменение в проект вносится только после одобрения заявки модератором
- При создании нового проекта, проект создается со статусом `Draft`, без создания заявки
- Проект начинает участвовать в конкурсе только после одобрения заявки модератором
```mermaid
sequenceDiagram
actor User as Пользователь
participant Application
actor Moderation as Модератор
participant Project
Note over User, Project: Создание проекта
User->>+Project: Создание черновика проекта
Note over User, Project: Заявка на участие в конкурсе
User->>Application: Заполнение заявки
Application->>Moderation: Отправка на модерацию
Moderation-->>Application: Возврат на доработку
User-->>Application: Исправление заявки
Application-->>Moderation: Повторная оправка на модерацию
Moderation->>+Project: Проект учавствует в конкурсе
Note over User, Project: Проект профинансирован
loop
Note over User, Project: Заявка на изменение проекта
User->>Application: Заполнение заявки
Application->>Moderation: Отправка на модерацию
Moderation-->>Application: Возврат на доработку
User-->>Application: Исправление заявки
Application-->>Moderation: Повторная оправка на модерацию
Moderation->>+Project: Применение изменений на проект
end
User->Project: Завершение работы над проектом
deactivate Project
deactivate Project
deactivate Project
```
@@ -1,72 +0,0 @@
# Спецификация
## Исходные требования
- Все изменения в проект (не черновик) вносятся только после прохождения модерации
- Вся история изменения проекта должна храниться столько же, сколько и сам проект
## Проект
```mermaid
stateDiagram-v2
[*] --> Draft
Draft --> Rejected : Заявка отклонена модераторами
Draft --> Accepted : Заявка одобрена модераторами
Accepted --> Evaluating
Evaluating --> Awarding
note left of Evaluating
Оценка проекта
end note
Awarding --> Finalists : Проект не получил финансирование
Awarding --> Funded : Проект победил
note left of Awarding
Выбор победителей
end note
Rejected --> [*]
Finalists --> [*]
Funded --> [*]
```
- Проект со статусами `Draft` и `Rejected` не участвует в конкурсе
- Проект со статусами `Draft` и `Rejected` не видны никому, кроме участников организации и
модераторов
- Проект с остальными статусами участвует в конкурсе
- Отклоненный проект не участвует в конкурсе и не может быть восстановлен, только создан новый
проект
- Проект может быть удален авторами в любой любой момент до завершения конкурса без указания
причины, за исключением статуса `Funded`, т.к. этот проект уже направляется на финансирование
(не показано на схеме)
- Проект может быть отклонен организаторами конкурса в любой любой момент до завершения конкурса с
указанием причины отклонения (не показано на схеме, это техническая возможность, для этого
должны быть веские основания)
## Заявка
```mermaid
stateDiagram-v2
[*] --> Draft
Draft --> Moderating: Отправка на модерацию
Moderating --> Rejected: Отклонена модераторами
Rejected --> [*]
Moderating --> Accepted: Одобрена модераторами
Accepted --> [*]
Moderating --> Returned: Возврат на доработку
Returned --> Draft: Исправление заявки
Draft --> Deleted: Удаление заявки автором
Deleted --> [*]
```
- У проекта одновременно может быть несколько заявок в работе
- У проекта со статусом НЕ `Draft` обязательно должна быть заявка со статусом `Accepted`, притом
только одна
- Заявка со статусами `Draft`, `Rejected`, `Deleted` не видна никому, кроме участников организации
и модераторов
@@ -1,27 +0,0 @@
# Ревизии заявки
Сама ревизия хранит только отличия от предыдущей ревизии, кто и когда внес изменения. При
необходимости можно просмотреть историю изменений заявки и откатиться к предыдущим версиям.
## Исходные требования
- Все изменения изменения заявки должны быть сохранены
- Исключить потерю данных при редактировании заявки несколькими пользователями
- Возможность отката к предыдущим версиям заявки
- Возможность просмотра истории изменений заявки
- Возможность формирования графика интенсивности работы над заявкой
## Реализация
Над заявкой могут производится различные действия, которые влияют на ее состояние и данные. При этом
разные действия могут вносить разные изменения в данные заявки. Потому в ревизии есть отдельные поля
`action` и `payloadType`, которые позволяют определить тип действия и тип изменений в данных заявки.
Тип данных в поле `payload` зависит от значения поля `payloadType` и должно обрабатываться
соответствующим образом.
Такая структура позволяет легко добавлять новые типы действий и изменений в заявке.
## Заявка
Для получения актуальной версии заявки необходимо применить все ревизии в порядке их создания.
@@ -1 +0,0 @@
label: 'Проекты и заявки'
@@ -1 +0,0 @@
label: 'Заявки (Requests)'
@@ -1,99 +0,0 @@
# Участники проектов
```mermaid
classDiagram
class User {
id
}
class Contest {
id: pk
status: ContestStatus
title: string
}
class Organizator {
<<interface>>
id: pk
type: Organization | Volunteers
title: string
}
class Project {
id: pk
title: string
contest: Contest
org: Organizator
}
Project "*" --* "1" Organizator
Project "*" --* "1" Contest
class ContestMember {
id: pk
contest: Contest
role: ContestRole
roleTitle: string
}
ContestMember "*" --* "1" Contest
ContestMember "*" --o "1" User
class OrgMember {
id: pk
org: Organizator
role: OrgRole
roleTitle: string
}
OrgMember "*" --* "1" Organizator
OrgMember "*" --o "1" User
class ProjectMember {
id: pk
project: Project
role: ProjectRole
roleTitle: string
}
ProjectMember "*" --* "1" Project
ProjectMember "*" --o "1" User
class Participant {
id: pk
fio
phone
birthYear
user?: User
}
Participant "1" --o "0..1" User
class OrgParticipant {
id: pk
org: Organizator
participant: Participant
roleTitle: string
}
OrgParticipant "*" --* "1" Organizator
OrgParticipant "*" --* "1" Participant
class ProjectParticipant {
id: pk
project: Project
participant: Participant
roleTitle: string
}
ProjectParticipant "*" --* "1" Project
ProjectParticipant "*" --* "1" Participant
```
- Заявка превращается в конкурс (предположительно) после апрува модератором
### Различные типы участников
| Поле | Имя | Ключевые (руководитель, бухгалтер) | Участник проекта организации | Участник проекта волонтеров |
| ------------------------------ | -------------- | ---------------------------------- | ---------------------------- | --------------------------- |
| Фамилия | firstName | + | + | + |
| Имя | lastName | + | + | + |
| Отчество | patronymic | + | + | + |
| Должность | position | + | + | - |
| Телефон | phone | + | - | + |
| E-mail | email | + | - | + |
| Год рождения | birthYear | - | + | - |
| Зона ответственности в проекте | responsibility | - | + | - |
-98
View File
@@ -1,98 +0,0 @@
# Конкурсы, проекты и заявки
```mermaid
classDiagram
direction BT
class Organizator {
<<interface>>
id: pk
type: Organization | Volunteers
title: string
}
class Contest {
id: pk
status: ContestStatus
title: string
logo: string
description: string
totalBudget: number
receiptStartedAt: number
receiptEndedAt: number
ratingStartedAt?: number
ratingEndedAt?: number
publicResultsAt: number
workStartedAt: number
workEndedAt: number
createdAt: number
updatedAt: number
org: Organizator
}
Contest "*" --* "1" Organizator
class Activity {
id: pk
title: string
parent?: Activity
}
Activity "*" --* "1" Contest
Activity "1" --* "0..1" Activity
class Project {
id: pk
title: string
contest: Contest
activity: Activity
org: Organizator
leader: ProjectParticipant
description: string
}
Project "*" --* "1" Organizator
Project "*" --* "1" Contest
Project "*" --o "1" Activity
class OrgParticipant {
id: pk
org: Organizator
fio
roleTitle: string
}
OrgParticipant "*" --* "1" Organizator
class ProjectParticipant {
id: pk
project: Project
fio
roleTitle: string
}
ProjectParticipant "*" --* "1" Project
class Event {
id: pk
status: EventStatus
project: Project
title: string
logo
description: string
startsOn: Date
endsOn: Date
createdAt: timestamp
updatedAt: timestamp
}
Event "*" --* "1" Project
class News {
id
status: EventStatus
project: Project
title: string
logo
description: string
date: Date
createdAt: timestamp
updatedAt: timestamp
}
News "*" --* "1" Project
```
- Заявка превращается в конкурс (предположительно) после апрува модератором
-24
View File
@@ -1,24 +0,0 @@
# Заявки
## Состояния заявки
```mermaid
stateDiagram-v2
[*] --> Draft
Draft --> Autosave
Autosave --> Draft: Обновление черновика
Draft --> Moderating: Отправлено на модерацию
Moderating --> Returned: на доработку
Returned --> Draft: исправление
Moderating --> Rejected: заявка отклонена
Moderating --> Accepted: заявка принята
Accepted --> Archived: обновление заявки принято
Rejected --> [*]
```
- `Autosave` отдельная запись с указанием на черновик, при сохранении обновляет черновик
- `Accepted` и `Archived` обязаны иметь верное значение `projectId` т.к. при принятии заявки
создается проект и дальнейшие действия ведутся над проектом
- Заявки в статусе отличном от `Accepted` и `Archived` могут иметь `projectId` только если это
заявка на обновление проекта
- Отклоненная заявка не может быть подана повторно
@@ -1,17 +0,0 @@
# Введение
Данный модуль обеспечивает экспертную оценку заявок. Он автоматизирует распределение заявок между
экспертами, сбор и анализ оценок, а также формирование итогового рейтинга проектов.
## Общий процесс оценки проектов
1. Организатор настраивает форму оценки и критерии.
2. Система назначает экспертов на проекты.
3. Эксперты заполняют форму оценки.
4. Итоговые оценки агрегируются для формирования рейтинга.
## Роли участников
- **Организатор конкурса** – настраивает критерии, назначает экспертов, контролирует процесс.
- **Эксперт** – оценивает проекты по заданным критериям.
- **Платформа** – автоматически распределяет проекты, фиксирует оценки, собирает данные.
@@ -1,20 +0,0 @@
# Создание формы оценки
## Назначение и общие принципы
Так как эксперты оценивают проекты по заранее настроенным критериям, требуется конструктор формы,
которую будут заполнять эксперты. Организатор конкурса настраивает критерии оценки до начала
конкурса. После начала оценки структура формы не может быть изменена.
## Типы критериев
- **Выбор из фиксированного списка ответов** (каждый вариант имеет скрытый для эксперта вес).
- **Текстовое поле** с настройками ограничений по длине.
Так же критерию можно добавить описание и подсказку.
## Группировка критериев
- Критерии разделены на группы.
- Некоторые критерии могут быть необязательными.
- Если для группы критериев требуется комментарий, то его нужно добавить в схему.
@@ -1,36 +0,0 @@
# Назначение экспертов
## Принципы назначения
Платформа для грантов должна распределять проекты среди экспертов для независимой оценки. Процесс
назначения проектов должен учитывать:
- Автоматическое и ручное распределение.
- Обеспечение равномерной нагрузки на экспертов.
- Возможность перераспределения проектов в случае отказа эксперта.
## Ручное назначение
Организатор может вручную назначать экспертов и корректировать автоматическое распределение.
## Автоматическое назначение
Система может:
- Находить и назначать на проект наименее загруженного эксперта.
- Балансировать указанный проект.
- Балансировать все проекты категории.
Балансировать проект - доназначать экспертов до нужного количества, если сейчас их меньше, чем надо.
Обработка ошибок:
- Если найти и назначить наименее загруженного **эксперта** не удаётся, пользователю предлагается
**назначить его вручную**.
- Если для балансировки **проекта** не хватает экспертов, операция **отменяется**.
- Если для балансировки какого-то из проектов **категории** не хватает экспертов, этот **проект
помечается** `unassessable` и балансировка категории продолжается.
## Обработка отказов
Если эксперт отказывается от оценки, система автоматически переназначает проект другому эксперту.
@@ -1,12 +0,0 @@
# Оценка проектов экспертами
## Процесс оценки
- Эксперт оценивает проект независимо, не видя оценок других экспертов.
- Черновики сохраняются автоматически.
- Эксперт может редактировать оценку, пока она не отправлена.
- После отправки оценку изменить нельзя.
## Форма
- Обязательное заполнение всех критериев.
@@ -1,60 +0,0 @@
# Анализ ревью
После того как эксперты отправляют свои оценки, система автоматически анализирует их, вычисляет
средний балл проекта и определяет, завершена ли экспертиза.
Это происходит при каждом изменении ревью: при завершении, отклонении или отказе от ревью.
## Средний балл
Средний балл проекта рассчитывается как среднее арифметическое баллов всех завершённых ревью,
округлённое до целого. Незавершённые, отклонённые и удалённые ревью в расчёте не участвуют. Если ни
одно ревью ещё не завершено, средний балл не отображается.
## Завершение экспертизы
Экспертиза проекта считается завершённой, когда выполнены два условия одновременно:
- Все назначенные эксперты завершили свои ревью (не считая отклонённых и удалённых).
- Количество завершённых ревью не меньше минимально необходимого количества, заданного в
настройках номинации.
## Спорные оценки
Когда эксперты расходятся во мнениях слишком сильно, система помечает проект как спорный. Это
позволяет организатору обратить внимание на такие проекты и при необходимости назначить
дополнительных экспертов.
### Как определяется спорность
Проверка спорности происходит, когда набрано ровно минимально необходимое количество ревью. Система
сравнивает разброс оценок — разницу между максимальным и минимальным баллом среди всех завершённых
ревью — с допустимым порогом, заданным в настройках номинации в процентах от шкалы оценки.
- Если разброс превышает порог — проект помечается как спорный и экспертиза не завершается, чтобы
организатор мог назначить дополнительного эксперта.
- Если разброс в пределах порога — проект не спорный, экспертиза завершается.
### Дополнительные ревью
Когда организатор назначает дополнительного эксперта на спорный проект и тот завершает ревью,
количество ревью превышает минимально необходимое. В этом случае:
- Проект остаётся помеченным как спорный (метка не снимается автоматически).
- Экспертиза завершается, когда все назначенные эксперты завершили свои ревью.
- Модератор должен вручную снять метку спорности, если считает, что дополнительное ревью разрешило
спор.
### При недостатке ревью
Если количество завершённых ревью меньше минимально необходимого (например, ревью было отклонено),
метка спорности снимается и экспертиза остаётся незавершённой.
## Пересчёт проектов всей номинации
При изменении настроек оценки в номинации запускается пересчёт всех проектов в номинации.
При пересчёте:
- Затрагиваются только проекты с незавершённой или завершённой экспертизой.
- Метка спорности сбрасывается у всех проектов и вычисляется заново.
@@ -1 +0,0 @@
label: 'Оценка проектов'
@@ -1 +0,0 @@
label: 'Отчетность'
-11
View File
@@ -1,11 +0,0 @@
# Отчетность
Пользователи, получившие гранты должны предоставить отчеты по расходу полученных средств
Отчетности всего две
## Финансовый отчет
Сколько было потрачено средств, на что, с комментариями и прикреплением документов
## Аналитический отчет
-1
View File
@@ -1 +0,0 @@
label: 'Продуктовые модули'
-1
View File
@@ -1 +0,0 @@
label: 'Гайдлайны'
-202
View File
@@ -1,202 +0,0 @@
# Модальное окно
Модальное окно — диалоговый элемент интерфейса, который появляется поверх страницы и блокирует
доступ к её основному содержимому.
## Когда использовать
Используйте модальные окна для:
- подтверждения действий,
- отображения ошибок,
- вывода небольших форм (до 10 полей), связанных с локальными действиями — например, настройками
или созданием объекта.
Не используйте модальные окна для больших форм (> 15 полей) и действий, требующих длительного
непрерывного взаимодействия пользователя с интерфейсом.
## Принцип работы
Модальное окно отображается поверх страницы с затемнением фона. Это помогает сфокусировать внимание
пользователя на локальном действии, сохранив контекст.
Модальное окно:
- перехватывает фокус внутри себя,
- восстанавливает фокус после закрытия (свойство `restoreFocus`).
## Состав модального окна
Модальное окно может включать:
- **заголовок** (обязателен),
- **кнопку закрытия**,
- **иконку статуса**,
- **контент**,
- **подвал с действиями**.
## Компоненты модальных окон
### `NoticeDialog`
Используется для отображения информационных сообщений:
- успешные действия,
- ошибки,
- предупреждения,
- служебные уведомления.
Особенности:
- одна кнопка действия («ОК» или «Закрыть»),
- иконка и цвет определяют тип сообщения,
- контент — опционален,
- кнопка получает фокус.
Типы сообщений:
- успех — зелёное оформление `variant=success` и позитивная иконка,
- ошибка — красное оформление `variant=danger` и иконка ошибки,
- предупреждение — жёлтое оформление `variant=warning` и иконка предупреждения,
- информация — синее оформление `variant=info` и информационная иконка.
### `ActionDialog`
Используется для модальных окон, которые последовательно проходят следующие состояния:
- подтверждение действия,
- выполнение асинхронной операции,
- отображение статуса выполнения,
- сообщение об успехе или ошибке.
Особенности:
- объединяет несколько состояний в одном окне,
- обеспечивает единый UX-паттерн для типовых async-сценариев,
- используется вместо самостоятельной реализации поведения, если сценарий вписывается в типовой
flow.
### `Dialog`
Базовый компонент модального окна. Предназначен для:
- подтверждения действий,
- ввода дополнительной информации,
- отображения сценариев, не охваченных `NoticeDialog` и `ActionDialog`.
Особенности:
- может содержать любое количество кнопок,
- может быть гибко настроен под различные сценарии,
- требует самостоятельной реализации логики поведения.
## Диалоговые окна
### Заголовок
Заголовок должен быть кратким (1–3 слова) и отражать суть действия или процесса:
- Создание проекта
- Редактирование события
Для окон, требующих подтверждения:
- Удалить проект?
- Выйти без сохранения?
### Действия
В диалоговых окнах обычно две кнопки:
- **Основная кнопка** — подтверждает действие («Сохранить», «Удалить»). Основная кнопка должна
быть в фокусе.
- **Кнопка отмены** — закрывает окно без выполнения действия («Отменить»).
> **Правило:** чем правее кнопка — тем менее важное и менее частое действие. Вот обновлённая краткая
> версия с переносом правил подтверждения в конец:
## Именование компонентов
Для модальных окон используется единый принцип именования, который отражает тип действия и
обеспечивает предсказуемость.
### Обычные модальные окна
Используется паттерн:
> **`<Entity><Action>Modal`**
Применяется для создания, редактирования, просмотра или выполнения безопасных действий.
Где:
- **Entity** — сущность: `Project`, `Review`, `Expert`, …
- **Action** — действие в глагольной форме: `Create`, `Edit`, `Assign`, `View`, …
Примеры:
- `ProjectCreateModal`
- `CategoryEditModal`
- `ReviewDetailsModal`
- `ExpertAssignModal`
### Подтверждение действий
Для модальных окон, которые подтверждают действие, добавляется префикс:
> **`<Entity><Action>ConfirmModal`**
Примеры:
- `ProjectDeleteConfirmModal`
- `ExpertUnassignConfirmModal`
- `ReviewRejectConfirmModal`
### Преимущества схемы
- структурированность,
- предсказуемость,
- понятные правила,
- единообразие в кодовой базе.
## Закрытие модального окна
Клик по иконке закрытия, нажатие _Esc_ или клик вне модального окна — **эквивалентные действия**,
приводящие к отмене и закрытию.
Исключения:
- важные процессы, которые нельзя прервать случайно;
- формы ввода данных, во избежание потери изменений, если эти изменения критичны.
В таких случаях:
- окно может не закрываться через Esc/клик вне области;
- перед закрытием нужно уточнить: сохранить изменения или выйти.
## Асинхронные действия
Используйте `ActionDialog`, если:
- нужно подтвердить действие,
- выполнить асинхронную операцию,
- показать прогресс,
- отобразить успех или ошибку.
Для особых сценариев допускается использовать обычный `Dialog`, но:
- во время выполнения действия кнопки должны быть заблокированы,
- окно не должно закрываться до завершения операции,
- ошибка должна быть отображена в понятной форме.
## Dialog vs Modal
> Кратко: **Dialog — технология, Modal — сценарий продукта.**
- **`Dialog`** используется только для инфраструктурных UI-компонентов дизайн-системы (например,
`Dialog`, `ActionDialog`, `NoticeDialog`). Это строительные блоки, не связанные с доменными
сущностями.
- **`Modal`** используется для прикладных модальных окон, связанных с реальными пользовательскими
сценариями (`ProjectCreateModal`, `ConfirmProjectDeleteModal`, `ReviewDetailsModal`). Такие
компоненты используют `Dialog` внутри, но представляют собой доменные элементы интерфейса.
-61
View File
@@ -1,61 +0,0 @@
# Быстрый старт
## Репозиторий
Настраиваем [ssh ключи в gitea](https://git.jt4d.ru/user/settings/keys), клонируем репозиторий и
ставим зависимости. В проекте используется пакетный менеджер yarn
```bash
git clone ssh://git@git.jt4d.ru:2222/1vit/more.git
cd more
yarn
```
Установка может занять несколько минут.
## Настройка БД
Рекомендуется устанавливать всё через docker чтобы избежать проблем с зависимостями и сделать
окружение более повторяемым.
Клонируем [репозиторий с docker-compose](https://git.jt4d.ru/1vit/pgsql) и выполняем
`docker compose up`. Если команда не найдена, ставим compose
[пакетом](https://docs.docker.com/compose/install/linux/#install-using-the-repository) или
[из исходников](https://docs.docker.com/compose/install/linux/#install-the-plugin-manually).
Потом создаём в корне проекта (основного репозитория) файл `.env` (пример в `.env.example`). Он
используется для подключения к основной БД.
```
DB_HOST=localhost
DB_PORT=5432
DB_USERNAME=1vit_more
DB_PASSWORD=password
DB_DATABASE=1vit_more
```
Ещё рекомендуется создать `.env.test` (пример в `.env.test.example`), он будет использоваться для
тестов с использованием БД. Если файл не найден, будет перезаписываться основная база.
Проверяем подключение `yarn db:show` и накатываем миграции (создание структуры данных, schema)
`yarn db:migrate`.
Далее нужно заполнить БД демо-данными при помощи команды `yarn test-data:db-restore`.
## Запуск
| тип сервера | команда |
| ------------------------------------------------- | ------------------------------------------------------------------ |
| Полный dev сервер с БД | `yarn start` |
| Сторибук (для тестирования отдельных компонентов) | `yarn storybook` |
| Jest (unit-тесты) | `yarn test:unit` или `yarn test:unit path/to/file` |
| Jest (db-тесты) | `yarn test:db`, см. [доку про дб тесты](/docs/developing/database) |
## CI
При каждом `git push` запускается сборка приложения на
[Gitea Actions](https://git.jt4d.ru/1vit/more/actions).
Стенд для ветки доступен по адресу `http://<branch-name>.<STAGING_HOST>`.
Подробнее о системе деплоя, тегах и продакшн-деплое — в [разделе «Поставка»](/docs/deploy/ci-cd).
-58
View File
@@ -1,58 +0,0 @@
# Ветки и пулл-реквесты
## Branches
Для работы над задачей создаётся ветка с названием формата `i<номер_issue>-<краткое_описание>`.
Краткое описание это БУКВАЛЬНО 1-2 слова. Примеры:
```txt
i472-statements
i343-project-page
```
## Pull requests
Сразу после загрузки (`git push`) ветки рекомендуется создавать ей pull request и привязать его к
задаче
Описание PR должно иметь вид список изменений - пустая строка - вид отношения к задаче и
\#номер_задачи, пример:
```md
- cover `viewRoot` flag in `users.dal` with db tests
- add `viewRole.findOne` test group
Closes #381
```
или
```md
- добавил тесты на наличие буквы, цифры, спецсимвола и на повторяемость.
- улучшен алгоритм генерации пароля
Closes #433
```
или
```md
- rename statements-period.types
- fix DateView stories
Related to #472
```
1. Загружаем (`git push`) ветку
2. Создаём pull request с названием `WIP: <название_ветки>`
3. Добавляем описание
4. Добавляем PR в зависимости задачи (Dependencies справа)
Когда ветка готова, отправляем на ревью (Reviewers справа), убираем префикс `WIP` из названия и
перемещаем в столбец `To review` на [доске](http://git.arswarog.ru/1vit/more/projects/1).
## Доска проекта
Статус задач можно отслеживать на [доске проекта](http://git.arswarog.ru/1vit/more/projects/1). Pull
request на доску не добавляем, там должны быть только задачи. PR будет отображаться под задачей если
его добавить в зависимости.
-88
View File
@@ -1,88 +0,0 @@
# Файловая структура проекта
## Фронтенд
### Гостевой фронтенд
Располагается в `/src/visitor`
- Лендинг на главной странице
- Страница организатора
- Страница проекта
Все они имеют уникальный дизайн (на `scss`), и могут быть быстро изменены
#### Ограничения
Может обращаться только к `common`
### Клиентский фронтенд
Располагается в `/src/client`
- регистрация
- авторизация
- личный кабинет
- создание заявок
- панель эксперта
- панель модератора
- панель админа
#### Ограничения
Может обращаться только к `common`
---
## Бэкенд
Располагается в папке `/src/server`
### Ограничения
Может обращаться только к `common` и `client`
---
## Общие типы и функции
Располагаются в папке `/src/common`
### Ограничения
Не может обращаться ни к каким другим частям, т.к. является корнем
## Демо данные
Располагаются в папке `/src/common`
Требования:
- Файл с этими данными должен называться `<название>.demo.ts`, где `<название>` - это название
типа во множественном числе
- Переменная с демо данными должна начинаться с `demo` и иметь вид `demo<название>`
- Переменные с вариантами данных одного типа должны начинаться с `demo<название>` и иметь вид
`demo<название><название варианта>`
- Если демо данных одного типа больше одного значения, то эти значения располагаются в этом же
файле
Рекомендация:
В названии константы указывать её тип (`demoEvent: IEvent`).
### Пример
Пример организации демо данных участников:
```ts
/// file: src/demoData/participants.ts
export const demoParticipantBase: IBaseParticipant = { ... }
export const demoParticipantKey: IKeyParticipant = { ... }
export const demoParticipants: IParticipant[] = [
demoParticipantBase,
demoParticipantKey,
]
```
-58
View File
@@ -1,58 +0,0 @@
# Схема зависимостей модулей
```mermaid
classDiagram
Applications
Applications <|-- Evaluation
Auth
Client
Contests
Contests : ContestsService
Contests : ContestAreasService
Contests <|-- Requests
Contests <|-- Applications
Contests <|-- Evaluation
Events
Events : EventsService
Events <|-- Requests
note for Events "Events должен\n зависеть от Requests,\n а не наоборот"
Expenses
Expenses : ExpensesService
Expenses <|-- Statements
Export
Export : ExportService
Export <|-- Statements
Export <|-- Requests
Files
Files : FilesService
Organizators
Organizators : OrganizatorsService
Organizators <|-- Applications
Organizators <|-- Contests
Organizators <|-- Requests
Profile
Requests
Requests : RequestsService
Requests <|-- Expenses
Requests <|-- Statements
Statements
Statements : StatementsPeriodsService
Users
Users : UsersService
Users <|-- Client
Users <|-- Profile
```
-110
View File
@@ -1,110 +0,0 @@
# Тестирование с использованием реального бэкенда
Поднимается полноценнный nest сервер, затем для каждого теста БД сбрасывается и восстанавливается из
снапшота (`/test-data/db/`)
### CI/CD
Для каждого прогона
- создаётся база тестовая данных
- запускаются тесты с бэком
- тестовая база удаляется
Для CI/CD отдельных настроек не требуется
### Настройка
Настройки для тестов хранятся в файле `.env.test`. Пример файла:
```env
DB_USERNAME=test_user
DB_PASSWORD=secret
DB_DATABASE=test_db
```
Все недостающие значения будут взяты из `.env`.
### Локальный запуск
При запуске тестов можно использовать команды
- `yarn test:db` запустит только тесты с БД
- `yarn test` запустит все тесты в проекте
### Написание тестов
Тесты должны иметь расширение `.db-spec.ts`.
Для создания и запуска бэка используется утилита `createTestingBackend()`. В возвращаемом ей
интерфейсе есть:
- метод `resetState()` - нужно запускать перед каждым тестом
- метод `destroy()` - вызывать в конце группы тестов
- поле `http` - результат вызова `INestApplication.getHttpServer()`.
Также есть утилита `authenticate`, которую можно использовать для входа.
Пример управления состоянием:
```ts
let app: ITestingBackend;
beforeAll(async () => {
app = await createTestingBackend();
});
afterAll(async () => {
await app.destroy();
});
beforeEach(async () => {
await app.resetState();
});
```
Пример теста:
```ts
it('при создании AppForm имеет статус draft и не удалена', async () => {
const api = supertest(app.http);
const response = await api
.post('/v1/contests/1/forms')
.set('Cookie', await authenticate(api, 'admin'))
.send(exampleValidForm)
.expect(201);
expect(response.body).toMatchObject({
draft: true,
deleted: false,
});
});
```
### Тестирование изолированной части бэкенда
Для unit-тестов определённой части бэкенда можно использовать утилиту `createTestingHarness(app)`.
Пример:
```ts
const module = await Test.createTestingModule({
providers: [UsersDal, TimeService, createMockConfigProvider(authConfigToken)],
imports: [
TypeOrmModule.forRoot({
...testOrmCredentials,
entities: [User, Contest, AuthToken],
synchronize: false,
logging: false,
}),
TypeOrmModule.forFeature([User]),
],
}).compile();
app = module.createNestApplication();
await app.init();
const harness = await createTestingHarness(app);
harness.resetState(); // имеет тот же интерфейс, что createTestingBackend
```
-1
View File
@@ -1 +0,0 @@
label: 'Разработчику'
-67
View File
@@ -1,67 +0,0 @@
# Введение
## Зачем нужно логирование
### Цель
Логирование — это ключевой инструмент диагностики и анализа поведения системы. Оно фиксирует
**действия, ошибки и события**, позволяя:
- анализировать поведение приложения;
- находить причины ошибок и сбоев;
- отслеживать бизнес-процессы (например, запуск конкурса или назначение эксперта);
- проводить аудит действий пользователей;
- контролировать производительность и стабильность работы.
Хорошее логирование даёт **контекст и доказательства** — кто, когда и что сделал, с каким
результатом.
### Основные задачи логирования
Логирование решает как **технические**, так и **организационные** задачи.
**Технические:**
- **Диагностика:** восстановление хода событий при сбое.
- **Аналитика:** понимание того, как система используется.
- **Поддержка:** ускорение поиска причин ошибок в эксплуатации.
**Организационные:**
- **Аудит:** фиксация действий пользователей и администраторов.
- **Безопасность:** выявление попыток несанкционированного доступа.
## Уровни логирования
В системе используется библиотека **Pino**, интегрированная через `nestjs-pino`. Она поддерживает
стандартные уровни логирования, отражающие важность события.
| Уровень | Метод | Когда использовать | Пример |
| --------- | --------- | --------------------------------------------------- | -------------------------------- |
| **TRACE** | `trace()` | Максимальная детализация, пошаговая трассировка | Проверка цепочки вызовов |
| **DEBUG** | `debug()` | Отладочная информация о логике работы | Вывод промежуточных данных |
| **INFO** | `log()` | Обычные события нормальной работы | Создание проекта |
| **WARN** | `warn()` | Нежелательные, но некритичные ситуации | Отказ в доступе |
| **ERROR** | `error()` | Ошибки, требующие внимания и реакции | Исключение при сохранении |
| **FATAL** | `fatal()` | Критические сбои, приводящие к остановке приложения | Потеря соединения с базой данных |
> 💡 Все уровни логов фиксируются одинаково по структуре данных — различается только их
> **важность**.
## Различие между dev и prod
Логирование настроено так, чтобы быть **удобным в разработке** и **эффективным в продакшене**.
| Среда | Формат | Уровень по умолчанию | Особенности |
| --------------- | ----------------- | -------------------- | --------------------------------------------------------------------- |
| **Development** | человеко-читаемый | `debug` | Цвета, отступы, подробные данные — удобно для чтения в консоли. |
| **Production** | JSON | `info` | Машиночитаемый формат для централизованной агрегации и анализа логов. |
> Состав данных в логе всегда одинаков — меняется только способ отображения. Уровень логирования
> можно переопределить через переменные окружения.
Таким образом:
- в **разработке** акцент на удобство восприятия и диагностику;
- в **продакшене** — на структурированные данные и интеграцию с системами мониторинга (например,
Loki или ELK).
-91
View File
@@ -1,91 +0,0 @@
# Подключение и использование
## Получение логгера
Логгер внедряется в любой сервис, контроллер или компонент через **dependency injection**. После
внедрения требуется указать **контекст**, чтобы логи было проще анализировать.
```ts
@Injectable()
export class ProjectService {
constructor(private readonly logger: Logger) {
this.logger.setContext('Projects');
}
async createProject(data: CreateProjectDto) {
this.logger.log('Project created', { project: 42, user: data.ownerId });
}
}
```
## Контекст логгера
Контекст указывает, **к какому модулю или бизнес-процессу относится лог**. Он помогает группировать
события и быстрее понимать источник при анализе системы.
Контекст задаётся вручную при инициализации логгера:
```ts
constructor(private readonly logger: Logger) {
this.logger.setContext('Evaluation');
}
```
## Правила именования контекста
Хорошие контексты делают логи самодокументируемыми. Чтобы они оставались единообразными, следуйте
правилам:
1. **Основой контекста является название модуля**, отражающее область бизнес-логики. Примеры:
- `Evaluation` — процесс оценки заявок;
- `Contests` — управление конкурсами;
- `Users` — работа с пользователями.
2. **При необходимости уточнения** добавляется второй уровень через двоеточие:
- `Evaluation:ExpertAssignment` — назначение экспертов;
- `Applications:Moderation` — модерация проектов.
3. **Технические роли** (`Service`, `Controller` и т. п.) в названии **не используются**, так как
контекст должен отражать **бизнес-смысл**, а не структуру кода.
> 💬 Контекст — часть “языка логирования” проекта. Он должен быть **стабильным, понятным и
> единообразным** во всех модулях.
## Контекст запроса
При логировании внутри HTTP-запроса система автоматически добавляет поля:
- `user` — идентификатор пользователя (если авторизован);
- `url` — адрес текущего запроса.
Это позволяет связывать бизнес-события с конкретными действиями пользователей и упрощает анализ
логов.
## Переменные окружения
Настройки логирования задаются через `.env` или переменные окружения.
| Переменная | Назначение | Пример значения |
| ----------------- | -------------------------------------------------------- | -------------------------------- |
| `LOG_LEVEL` | Минимальный уровень логирования. | `debug`, `info`, `warn`, `error` |
| `LOG_PRETTY` | Включает человеко-читаемый формат (обычно в dev). | `true` |
| `LOG_SILENT_HTTP` | Отключает автоматические HTTP-логи (актуально в тестах). | `true` |
Если переменные не заданы, применяются значения по умолчанию:
| Окружение | Значения по умолчанию |
| --------- | ------------------------------------ |
| **dev** | `LOG_LEVEL=debug`, `LOG_PRETTY=true` |
| **prod** | `LOG_LEVEL=info`, `LOG_PRETTY=false` |
> 🔧 Уровень логирования можно переопределить без релиза — через переменные окружения при запуске
> приложения.
## Чек-лист перед ревью
- [ ] Логгер подключён через dependency injection.
- [ ] Контекст задан явно через `setContext()`.
- [ ] Уровень логирования соответствует окружению.
- [ ] Логи не содержат чувствительных данных.
-106
View File
@@ -1,106 +0,0 @@
# Автоматическое логирование
Автоматическое логирование фиксирует системные события **без участия разработчика**. Оно
обеспечивает единый формат сообщений и полное покрытие ключевых операций платформы —
**HTTP-запросов**, **проверок доступа (RBAC)** и **ошибок**.
## Назначение
Цели автоматического логирования:
- Сократить количество ручных логов в коде.
- Обеспечить единообразие сообщений и уровней логирования.
- Повысить наблюдаемость и удобство диагностики.
- Позволить анализировать производительность и отказы без изменения бизнес-логики.
> 💡 Автоматические логи всегда присутствуют и формируются системой независимо от действий
> разработчика.
## Источники автоматических логов
| Тип события | Источник | Что логируется |
| --------------------------- | ---------------------------- | -------------------------------------------------------------- |
| **HTTP-запросы** | middleware `pino-http` | Каждый завершённый запрос с кодом ответа и временем обработки. |
| **Проверки доступа (RBAC)** | модуль `RbacModule` | Успешные и неуспешные проверки разрешений. |
| **Ошибки и исключения** | глобальный `ExceptionFilter` | Необработанные ошибки приложения и системные сбои. |
Все эти события регистрируются автоматически и не требуют дополнительного кода в модулях.
## HTTP-запросы
### Общие принципы
- Каждый HTTP-запрос логируется **после завершения обработки**.
- Лог формируется middleware `pino-http` на основе данных ответа.
- Разработчику не нужно добавлять логи вручную — они создаются автоматически.
Каждая запись содержит:
- HTTP-метод (`GET`, `POST`, `PATCH` и т. д.);
- путь запроса (`/api/v1/...`);
- код ответа (`200`, `403`, `500` и т. д.);
- время выполнения (в миллисекундах);
- идентификатор пользователя (`user`, если известен).
### Уровни логирования
| Категория ответа | Пример кода | Уровень |
| ---------------- | ----------- | ------- |
| Успешный ответ | `2xx` | `info` |
| Ошибка клиента | `4xx` | `info` |
| Ошибка сервера | `5xx` | `error` |
> Уровень `warn` для HTTP-логов не используется — это предотвращает появление ложных предупреждений
> при корректных отказах.
### Задачи HTTP-логов
- Отслеживание активности API.
- Измерение времени обработки запросов.
- Анализ причин ошибок и неудачных вызовов.
- Связь действий пользователей с конкретными маршрутами.
## Проверки доступа (RBAC)
### Общие принципы
Все проверки доступа логируются **автоматически** при вызовах методов `check*` и `accessDenied`. Это
обеспечивает аудит всех случаев — и разрешённых, и запрещённых действий.
### Уровни логирования
| Событие | Уровень | Что фиксируется |
| --------------- | ------- | -------------------------------------------------- |
| Отказ в доступе | `warn` | Попытка выполнить действие без нужного разрешения. |
| Доступ разрешён | `debug` | Успешная проверка разрешения. |
> Такое распределение уровней помогает разделять реальные проблемы (отказы) и штатные сценарии
> (успешные проверки).
## Ошибки и исключения
### Общие принципы
Глобальный `ExceptionFilter` автоматически логирует все необработанные ошибки:
- `error` — ошибки приложения (например, неверные данные или сбой бизнес-логики);
- `fatal` — критические сбои, приводящие к остановке приложения (например, невозможность
подключиться к базе данных).
### Структура записи
Каждая запись об ошибке включает:
- `trace` — стек вызовов;
- `context` — модуль, где произошла ошибка;
- `user` и `url` — если ошибка возникла в контексте HTTP-запроса.
> Это обеспечивает точную локализацию проблем и упрощает разбор инцидентов.
## Рекомендации
- Не дублируйте автоматические логи вручную.
- Если требуется зафиксировать **дополнительный контекст**, добавляйте отдельный лог рядом с
бизнес-событием — но не повторяйте уже зарегистрированные HTTP-запросы или проверки доступа.
- При анализе проблем **начинайте с автоматических логов** — они формируются всегда и имеют единый
формат.
-123
View File
@@ -1,123 +0,0 @@
# Ручное логирование и бизнес-события
Автоматическое логирование фиксирует системные события (HTTP-запросы, проверки доступа, ошибки), но
не отражает **внутреннюю бизнес-логику** приложения.
Ручное логирование используется для фиксации действий и изменений, важных **с точки зрения
продукта** — того, что видит или делает пользователь.
## Назначение
Ручные логи применяются для описания событий, которые отражают состояние системы и бизнес-процессы:
- создание, изменение и удаление сущностей;
- начало и завершение процессов;
- изменение статуса или состояния;
- ошибки бизнес-операций;
- любые другие действия, значимые для анализа и аудита.
> Такое логирование помогает понять, **что происходило внутри системы**, даже без включённой отладки
> или доступа к базе данных.
## Правила оформления логов
### Общие принципы
Сообщения должны быть:
- **краткими** — не длиннее одной фразы;
- **человеко-читаемыми** — понятными без знания кода;
- **однозначными** — описывать факт, а не процесс;
- **на английском**, **в прошедшем времени**, **без артиклей и пунктуации**.
Если операция завершилась неудачей, сообщение должно оканчиваться на `failed`.
Логирование выполняется **после завершения** операции — независимо от результата.
### Примеры сообщений
**Успешные операции:**
- `Project created`
- `Contest launched`
- `Review assigned`
- `Evaluation completed`
**Ошибки и неудачи:**
- `Project update failed`
- `File upload failed`
- `Review submission failed`
**Неправильные формулировки:**
- `Creating project...` — отражает процесс, а не факт.
- `Contest ${id} launched` — содержит шаблон и динамику.
- `Project created successfully!` — избыточно и разговорно.
### Дополнительные данные
Контекст события передаётся отдельным объектом (`details`). Включайте только данные, которые
действительно помогают при анализе.
**Рекомендации:**
- Всегда указывайте **идентификаторы** (`project`, `contest`, `user` и т. д.).
- В идентификаторах избегайте суфиксов `id` (пример: `project` вместо `projectId`).
- Добавляйте **причину** (`reason`) при ошибках или отказах.
- Указывайте **новое состояние** (`status`, `result`) при изменениях.
- Не включайте чувствительные данные — токены, e-mail, пароли, персональные сведения.
**Пример:**
```ts
logger.log('Project created', { project: 42, user: 7 });
logger.error('Project update failed', { project: 42, reason: 'invalid_status' });
```
## Бизнес-события
### Что считается бизнес-событием
Бизнес-события описывают **действия и изменения на уровне предметной области**, например:
- `Contest launched` — запуск конкурса;
- `Project submitted` — заявка подана;
- `Review assigned` — эксперт назначен;
- `Evaluation completed` — оценка завершена.
> Все бизнес-события логируются на уровне `info` (`logger.log()`), а ошибки бизнес-операций — на
> уровне `error`.
### Длительные процессы
Для долгих операций допускается логирование **двух фаз**:
- начало — `<Action> started`;
- завершение — `<Action>` (применяется обычные правила оформления сообщений).
**Пример:**
```ts
logger.log('File export started', { contest: 3 });
logger.log('File exported', { contest: 3, durationMs: 14250 });
logger.error('File export failed', { contest: 3, reason: 'timeout' });
```
Такой подход позволяет отслеживать прогресс и измерять длительность операций.
### Зачем нужны бизнес-события
- Повышают прозрачность бизнес-процессов.
- Используются при анализе и аудите.
- Позволяют отследить жизненный цикл сущностей — от создания до завершения.
- Упрощают поиск причин ошибок на уровне сценариев пользователей.
## Чек-лист перед ревью
- [ ] Контекст логгера задан.
- [ ] Сообщение короткое, на английском и в прошедшем времени.
- [ ] В `details` указаны идентификаторы и причина (если применимо).
- [ ] Уровень логирования выбран корректно (`info` или `error`).
- [ ] Лог не дублирует автоматические записи (HTTP, RBAC).
- [ ] Отсутствуют конфиденциальные данные.
-160
View File
@@ -1,160 +0,0 @@
# Тестирование логирования
## Подключение
Для тестов следует подключать специальный модуль — **`LocalLoggerModule.forTesting()`**. Он
сохраняет логи в памяти и предоставляет к ним доступ через `TestingLogger`.
Это позволяет:
- перехватывать все вызовы логгера внутри тестируемого кода;
- сохранять их в память вместо вывода в консоль;
- проверять, какие именно логи были собраны за время теста.
Пример подключения через NestJS `TestingModule`:
```ts
let logger: TestingLogger;
beforeEach(async () => {
const module = await Test.createTestingModule({
imports: [LocalLoggerModule.forTesting()],
}).compile();
logger = module.get(TestingLogger);
});
```
После такого подключения любые вызовы вида:
```ts
this.logger.log('Project created');
this.logger.error('Project update failed');
```
будут сохранены внутри `TestingLogger`, и вы сможете проверить их в тестах при помощи матчеров:
```ts
logger.withMessage('Project created').withLevel('info').toBeLoggedOnce();
logger.withMessage('Project update failed').withLevel('error').toBeLogged();
```
## Цепочка фильтров
Методы фильтрации (`withMessage`, `withLevel`, `withContext`, `withDetails`) можно вызывать
цепочкой, для более точного сопоставления логов:
```ts
logger
.withMessage('Project created')
.withLevel('info')
.withContext('Projects')
.withDetails({ user: 5 })
.toBeLoggedOnce();
```
Метод `withDetails` поддерживает частичное сравнение объекта, а также может проверять поля `level`,
`context` и `message`:
```ts
logger
.withMessage('Project created')
.withDetails({
level: 'info',
context: 'Projects',
user: 5,
})
.toBeLoggedOnce();
```
### Просмотр найденных записей
Если нужно отладить фильтр, и просмотреть, какие записи были найдены по текущему фильтру, можно
использовать метод `showLogs()`:
```ts
logger.withLevel('error').showLogs().toBeLogged();
```
### Методы проверки
| Метод | Описание |
| -------------------- | --------------------------------------------------------- |
| `toBeLogged()` | Проверяет, что хотя бы одна запись соответствует фильтру. |
| `toBeNotLogged()` | Проверяет, что таких записей нет. |
| `toBeLoggedOnce()` | Проверяет, что запись встречается ровно один раз. |
| `toBeLoggedTimes(n)` | Проверяет, что запись встречается `n` раз. |
Все методы выбрасывают ошибку, если условие не выполняется.
> **Важно:** Методы проверки **обязательно** должны вызываться, иначе проверки так и не будет.
### Группирование проверок
Если в тесте нужно проверить несколько событий с общим набором полей, можно использовать такой
приём:
```ts
const rbacLogger = logger.withContext('RBAC');
rbacLogger.withMessage('Access granted').toBeLoggedTimes(2);
rbacLogger.withMessage('Access denied').toBeLoggedOnce();
```
Это удобно для группировки проверок по контексту или другим общим параметрам.
## Примеры использования
### Проверка бизнес-события
```ts
it('должен логировать создание проекта', () => {
service.createProject({ user: 7 });
logger
.withMessage('Project created')
.withLevel('info')
.withDetails({ user: 7 })
.toBeLoggedOnce();
});
```
### Проверка ошибок
```ts
it('должен логировать ошибку при невалидном статусе', () => {
service.updateProjectStatus(42, 'invalid');
logger.withMessage('Project update failed').withLevel('warn').toBeLogged();
});
```
### Проверка отсутствия логов
```ts
it('не должен логировать ничего при успешном выполнении без событий', () => {
service.doNothing();
logger.any().toBeNotLogged();
});
```
### Очистка
```ts
beforeEach(() => {
logger.clear();
});
```
Используйте `clear()` для сброса состояния между тестами, если экземпляр `TestingLogger`
переиспользуется в нескольких сценариях.
## Рекомендации
- `TestingLogger` предназначен **только для тестов** — в рабочем коде используется обычный
`Logger`.
- Проверяйте **факт логирования**, а не реализацию — тесты должны оставаться устойчивыми к
внутренним изменениям.
- Используйте `toBeNotLogged()` для сценариев, где событие **не должно** быть зафиксировано.
-1
View File
@@ -1 +0,0 @@
label: 'Логирование'
-1
View File
@@ -1 +0,0 @@
label: 'Технические модули'
@@ -1 +0,0 @@
label: 'Auth модуль и gateway'
-157
View File
@@ -1,157 +0,0 @@
# Принцип и сценарии
## Глоссарий
- **гость** не аутентифицированный посетитель
- **пользователь** аутентифицированный посетитель
## Назначение и требования к токенам
### Access token
- Короткоживущий многоразовый токен
- JWT
- За счет короткого времени жизни дополнительной проверки не требуется
### Refresh token
- Предназначен для одноразового получения нового комплекта токенов
- Токен должен храниться в базе данных и содержать следующую информацию:
- Разрешение на генерацию токена
- предыдущий refresh token
- аутентификация пользователя
- регистрация пользователя
- Т.к. токен одноразовый и периодически обновляется, то нельзя использовать sessionStorage для его
хранения
## Технические сценарии
### Гость на сайте
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Server
C->>G: Request without access token
G->>S: Request without user info
S->>G: Response
G->>C: Response without tokens
```
### Гость логинится
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Server
C->>S: Credentials
Note over S: Check credentials
alt valid and user active
S->>C: access_token, refresh_token
else invalid or user not active
S-->>C: 422 error
end
```
### Гость регистрируется
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Server
C->>S: Credentials
Note over S: Check for no access token
Note over S: Check credentials
alt valid and user active
S->>C: Congratulation page with redirect on timeout to login
else invalid or user not active
S-->>C: 422 error
end
```
### Пользователь выполняет запрос с корректным токеном
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Server
C->>G: Request with access token
Note over G: Check access token
G->>S: Request with session data
alt session data changed
S->>G: Change sesstion data
G->>C: Response with new access token
else no sesstion changed
S->>C: Response
end
```
### Пользователь выполняет запрос с некорректным токеном
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Server
C->>G: Request with access and refresh tokens
Note over G: Check access token
Note over G: Access token invalid
Note over G: Check refresh token
alt refresh token valid, user is active
G->>S: Request with sesstion data
S->>G: Response with/without session data
Note over G: Create new tokens with session data
G->>C: Response with new access and refresh tokens
else refresh token expared
G-->>C: 401 Token expired
else refresh token already used
Note left of G: Leak warning
G-->>C: 419 Token already used, leak warning
end
```
Если `refresh token` уже был ранее использован, то это может означать что токен ранее утек, потому
пользователю надо об этом сообщить, и, возможно заблокировать все refresh token'ы, выпущенные
благодаря потенциально утекшему
### Пользователь обновляет токены
```mermaid
sequenceDiagram
participant C as Client
participant G as Gateway
participant S as Server
C->>G: Request with access and refresh tokens
Note over G: Check refresh token
alt refresh token valid, user is active
Note over G: Create new tokens with session data from access token
G->>C: Response with new access and refresh tokens
else refresh token expared
G-->>C: 401 Token expired
else refresh token already used
Note left of G: Leak warning
G-->>C: 419 Token already used, leak warning
end
```
Если `refresh token` уже был ранее использован, то это может означать что токен ранее утек, потому
пользователю надо об этом сообщить, и, возможно заблокировать все refresh token'ы, выпущенные
благодаря потенциально утекшему
### Пользователь логинится
При переходе пользователя на страницу логина его перенаправляет на главную
### Пользователь регистрируется
Регистрация недоступна для аутентифицированного пользователя
-31
View File
@@ -1,31 +0,0 @@
# Настройки
## Порядок применения настроек
Настройки берутся из следующих мест (в порядке убывания приоритета):
### Переменные окружения
Ключ для каждой настройки формируется как SCREAMING_SNAKE_CASE из полного пути к настройке, включая
scope
### .env файл
Значения берутся из `.env` файла (для `NODE_ENV` = `test` из файла `.env.test`)
### Значения по умолчанию
Значения по умолчанию указаны в коде приложения
## Настройки для тестирования
Переменные окружения для теста можно указать, подменив значение провайдера `VIRTUAL_ENV`:
```ts
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule],
})
.overrideProvider(VIRTUAL_ENV)
.useValue({ NEW_OPTION: 'someValue' })
.compile();
```
@@ -1,61 +0,0 @@
# Общие концепции
## Что такое DataStoreModule
`DataStoreModule` — это сервисный модуль в NestJS, предназначенный для хранения **внутренних данных
feature-модулей**. Он работает через концепцию **bucket** (аналог внутреннего S3 или localStorage
для конкретного модуля).
> Важно: `DataStoreModule` не имеет отношения к S3 и используется только для хранения служебных
> данных конкретного модуля.
Каждый модуль получает собственное пространство хранения, изолированное от других. Даже если два
разных модуля используют bucket с одинаковым именем, они всё равно будут независимы.
---
## Основные концепции
### Bucket
- Логическое пространство хранения внутри модуля.
- Для каждого конкурса используется отдельный bucket.
- Если требуется хранить данные, не связанные с конкурсом, используется `contestId = 0`.
- Имеет имя (например, `"test"`).
- Может использоваться для разных типов данных: настройки, результаты модерации и т.д.
Пример:
- `bucket: settings` → хранение общих настроек модуля.
- `bucket: moderationResults` → хранение результатов модерации.
### Feature-модуль
Каждый бизнес-модуль (например, `FooModule`, `BarModule`) подключает `DataStoreModule` через метод
`forFeature`. В результате внутри модуля можно работать с одним или несколькими bucket’ами.
---
## Поддерживаемые сценарии
- **Работа в production**`forRoot` подключает модуль к базе данных, а `forFeature` регистрирует
bucket’ы для конкретного feature.
- **Тестирование**`forTesting` создаёт in-memory версию, совместимую по API с production, но
без использования БД.
---
## Зачем использовать DataStoreModule
- Унифицированное API для хранения служебных данных модулей без необходимости создания собственных
таблиц.
- Автоматическое разделение данных между модулями и конкурсами.
- Простая интеграция в любой feature-модуль.
### Когда использовать DataStoreModule
- Временное хранение данных, не требующих сложных запросов.
- Хранение данных, не требующих частого обращения и сложных запросов.
- Хранение данных при прототипировании.
---
@@ -1,98 +0,0 @@
# Использование
## Подключение в AppModule
В корневом модуле приложения необходимо инициализировать `DataStoreModule` с помощью метода
`forRoot`. В production используется хранилище на базе БД.
```ts
// app.module.ts
import { Module } from '@nestjs/common';
import { DataStoreModule } from '@src/server/data-store';
import { FooModule } from './foo/foo.module';
@Module({
imports: [DataStoreModule.forRoot(), FooModule],
})
export class AppModule {}
```
---
## Подключение bucket в feature-модуле
Каждый feature-модуль определяет, какие bucket’ы ему нужны, через метод forFeature.
```ts
// foo.module.ts
import { Module } from '@nestjs/common';
import { DataStoreModule } from '@src/server/data-store';
import { FooService } from './foo.service';
@Module({
imports: [DataStoreModule.forFeature('foo', ['settings'])],
providers: [FooService],
exports: [FooService],
})
export class FooModule {}
```
В примере выше модуль `foo` получает доступ к bucket с именем `settings`.
> Важно: В feature-модуле будут доступны только bucket'ы указанные в `forFeature`. Попытка
> инжектировать не объявленный bucket приведёт к ошибке.
---
## Инжектирование bucket в сервис
Для работы с bucket используется декоратор `@InjectDataStore(bucketName)`. Каждый сервис получает
свой экземпляр DataStore для конкретного bucket.
```ts
// foo.service.ts
import { Injectable } from '@nestjs/common';
import { InjectDataStore, DataStore } from '@src/server/data-store';
@Injectable()
export class FooService {
constructor(
@InjectDataStore('settings')
private readonly bucket: DataStore<string, { value: string }>,
) {}
async saveValue(key: string, value: string) {
await this.bucket.set(1, key, { value });
}
async loadValue(key: string) {
return this.bucket.get(1, key);
}
}
```
---
## Независимость bucket’ов разных модулей и конкурсов
Даже если несколько модулей используют bucket с одинаковым именем (`'test'`), они будут полностью
изолированы.
Пример:
- FooModule с bucket `test` и
- BarModule с bucket `test`
получат разные хранилища, и данные не будут пересекаться.
Тоже самое и для разных конкурсов: данные для `contestId = 1` и `contestId = 2` будут храниться
отдельно.
---
## Резюме
- В `AppModule` подключаем `DataStoreModule.forRoot(...)`.
- В каждом feature-модуле объявляем нужные bucket’ы через `forFeature(...)`.
- Доступ к bucket в сервисе осуществляется через `@InjectDataStore`.
- Bucket’ы одного имени в разных модулях изолированы друг от друга.
@@ -1,55 +0,0 @@
# Тестирование
## Зачем нужен тестовый режим
Для юнит- и интеграционных тестов используется метод `forTesting`. В этом случае `DataStoreModule`
работает на **in-memory движке**:
- API полностью совпадает с production-режимом,
- данные не сохраняются между перезапусками,
- не требуется подключение к реальной базе данных.
---
## Подключение в тестах
Вместо `forRoot` в тестовом модуле подключается `forTesting`.
```ts
// example.spec.ts
import { Test } from '@nestjs/testing';
import { DataStoreModule } from '@src/server/data-store';
import { FooModule } from './foo/foo.module';
describe('FooModule', () => {
it('Инициализация', async () => {
const module = await Test.createTestingModule({
imports: [DataStoreModule.forTesting(), FooModule],
}).compile();
expect(() => module.createNestApplication()).not.toThrow();
});
});
```
---
## Доступ к данным
Для задания данных в тестах достаточно получить бакет через сервис модуля:
```ts
it('Запись данных в bucket', async () => {
const module = await Test.createTestingModule({
imports: [DataStoreModule.forTesting(), FooModule, BarModule],
}).compile();
const dataStore = module.get(DataStoreService);
const barService = module.get(BarService);
const bucket = dataStore.bucket('bar', 'test'); // доступ к bucket "test" модуля "bar"
await bucket.set(1, 'key', { value: 'bar' });
expect(await barService.bucket.get(1, 'key')).toEqual({ value: 'bar' });
});
```
@@ -1 +0,0 @@
label: 'DataStore'
-56
View File
@@ -1,56 +0,0 @@
# Документы и изображения
## Общее устройство
Все загружаемые файлы хранятся в S3 хранилище, на сервер приложения файлы могут сохраняться только
на время обработки и только в директорию для временных файлов.
Изображение так же является документом, только с дополнительными возможностями
Индентификатор документа - это непорядковый длинный код
Отдельной системы контроля доступа нет, если пользователь знает ID файла - он может к нему
обратиться
Обновление файлов не предусмотрено, если нужно обновить - то загружается новый файл, и в нужном
месте указывается уже новый идентификатор, обновлять можно только название и описание.
## Документ
### Сохраняемая информация
В базе данных о каждом документе хранится информация.
- оригинальное имя файла (используется при скачивании)
- время загрузки файла
- пользователь, загрузивший файл
- mime тип файл
- общий тип файла, пока только документ или изображение
- так же файлу можно задать название документа и описание
- размер файла
### Доступные действия
- получение информации о файле (`GET /files/:id`)
- получение информации о нескольких файлах (`GET /files/many/:id,:id,:id`)
- просмотреть (`GET /files/:id/view`)
- скачать (`GET /files/:id/download`) отличается от **посмотреть** тем, что указываются заголовки
для скачивания
- загрузка файла (`POST /files`), возвращает структуру `IDocument` или дочернюю
## Изображение
Изображение так же являются документом, и для него доступны те же действия и возможности
### Сохраняемая информация
Дополнительно сохранятся размер изображения
### Дополнительные доступные действия
- просмотреть с определенными размерами (`GET /files/:id/view/:size`)
где `size` это Enum с заранее определенными размерами и параметрами
Для просмотра изображения используется сервер, на лету меняющий размеры изображения, для этого
сервер приложения выполняет редирект на специально подготовленный адрес
-1
View File
@@ -1 +0,0 @@
label: 'UI/UX'
-84
View File
@@ -1,84 +0,0 @@
# Вкладки
Допустим нам нужно разделить страницу `users/page.tsx` на 2 вкладки:
- Основные данные
- Организации
При открытии страницы по умолчанию должна открываться вкладка "Основные данные".
Организуем структуру следующим образом:
```tsx
users / page.tsx; // корневая страница
general / page.tsx; // страница вкладки
organizators / page.tsx; // страница вкладки
```
## Определение вкладок
Добавим файл с определением вкладок:
```ts
// users/tabs.ts
import { ITabs } from '@src/client/uikit/TabBar';
export enum UserTab {
General = 'general',
Organizators = 'organizators',
}
export const clientUserTabs: ITabs<UserTab> = [
{
label: 'Основные данные',
slug: UserTab.General,
},
{
label: 'Организации',
slug: UserTab.Organizators,
},
];
```
И сделать по странице для каждой вкладки, _имя страницы_ должно совпадать с _текстовым значением_
варианта enum.
## Страницы вкладок
```tsx
// users/general/page.tsx
import { clientUserTabs } from '../tabs';
export default async function UserViewPage({ params }: { id: string }) {
return (
<Card>
<Card.Header />
<TabBar items={clientUserTabs} />
<Card.Body />
</Card>
);
}
```
Компонент `TabBar` предназначен для использования непосредственно внутри `Card`. Если поместить его
в `Card.Header`, стили могут сломаться.
## Корневая страница
Корневые страницы (`users/page.tsx` в нашем примере) не поддерживаются, поскольку имя страницы
привязано к варианту enum, а корневая страница была бы пустой строкой.
Если она вам нужна, создайте страницу для редиректа:
```tsx
// users/page.tsx
import { redirect } from 'next/navigation';
interface IProps {
params: { id: string };
}
export default function UserViewPage({ params }: IProps) {
redirect(`/admin/users/${params.id}/general`);
}
```
-64
View File
@@ -1,64 +0,0 @@
# CI/CD и деплой
Система CI построена на **Gitea Actions** (`.gitea/workflows/`). Два основных сценария: ветка и тег.
## Ветки → стенды
При каждом `git push` в любую ветку (включая `master`) автоматически:
1. Собирается проект (`yarn build`)
2. Публикуются Docker-образы в registry `git.jt4d.ru/1vit/more/`:
- `web:<branch>`
- `api:<branch>`
- `ingress:<branch>`
3. Разворачивается стенд на стейджинге по адресу `http://<branch>.<STAGING_HOST>`
Каждый стенд получает изолированную базу данных. Миграции запускаются автоматически при деплое.
При удалении ветки стенд и образы удаляются автоматически.
## Теги → релизные образы
При `git push` тега (например `v1.18.0`) запускается workflow `release.yaml`:
1. Собирается проект
2. Публикуются Docker-образы с **двумя тегами** — именем тега и `latest`:
- `git.jt4d.ru/1vit/more/web:v1.18.0`
- `git.jt4d.ru/1vit/more/web:latest`
- Аналогично для `api` и `ingress`
Стенд при push тега **не создаётся**.
Чтобы выпустить релиз:
```bash
git tag v1.18.0
git push origin v1.18.0
```
## Продакшн-деплой
Для деплоя на продакшн используется `docker-compose.production.yml`. Образы берутся из Gitea
registry.
Развернуть последнюю версию (`latest`):
```bash
PROD_HOST=example.com \
DB_HOST=db DB_USERNAME=user DB_PASSWORD=pass DB_DATABASE=mydb \
docker compose -f docker-compose.production.yml up -d
```
Развернуть конкретную версию:
```bash
TAG=v1.18.0 PROD_HOST=example.com \
DB_HOST=db DB_USERNAME=user DB_PASSWORD=pass DB_DATABASE=mydb \
docker compose -f docker-compose.production.yml up -d
```
После деплоя не забыть прогнать миграции:
```bash
docker exec 1vit_more_prod_api node_modules/.bin/typeorm migration:run -d data-source.js
```
-1
View File
@@ -1 +0,0 @@
label: 'Поставка'
+23 -33
View File
@@ -1,9 +1,12 @@
import fs from 'fs';
import yaml from 'js-yaml';
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
import type {NavbarItem} from '@docusaurus/theme-common'
import type { NavbarItem } from '@docusaurus/theme-common';
import type { Config } from '@docusaurus/types';
import yaml from 'js-yaml';
import { themes as prismThemes } from 'prism-react-renderer';
import docuservix from './plugins/docuservix';
interface DocsConfig {
title: string;
@@ -13,24 +16,17 @@ interface DocsConfig {
const docsConfig = yaml.load(fs.readFileSync('./.docuservix.yml', 'utf8')) as DocsConfig;
const {
title,
} = docsConfig
const { title } = docsConfig;
const url = process.env.DOCUSERVIX_URL;
const url = process.env.DOCUSERVIX_URL || 'http://example.com';
const {
org,
repo
} = docsConfig.project
const { org, repo } = docsConfig.project;
const {
docs: docsDir = 'docs',
blog: blogDir
} = docsConfig.dirs || {}
const { docs: _docsDir = 'docs', blog: blogDir } = docsConfig.dirs || {};
const giteaUrl = 'https://git.jt4d.ru';
const onBrokenLinks = (process.env.DOCUSERVIX_ON_BROKEN_LINKS as Config['onBrokenLinks']) || 'throw';
const onBrokenLinks =
(process.env.DOCUSERVIX_ON_BROKEN_LINKS as Config['onBrokenLinks']) || 'throw';
const config: Config = {
title,
@@ -39,6 +35,7 @@ const config: Config = {
markdown: {
mermaid: true,
},
plugins: [docuservix()],
themes: ['@docusaurus/theme-mermaid'],
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
@@ -89,16 +86,6 @@ const config: Config = {
],
],
themes: ['@docusaurus/theme-mermaid'],
plugins: [
[
path.resolve(__dirname, './plugins/docuservix-search/index.ts'),
{
providersModule: require.resolve('./src/search-providers'),
},
],
],
themeConfig: {
// Replace with your project's social card
image: 'img/docusaurus-social-card.jpg',
@@ -117,11 +104,13 @@ const config: Config = {
label: 'Документация',
position: 'left',
},
blogDir ? {
to: '/blog',
label: 'Блог',
position: 'left'
} : undefined,
blogDir
? {
to: '/blog',
label: 'Блог',
position: 'left',
}
: undefined,
{
href: `${giteaUrl}/${org}/${repo}`,
label: 'Gitea',
@@ -131,7 +120,8 @@ const config: Config = {
},
footer: {
style: 'dark',
copyright: `Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.`,
copyright:
'Проект хостится на JT4D.ru, документация собрана с использованием Docuservix и Docusaurus.',
},
prism: {
theme: prismThemes.github,
+351
View File
@@ -0,0 +1,351 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fixupPluginRules } from '@eslint/compat';
import { FlatCompat } from '@eslint/eslintrc';
import js from '@eslint/js';
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import tsParser from '@typescript-eslint/parser';
import etc from 'eslint-plugin-etc';
import _import from 'eslint-plugin-import';
import noOnlyTests from 'eslint-plugin-no-only-tests';
import noSkipTests from 'eslint-plugin-no-skip-tests';
import react from 'eslint-plugin-react';
import reactHooks from 'eslint-plugin-react-hooks';
import unusedImports from 'eslint-plugin-unused-imports';
import globals from 'globals';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: [
'**/.eslintrc.js',
'**/node_modules',
'**/coverage',
'**/build',
'**/.docusaurus',
'**/vite.config.*.timestamp*',
'**/vitest.config.*.timestamp*',
],
},
...compat.extends(
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
'prettier',
'plugin:eslint-comments/recommended',
),
{
plugins: {
import: fixupPluginRules(_import),
react,
'react-hooks': fixupPluginRules(reactHooks),
'@typescript-eslint': typescriptEslint,
etc,
'no-only-tests': noOnlyTests,
'no-skip-tests': noSkipTests,
'unused-imports': unusedImports,
},
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
parser: tsParser,
ecmaVersion: 6,
sourceType: 'module',
parserOptions: {
ecmaFeatures: {
modules: true,
},
},
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.ts', '.tsx', '.json'],
},
typescript: {
alwaysTryTypes: true,
},
},
react: {
version: 'detect',
},
},
rules: {
'@typescript-eslint/interface-name-prefix': 'off',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
curly: ['error', 'all'],
'max-params': 'off',
'no-console': [
'error',
{
allow: ['warn', 'error'],
},
],
'no-warning-comments': [
'error',
{
terms: ['fixme'],
location: 'anywhere',
},
],
'no-unused-vars': 'off',
'space-before-blocks': 'error',
'padding-line-between-statements': [
'error',
{
blankLine: 'always',
prev: '*',
next: ['break', 'continue', 'return'],
},
{
blankLine: 'always',
prev: ['const', 'let'],
next: '*',
},
{
blankLine: 'any',
prev: ['const', 'let'],
next: ['const', 'let'],
},
{
blankLine: 'always',
prev: 'directive',
next: '*',
},
{
blankLine: 'any',
prev: 'directive',
next: 'directive',
},
{
blankLine: 'always',
prev: 'block-like',
next: '*',
},
{
blankLine: 'always',
prev: '*',
next: 'block-like',
},
],
'import/order': [
'error',
{
pathGroups: [
{
pattern: 'react,bem-css-modules',
group: 'builtin',
position: 'before',
},
{
pattern: '@docuservix/**',
group: 'internal',
},
],
pathGroupsExcludedImportTypes: ['react'],
'newlines-between': 'always',
groups: ['builtin', 'external', 'internal', 'parent', ['sibling', 'index']],
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'react/no-direct-mutation-state': 'error',
'react/no-deprecated': 'error',
'react/no-unsafe': 'error',
'react/jsx-uses-vars': 'error',
'react/jsx-uses-react': 'error',
'react/jsx-curly-brace-presence': ['error', 'never'],
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
quotes: [
'error',
'single',
{
avoidEscape: true,
},
],
'quote-props': ['warn', 'as-needed'],
'@typescript-eslint/no-explicit-any': [
'warn',
{
ignoreRestArgs: true,
},
],
'@typescript-eslint/member-ordering': [
'error',
{
default: [
'public-static-field',
'protected-static-field',
'private-static-field',
'public-instance-field',
'protected-instance-field',
'private-instance-field',
'constructor',
'public-instance-method',
'protected-instance-method',
'private-instance-method',
'public-static-method',
'protected-static-method',
'private-static-method',
'signature',
],
},
],
'etc/prefer-interface': [
'warn',
{
allowLocal: true,
},
],
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-ignore': 'allow-with-description',
'ts-nocheck': 'allow-with-description',
'ts-check': false,
'ts-expect-error': false,
},
],
'@typescript-eslint/no-empty-interface': 'warn',
'@typescript-eslint/no-empty-function': 'warn',
'@typescript-eslint/no-unused-vars': 'off',
// todo изучить и включить
'@typescript-eslint/no-unused-expressions': 'off',
// todo изучить и включить
'@typescript-eslint/no-empty-object-type': 'off',
'no-restricted-imports': [
'error',
{
paths: [
{
name: '@nestjs/swagger',
importNames: ['PartialType'],
message:
"Please import 'PartialType' from '@src/server/common/nest' instead.",
},
{
name: 'react-bootstrap',
importNames: [
'Card',
'CardHeader',
'CardBody',
'CardFooter',
'Row',
'Col',
'Modal',
],
message: "Please use project's components with same name",
},
{
name: 'react-bootstrap/Modal',
message: "Please use project's components with same name",
},
{
name: '@nestjs/common',
importNames: ['Logger'],
message: "Please import 'Logger' from '@src/server/logger' instead.",
},
{
name: 'nestjs-pino',
importNames: ['Logger', 'PinoLogger'],
message: "Please import 'Logger' from '@src/server/logger' instead.",
},
],
},
],
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'error',
{
vars: 'all',
args: 'after-used',
ignoreRestSiblings: true,
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
},
],
'eslint-comments/require-description': [
'error',
{
ignore: ['eslint-enable'],
},
],
'eslint-comments/disable-enable-pair': [
'error',
{
allowWholeFile: true,
},
],
complexity: ['warn', 10],
eqeqeq: ['error'],
'func-style': ['warn', 'declaration'],
},
},
{
files: ['**/*.spec.{js,jsx,ts,tsx}'],
rules: {
'no-only-tests/no-only-tests': 'error',
'no-skip-tests/no-skip-tests': 'warn',
'no-console': [
'warn',
{
allow: ['warn', 'error'],
},
],
'@typescript-eslint/no-non-null-assertion': 'off',
},
},
];
+86 -50
View File
@@ -1,52 +1,88 @@
{
"name": "docusaurus",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@docusaurus/theme-mermaid": "3.10.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"js-yaml": "^4.2.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.0.0",
"typescript": "~6.0.2"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"engines": {
"node": ">=20.0"
}
"name": "docusaurus",
"version": "0.0.0",
"private": true,
"scripts": {
"build": "docusaurus build",
"clear": "docusaurus clear",
"deploy": "docusaurus deploy",
"docusaurus": "docusaurus",
"eslint:check": "yarn eslint",
"eslint:fix": "yarn eslint --fix",
"lint": "run-s eslint:fix prettier:fix",
"lint:check": "run-s eslint:check prettier:check",
"prepare": "husky",
"prettier:check": "prettier --check \"**/*.{ts,tsx,js,mjs,json,yml,yaml,md,mdx}\"",
"prettier:fix": "prettier --write \"**/*.{ts,tsx,js,mjs,json,yml,yaml,md,mdx}\"",
"serve": "docusaurus serve",
"start": "docusaurus start",
"swizzle": "docusaurus swizzle",
"typecheck": "tsc",
"write-heading-ids": "docusaurus write-heading-ids",
"write-translations": "docusaurus write-translations"
},
"lint-staged": {
"*.{json,ts,tsx,js,jsx,js,mjs,md,mdx,yaml,yml}": "prettier --write",
"{src,e2e}/**/*.{ts,tsx}": "eslint --quiet --fix"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 3 chrome version",
"last 3 firefox version",
"last 5 safari version"
]
},
"dependencies": {
"@docusaurus/core": "3.10.1",
"@docusaurus/faster": "3.10.1",
"@docusaurus/preset-classic": "3.10.1",
"@docusaurus/theme-mermaid": "3.10.1",
"@mdx-js/react": "^3.0.0",
"bem-css-modules": "^1.4.3",
"clsx": "^2.0.0",
"js-yaml": "^4.2.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.1",
"@docusaurus/tsconfig": "3.10.1",
"@docusaurus/types": "3.10.1",
"@eslint/compat": "^1.1.1",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.8.0",
"@types/js-yaml": "^4.0.9",
"@types/react": "^19.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-etc": "^2.0.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-no-skip-tests": "^1.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-unused-imports": "^4.0.1",
"globals": "^17.6.0",
"husky": "^9.1.7",
"lint-staged": "^17.0.7",
"npm-run-all": "^4.1.5",
"prettier": "^3.8.4",
"typescript": "~6.0.2"
},
"engines": {
"node": ">=20.0"
}
}
-46
View File
@@ -1,46 +0,0 @@
import path from 'path';
import type { LoadContext, Plugin } from '@docusaurus/types';
interface SearchPluginOptions {
providersModule: string;
}
export default function docuservixSearchPlugin(
_ctx: LoadContext,
options: SearchPluginOptions,
): Plugin {
return {
name: 'docuservix-search',
getThemePath() {
return path.resolve(__dirname, './theme');
},
getTypeScriptThemePath() {
return path.resolve(__dirname, './theme');
},
configureWebpack() {
return {
resolve: {
alias: {
'@docuservix-search/config': options.providersModule,
},
},
};
},
async contentLoaded({ actions }) {
actions.addRoute({
path: '/search',
component: '@theme/SearchPage',
exact: true,
});
actions.addRoute({
path: '/chat',
component: '@theme/ChatPage',
exact: true,
});
},
};
}
@@ -1,210 +0,0 @@
import { useLocation } from '@docusaurus/router';
import Link from '@docusaurus/Link';
import Layout from '@theme/Layout';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { chatUrl } from '@docuservix-search/config';
import styles from './styles.module.css';
interface Source {
file: string;
heading: string;
anchor: string;
score: number;
}
interface Message {
role: 'user' | 'assistant';
content: string;
sources?: Source[];
}
function stripNumericPrefixes(p: string): string {
return p
.split('/')
.map((seg) => seg.replace(/^\d+-/, ''))
.join('/');
}
function sourceToUrl(file: string, anchor: string): string {
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
p = stripNumericPrefixes(p);
return `/docs/${p}${anchor ? `#${anchor}` : ''}`;
}
function sourceToPath(file: string): string {
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
return stripNumericPrefixes(p);
}
function useQuery(): string {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get('q') ?? '';
}
export default function ChatPage(): JSX.Element {
const urlQuery = useQuery();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const initialSentRef = useRef(false);
const sendMessage = useCallback(async (content: string, history: Message[]) => {
if (!content.trim()) return;
const userMessage: Message = { role: 'user', content };
const newHistory = [...history, userMessage];
setMessages(newHistory);
setInput('');
setLoading(true);
setError(null);
try {
const res = await fetch(chatUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: newHistory }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data: { answer: string; sources?: Source[] } = await res.json();
setMessages((prev) => [
...prev,
{ role: 'assistant', content: data.answer, sources: data.sources },
]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при обращении к серверу');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (urlQuery && !initialSentRef.current) {
initialSentRef.current = true;
sendMessage(urlQuery, []);
}
}, [urlQuery, sendMessage]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, loading]);
const handleSend = () => {
if (!loading && input.trim()) {
sendMessage(input, messages);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<Layout title="Чат">
<div className={styles.page}>
{urlQuery && (
<div className={styles.header}>
<Link
to={`/search?q=${encodeURIComponent(urlQuery)}`}
className={styles.backLink}
>
Назад к поиску
</Link>
</div>
)}
<div className={styles.messages}>
{messages.length === 0 && !loading && (
<div className={styles.empty}>Задайте вопрос...</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`${styles.messageRow} ${msg.role === 'user' ? styles.messageRowUser : styles.messageRowAssistant}`}
>
<div
className={`${styles.bubble} ${msg.role === 'user' ? styles.userBubble : styles.assistantBubble}`}
>
<div className={styles.bubbleContent}>{msg.content}</div>
{msg.sources && msg.sources.length > 0 && (
<div className={styles.sources}>
<div className={styles.sourcesLabel}>Источники:</div>
{msg.sources.map((src, j) => (
<Link
key={j}
to={sourceToUrl(src.file, src.anchor)}
className={styles.sourceLink}
>
{src.heading || sourceToPath(src.file)}
</Link>
))}
</div>
)}
</div>
</div>
))}
{loading && (
<div className={`${styles.messageRow} ${styles.messageRowAssistant}`}>
<div
className={`${styles.bubble} ${styles.assistantBubble} ${styles.loadingBubble}`}
>
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
</div>
</div>
)}
{error && (
<div className={styles.errorRow}>
<div className={styles.errorText}>{error}</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className={styles.inputRow}>
<textarea
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Введите сообщение... (Enter — отправить, Shift+Enter — перенос)"
rows={2}
disabled={loading}
/>
<button
className={styles.sendBtn}
onClick={handleSend}
disabled={loading || !input.trim()}
>
Отправить
</button>
</div>
</div>
</Layout>
);
}
@@ -1,203 +0,0 @@
.page {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem 2rem;
}
.header {
padding: 0.75rem 0;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.backLink {
color: var(--ifm-color-primary);
font-size: 0.875rem;
text-decoration: none;
}
.backLink:hover {
text-decoration: underline;
}
.messages {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem 0;
}
.empty {
padding: 2rem 0;
color: var(--ifm-color-emphasis-500);
font-size: 0.95rem;
text-align: center;
}
.messageRow {
display: flex;
}
.messageRowUser {
justify-content: flex-end;
}
.messageRowAssistant {
justify-content: flex-start;
}
.bubble {
max-width: 75%;
padding: 10px 14px;
font-size: 0.9rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
border-radius: var(--ifm-global-radius);
}
.userBubble {
color: var(--ifm-color-primary-contrast-foreground);
background: var(--ifm-color-primary);
}
.assistantBubble {
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-200);
}
.bubbleContent {
margin-bottom: 0;
}
.sources {
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.sourcesLabel {
margin-bottom: 4px;
color: var(--ifm-color-emphasis-600);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.sourceLink {
display: block;
overflow: hidden;
color: var(--ifm-color-primary);
font-size: 0.8rem;
white-space: nowrap;
text-decoration: none;
text-overflow: ellipsis;
}
.sourceLink:hover {
text-decoration: underline;
}
.loadingBubble {
display: flex;
gap: 4px;
align-items: center;
padding: 12px 16px;
}
.loadingDot {
display: inline-block;
width: 7px;
height: 7px;
background: var(--ifm-color-emphasis-400);
border-radius: 50%;
animation: bounce 1.2s infinite;
}
.loadingDot:nth-child(2) {
animation-delay: 0.2s;
}
.loadingDot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes bounce {
0%,
60%,
100% {
transform: translateY(0);
}
30% {
transform: translateY(-5px);
}
}
.errorRow {
display: flex;
justify-content: center;
}
.errorText {
padding: 8px 14px;
color: var(--ifm-color-danger);
font-size: 0.85rem;
background: var(--ifm-color-danger-contrast-background);
border: 1px solid var(--ifm-color-danger);
border-radius: var(--ifm-global-radius);
}
.inputRow {
display: flex;
gap: 0.5rem;
align-items: flex-end;
padding: 0.75rem 0;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.input {
flex: 1;
padding: 8px 12px;
color: var(--ifm-font-color-base);
font-size: var(--ifm-font-size-base);
font-family: inherit;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
outline: none;
transition: border-color 0.2s ease;
resize: none;
}
.input:focus {
border-color: var(--ifm-color-primary);
}
.input:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.sendBtn {
padding: 8px 18px;
color: var(--ifm-color-primary-contrast-foreground);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.875rem;
white-space: nowrap;
background: var(--ifm-color-primary);
border: none;
border-radius: var(--ifm-global-radius);
cursor: pointer;
transition: opacity 0.15s ease;
}
.sendBtn:hover:not(:disabled) {
opacity: 0.85;
}
.sendBtn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
@@ -1,215 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useHistory } from '@docusaurus/router';
import searchConfig from '@docuservix-search/config';
import type { SearchResult } from '../../types';
import styles from './styles.module.css';
const MAX_DROPDOWN_RESULTS = 10;
const DEBOUNCE_MS = 300;
export default function SearchBar(): JSX.Element {
const [query, setQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [notices, setNotices] = useState<string[]>([]);
const [hasErrors, setHasErrors] = useState(false);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const history = useHistory();
const runSearch = useCallback(async (q: string) => {
if (q.trim().length < 2) {
setResults([]);
setNotices([]);
setHasErrors(false);
setOpen(false);
return;
}
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
const timeout = searchConfig.timeout ?? 5000;
const timeoutId = setTimeout(() => controller.abort(), timeout);
setLoading(true);
setHasErrors(false);
try {
const settled = await Promise.allSettled(
searchConfig.providers.map((provider) => {
const signal = controller.signal;
return provider.search(q, signal);
}),
);
if (controller.signal.aborted) return;
const allResults: SearchResult[] = [];
const allNotices: string[] = [];
let anyError = false;
for (const outcome of settled) {
if (outcome.status === 'fulfilled') {
allResults.push(...outcome.value.results);
if (outcome.value.notice) {
allNotices.push(outcome.value.notice);
}
} else {
anyError = true;
}
}
allResults.sort((a, b) => b.relevance - a.relevance);
const top = allResults.slice(0, MAX_DROPDOWN_RESULTS);
setResults(top);
setNotices(allNotices);
setHasErrors(anyError);
setOpen(true);
setSelectedIndex(-1);
} finally {
clearTimeout(timeoutId);
setLoading(false);
}
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setQuery(value);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => runSearch(value), DEBOUNCE_MS);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open) {
if (e.key === 'Enter' && query.trim()) {
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => Math.max(prev - 1, -1));
} else if (e.key === 'Enter') {
e.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
window.location.href = results[selectedIndex].url;
} else if (query.trim()) {
history.push(`/search?q=${encodeURIComponent(query.trim())}`);
}
setOpen(false);
} else if (e.key === 'Escape') {
setOpen(false);
setSelectedIndex(-1);
}
};
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false);
setSelectedIndex(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
if (abortRef.current) abortRef.current.abort();
};
}, []);
return (
<div
ref={containerRef}
className={styles.container}
>
<input
className={styles.input}
type="search"
placeholder="Поиск..."
value={query}
onChange={handleChange}
onKeyDown={handleKeyDown}
aria-label="Поиск по документации"
aria-expanded={open}
aria-autocomplete="list"
role="combobox"
/>
{loading && (
<span
className={styles.spinner}
aria-label="Загрузка..."
/>
)}
{open && (
<div
className={styles.dropdown}
role="listbox"
onMouseDown={(e) => e.preventDefault()}
>
{hasErrors && (
<div className={styles.errorBadge}>Некоторые источники недоступны</div>
)}
{results.length === 0 && !loading && (
<div className={styles.empty}>Ничего не найдено</div>
)}
{results.map((result, i) => (
<a
key={`${result.url}-${i}`}
href={result.url}
role="option"
aria-selected={i === selectedIndex}
className={
i === selectedIndex
? `${styles.item} ${styles.selectedItem}`
: styles.item
}
onClick={() => setOpen(false)}
>
<div className={styles.itemHeading}>{result.title}</div>
<div className={styles.itemFile}>
<span
className={styles.badge}
data-type={result.type}
>
{result.type}
</span>
{result.path}
<span className={styles.itemScore}>
{Math.round(result.relevance * 100)}%
</span>
</div>
{result.content && (
<div className={styles.itemContent}>{result.content}</div>
)}
</a>
))}
{notices.map((notice, i) => (
<div
key={i}
className={styles.notice}
>
{notice}
</div>
))}
</div>
)}
</div>
);
}
@@ -1,150 +0,0 @@
.container {
position: relative;
display: flex;
align-items: center;
}
.input {
width: 200px;
height: 32px;
padding: 0 32px 0 12px;
color: var(--ifm-font-color-base);
font-size: var(--ifm-font-size-base);
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
outline: none;
transition:
width 0.2s ease,
border-color 0.2s ease;
}
.input:focus {
width: 280px;
border-color: var(--ifm-color-primary);
}
.spinner {
position: absolute;
right: 8px;
width: 14px;
height: 14px;
border: 2px solid var(--ifm-color-emphasis-300);
border-top-color: var(--ifm-color-primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
pointer-events: none;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 100;
min-width: 320px;
max-width: 480px;
max-height: 400px;
overflow-y: auto;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
box-shadow: var(--ifm-global-shadow-md);
}
.item {
display: block;
padding: 10px 14px;
color: var(--ifm-font-color-base);
text-decoration: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.item:last-child {
border-bottom: none;
}
.item:hover {
color: var(--ifm-color-primary);
text-decoration: none;
background: var(--ifm-color-emphasis-100);
}
.selectedItem {
color: var(--ifm-color-primary);
background: var(--ifm-color-emphasis-100);
outline: 2px solid var(--ifm-color-primary-light);
outline-offset: -2px;
}
.itemHeading {
margin-bottom: 2px;
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.9rem;
}
.badge {
display: inline-block;
padding: 1px 6px;
color: var(--ifm-color-emphasis-700);
font-weight: normal;
font-size: 0.65rem;
letter-spacing: 0.04em;
white-space: nowrap;
text-transform: uppercase;
background: var(--ifm-color-emphasis-200);
border-radius: 999px;
}
.itemFile {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 4px;
color: var(--ifm-color-emphasis-600);
font-size: 0.75rem;
}
.itemScore {
margin-left: auto;
color: var(--ifm-color-emphasis-500);
font-size: 0.7rem;
white-space: nowrap;
}
.itemContent {
display: -webkit-box;
overflow: hidden;
color: var(--ifm-color-emphasis-700);
font-size: 0.8rem;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.empty {
padding: 12px 14px;
color: var(--ifm-color-emphasis-600);
font-size: 0.875rem;
text-align: center;
}
.notice {
padding: 8px 14px;
color: var(--ifm-color-info-dark);
font-size: 0.8rem;
background: var(--ifm-color-info-contrast-background);
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.errorBadge {
padding: 8px 14px;
color: var(--ifm-color-warning-dark);
font-size: 0.8rem;
background: var(--ifm-color-warning-contrast-background);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
@@ -1,243 +0,0 @@
import { useHistory, useLocation } from '@docusaurus/router';
import searchConfig from '@docuservix-search/config';
import Link from '@docusaurus/Link';
import Layout from '@theme/Layout';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { SearchResult } from '../../types';
import styles from './styles.module.css';
const MAX_RESULTS = 25;
function useQuery(): string {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get('q') ?? '';
}
export default function SearchPage(): JSX.Element {
const urlQuery = useQuery();
const history = useHistory();
const [inputValue, setInputValue] = useState(urlQuery);
const [results, setResults] = useState<SearchResult[]>([]);
const [notices, setNotices] = useState<string[]>([]);
const [errors, setErrors] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [activeFilter, setActiveFilter] = useState<string>('all');
const abortRef = useRef<AbortController | null>(null);
const runSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setResults([]);
setNotices([]);
setErrors([]);
return;
}
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
const timeout = searchConfig.timeout ?? 5000;
const timeoutId = setTimeout(() => controller.abort(), timeout);
setLoading(true);
setErrors([]);
try {
const settled = await Promise.allSettled(
searchConfig.providers.map((provider) => provider.search(q, controller.signal)),
);
if (controller.signal.aborted) {
return;
}
const allResults: SearchResult[] = [];
const allNotices: string[] = [];
const allErrors: string[] = [];
for (let i = 0; i < settled.length; i++) {
const outcome = settled[i];
const provider = searchConfig.providers[i];
if (outcome.status === 'fulfilled') {
allResults.push(...outcome.value.results);
if (outcome.value.notice) {
allNotices.push(outcome.value.notice);
}
} else {
allErrors.push(`${provider.name}: недоступен`);
}
}
allResults.sort((a, b) => b.relevance - a.relevance);
setResults(allResults.slice(0, MAX_RESULTS));
setNotices(allNotices);
setErrors(allErrors);
setActiveFilter('all');
} finally {
clearTimeout(timeoutId);
setLoading(false);
}
}, []);
useEffect(() => {
setInputValue(urlQuery);
runSearch(urlQuery);
}, [urlQuery, runSearch]);
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && inputValue.trim()) {
history.push(`/search?q=${encodeURIComponent(inputValue.trim())}`);
}
};
const sourceTypes = ['all', ...Array.from(new Set(results.map((r) => r.type)))];
const filteredResults =
activeFilter === 'all' ? results : results.filter((r) => r.type === activeFilter);
const typeLabel: Record<string, string> = {
all: 'Все',
docs: 'Docs',
};
return (
<Layout title={urlQuery ? `Поиск: ${urlQuery}` : 'Поиск'}>
<div className={styles.container}>
<h1 className={styles.heading}>Поиск</h1>
<input
className={styles.searchInput}
type="search"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder="Введите запрос и нажмите Enter..."
aria-label="Поисковый запрос"
/>
{urlQuery && (
<Link
to={`/chat?q=${encodeURIComponent(urlQuery)}`}
className={styles.chatLink}
>
Спросить ИИ
</Link>
)}
{errors.length > 0 && (
<div
className="alert alert--warning"
role="alert"
>
{errors.map((err, i) => (
<div key={i}>{err}</div>
))}
</div>
)}
{notices.length > 0 && (
<div
className="alert alert--info"
role="note"
>
{notices.map((notice, i) => (
<div key={i}>{notice}</div>
))}
</div>
)}
{loading && <div className={styles.loadingText}>Поиск...</div>}
{!loading && urlQuery && (
<>
{sourceTypes.length > 2 && (
<div
className={styles.filters}
role="tablist"
>
{sourceTypes.map((type) => (
<button
key={type}
role="tab"
aria-selected={activeFilter === type}
className={
activeFilter === type
? `${styles.filterBtn} ${styles.filterBtnActive}`
: styles.filterBtn
}
onClick={() => setActiveFilter(type)}
>
{typeLabel[type] ?? type}
</button>
))}
</div>
)}
{filteredResults.length === 0 ? (
<div className={styles.empty}>Ничего не найдено</div>
) : (
<div className={styles.resultList}>
{filteredResults.map((result, i) => (
<a
key={`${result.url}-${i}`}
href={result.url}
className={styles.resultItem}
>
<div className={styles.resultTitle}>{result.title}</div>
<div className={styles.resultMeta}>
<span
className={styles.badge}
data-type={result.type}
>
{typeLabel[result.type] ?? result.type}
</span>
<span className={styles.resultPath}>{result.path}</span>
{result.anchor && (
<span className={styles.resultAnchor}>
#{result.anchor}
</span>
)}
<span className={styles.resultScore}>
{Math.round(result.relevance * 100)}%
</span>
</div>
{result.content && (
<div className={styles.resultContent}>
{result.content}
</div>
)}
</a>
))}
</div>
)}
</>
)}
</div>
</Layout>
);
}
@@ -1,170 +0,0 @@
.container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
}
.heading {
margin-bottom: 1rem;
font-size: 1.5rem;
}
.searchInput {
width: 100%;
height: 40px;
margin-bottom: 1.5rem;
padding: 0 16px;
color: var(--ifm-font-color-base);
font-size: var(--ifm-font-size-base);
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
outline: none;
transition: border-color 0.2s ease;
}
.searchInput:focus {
border-color: var(--ifm-color-primary);
}
.loadingText {
padding: 1rem 0;
color: var(--ifm-color-emphasis-600);
}
.filters {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
}
.filterBtn {
padding: 4px 12px;
color: var(--ifm-font-color-base);
font-size: 0.875rem;
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 999px;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease;
}
.filterBtn:hover {
border-color: var(--ifm-color-primary);
}
.filterBtnActive {
color: var(--ifm-color-primary-contrast-foreground);
background: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
}
.empty {
padding: 2rem 0;
color: var(--ifm-color-emphasis-600);
font-size: 1rem;
text-align: center;
}
.resultList {
display: flex;
flex-direction: column;
gap: 0;
overflow: hidden;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
}
.resultItem {
display: block;
padding: 12px 16px;
color: var(--ifm-font-color-base);
text-decoration: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
transition: background 0.1s ease;
}
.resultItem:last-child {
border-bottom: none;
}
.resultItem:hover {
color: var(--ifm-font-color-base);
text-decoration: none;
background: var(--ifm-color-emphasis-100);
}
.resultTitle {
margin-bottom: 4px;
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.95rem;
}
.resultMeta {
display: flex;
gap: 6px;
align-items: center;
margin-bottom: 4px;
}
.resultPath {
color: var(--ifm-color-emphasis-600);
font-size: 0.75rem;
}
.resultAnchor {
color: var(--ifm-color-emphasis-500);
font-size: 0.75rem;
}
.resultScore {
margin-left: auto;
color: var(--ifm-color-emphasis-500);
font-size: 0.75rem;
white-space: nowrap;
}
.resultContent {
display: -webkit-box;
overflow: hidden;
color: var(--ifm-color-emphasis-700);
font-size: 0.85rem;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
.chatLink {
display: inline-block;
margin-bottom: 1rem;
padding: 6px 14px;
color: var(--ifm-color-primary);
font-size: 0.875rem;
text-decoration: none;
border: 1px solid var(--ifm-color-primary);
border-radius: var(--ifm-global-radius);
transition:
background 0.15s ease,
color 0.15s ease;
}
.chatLink:hover {
color: var(--ifm-color-primary-contrast-foreground);
text-decoration: none;
background: var(--ifm-color-primary);
}
.badge {
display: inline-block;
padding: 1px 8px;
color: var(--ifm-color-emphasis-700);
font-weight: normal;
font-size: 0.7rem;
letter-spacing: 0.04em;
white-space: nowrap;
text-transform: uppercase;
background: var(--ifm-color-emphasis-200);
border-radius: 999px;
}
-26
View File
@@ -1,26 +0,0 @@
export interface SearchResult {
title: string;
content: string;
path: string;
anchor?: string;
type: string;
relevance: number; // 0–1
url: string;
}
export interface SearchProviderResponse {
results: SearchResult[];
notice?: string;
}
export interface SearchProvider {
id: string;
name: string;
timeout?: number;
search: (query: string, signal: AbortSignal) => Promise<SearchProviderResponse>;
}
export interface SearchConfig {
timeout: number;
providers: SearchProvider[];
}
@@ -0,0 +1,59 @@
.MD p:last-child {
margin-bottom: 0;
}
.MD p:first-child {
margin-top: 0;
}
.MD code {
padding: 0.15em 0.4em;
border-radius: 4px;
background: var(--ifm-color-emphasis-200);
font-size: 0.85em;
}
.MD pre {
margin: 0.5em 0;
padding: 0.75em;
overflow-x: auto;
border-radius: 6px;
background: var(--ifm-color-emphasis-100);
}
.MD pre code {
padding: 0;
background: none;
}
.MD ul,
.MD ol {
padding-left: 1.5em;
margin: 0.5em 0;
}
.MD table {
width: 100%;
margin: 0.5em 0;
border-collapse: collapse;
font-size: 0.9em;
}
.MD th,
.MD td {
padding: 0.4em 0.75em;
border: 1px solid var(--ifm-color-emphasis-300);
text-align: left;
}
.MD th {
background: var(--ifm-color-emphasis-100);
font-weight: var(--ifm-font-weight-semibold);
}
.MD blockquote {
margin: 0.5em 0;
padding: 0.25em 1em;
border-left: 3px solid var(--ifm-color-emphasis-300);
color: var(--ifm-color-emphasis-700);
}
@@ -0,0 +1,17 @@
import React, { ReactNode } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import styles from './MD.module.css';
interface MDProps {
children: string;
}
export function MD({ children }: MDProps): ReactNode {
return (
<div className={styles.MD}>
<Markdown remarkPlugins={[remarkGfm]}>{children}</Markdown>
</div>
);
}
@@ -0,0 +1 @@
export { MD } from './MD';
+88
View File
@@ -0,0 +1,88 @@
import { useLocation } from '@docusaurus/router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useOptions } from '@docuservix/hooks/useOptions';
import { IChat, IChatMessage, IChatSource } from '@docuservix/models/chat';
interface UseChatResult {
dialog: IChat;
typing: boolean;
statusMessage?: string;
sendMessage: (text: string) => void;
}
function useQuery(): string {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get('q') ?? '';
}
export function useChat(): UseChatResult {
const chatEndpoint = useOptions().api + '/chat';
const urlQuery = useQuery();
const [messages, setMessages] = useState<IChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const initialSentRef = useRef(false);
const messagesEndRef = useRef(messages);
messagesEndRef.current = messages;
const sendMessage = useCallback(
async (text: string) => {
const content = text.trim();
if (!content) {
return;
}
const userMessage: IChatMessage = { role: 'user', content };
const newHistory = [...messagesEndRef.current, userMessage];
setMessages(newHistory);
setLoading(true);
setError(null);
try {
const res = await fetch(chatEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: newHistory }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const data: { answer: string; sources?: IChatSource[] } = await res.json();
setMessages((prev) => [
...prev,
{ role: 'assistant', content: data.answer, sources: data.sources },
]);
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка при обращении к серверу');
} finally {
setLoading(false);
}
},
[chatEndpoint],
);
useEffect(() => {
if (urlQuery && !initialSentRef.current) {
initialSentRef.current = true;
sendMessage(urlQuery);
}
}, [urlQuery, sendMessage]);
return {
dialog: { messages },
typing: loading,
statusMessage: error ?? undefined,
sendMessage,
};
}
+7
View File
@@ -0,0 +1,7 @@
import { usePluginData } from '@docusaurus/useGlobalData';
import { DocuservixOptions } from '@docuservix/models/docuservix';
export function useOptions(): DocuservixOptions {
return usePluginData('docuservix') as DocuservixOptions;
}
+39
View File
@@ -0,0 +1,39 @@
import path from 'path';
import type { LoadContext, Plugin } from '@docusaurus/types';
import { DocuservixOptions } from '@docuservix/models/docuservix';
export default function docuservix(options: Partial<DocuservixOptions> = {}) {
const { api = '/api' } = options;
return function pluginDocuservix(_context: LoadContext): Plugin {
return {
name: 'docuservix',
configureWebpack() {
return {
resolve: {
alias: {
'@docuservix': path.resolve(__dirname),
},
},
};
},
async contentLoaded({ actions }) {
const { addRoute, setGlobalData } = actions;
setGlobalData({
api,
});
addRoute({
path: '/chat',
component: '@docuservix/pages/chat',
exact: true,
});
},
};
};
}
+37
View File
@@ -0,0 +1,37 @@
export interface IChat {
messages: IChatMessage[];
}
export interface IChatSource {
file: string;
heading: string;
anchor: string;
score: number;
}
export interface IChatMessage {
role: 'user' | 'assistant';
content: string;
sources?: IChatSource[];
}
function stripNumericPrefixes(p: string): string {
return p
.split('/')
.map((seg) => seg.replace(/^\d+-/, ''))
.join('/');
}
export function sourceToUrl(file: string, anchor: string): string {
let p = file.replace(/^docs\//, '').replace(/\.md$/, '');
p = stripNumericPrefixes(p);
return `/docs/${p}${anchor ? `#${anchor}` : ''}`;
}
export function sourceToPath(file: string): string {
const p = file.replace(/^docs\//, '').replace(/\.md$/, '');
return stripNumericPrefixes(p);
}
+3
View File
@@ -0,0 +1,3 @@
export interface DocuservixOptions {
api?: string;
}
@@ -0,0 +1,22 @@
import Layout from '@theme/Layout';
import { ReactNode } from 'react';
import { useChat } from '@docuservix/hooks/useChat';
import { Chat } from '@docuservix/widgets/chat';
export function ChatPage(): ReactNode {
const { dialog, typing, statusMessage, sendMessage } = useChat();
return (
<Layout title="Чат">
<main className="container margin-vert--lg">
<Chat
dialog={dialog}
typing={typing}
statusMessage={statusMessage}
onSend={sendMessage}
/>
</main>
</Layout>
);
}
+3
View File
@@ -0,0 +1,3 @@
import { ChatPage } from './ChatPage';
export default ChatPage;
@@ -0,0 +1,24 @@
.Chat {
display: flex;
flex-direction: column;
width: 100%;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: var(--ifm-global-radius);
overflow: hidden;
}
.Chat__statusMessage {
font-size: .65rem;
color: #666;
bottom: 0;
left: 0;
right: 0;
padding: .75rem 2.5rem;
background: #eee;
}
.Chat__statusMessage i {
margin-right: .25rem;
color: #667eea;
}
+37
View File
@@ -0,0 +1,37 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { IChat } from '@docuservix/models/chat';
import styles from './Chat.module.css';
import { Header } from './Header';
import { Input } from './Input';
import { Messages } from './Messages';
const b = block(styles, 'Chat');
interface ChatProps {
dialog: IChat;
typing?: boolean;
statusMessage?: string;
onSend?: (text: string) => void;
}
export function Chat({ dialog, typing, statusMessage, onSend }: ChatProps): ReactNode {
const { messages } = dialog;
return (
<div className={b()}>
<Header />
<Messages
messages={messages}
typing={typing}
/>
{statusMessage && <div className={b('statusMessage')}>{statusMessage}</div>}
<Input
disabled={typing}
onSend={onSend}
/>
</div>
);
}
@@ -0,0 +1,32 @@
.Header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.Header__avatar {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
flex-shrink: 0;
}
.Header__info h3 {
margin: 0 0 0.25rem;
color: var(--ifm-font-color-base);
font-size: 1.125rem;
}
.Header__info p {
margin: 0;
color: var(--ifm-color-emphasis-600);
font-size: 0.85rem;
}
@@ -0,0 +1,21 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import styles from './Header.module.css';
import { RobotIcon } from './icons';
const b = block(styles, 'Header');
export function Header(): ReactNode {
return (
<div className={b()}>
<div className={b('avatar')}>
<RobotIcon />
</div>
<div className={b('info')}>
<h3>AI Assistant</h3>
<p>Ready to help</p>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
.Input {
display: flex;
align-items: flex-end;
gap: 0.75rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.Input__field {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid var(--ifm-color-emphasis-200);
border-radius: 1.5rem;
font-size: 1rem;
font-family: inherit;
color: var(--ifm-font-color-base);
background: var(--ifm-background-surface-color);
outline: none;
transition: border-color 0.3s;
resize: none;
min-height: 3.25rem;
max-height: 8rem;
field-sizing: content;
}
.Input__field:focus {
border-color: #667eea;
}
.Input__field:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.Input__send {
display: flex;
justify-content: center;
align-items: center;
width: 3.25rem;
height: 3.25rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 50%;
color: white;
font-size: 1.125rem;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.3s;
}
.Input__send:hover:not(:disabled) {
transform: scale(1.1);
}
.Input__send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+55
View File
@@ -0,0 +1,55 @@
import block from 'bem-css-modules';
import React, { ReactNode, useState } from 'react';
import { PaperPlaneIcon } from './icons';
import styles from './Input.module.css';
const b = block(styles, 'Input');
interface InputProps {
disabled?: boolean;
onSend?: (text: string) => void;
}
export function Input({ disabled, onSend }: InputProps): ReactNode {
const [input, setInput] = useState('');
const handleSend = () => {
const text = input.trim();
if (!text || disabled) {
return;
}
setInput('');
onSend?.(text);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className={b()}>
<textarea
className={b('field')}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message here..."
rows={1}
disabled={disabled}
/>
<button
className={b('send')}
onClick={handleSend}
disabled={disabled || !input.trim()}
>
<PaperPlaneIcon />
</button>
</div>
);
}
@@ -0,0 +1,70 @@
.Message {
max-width: 90%;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.Message_role_assistant {
align-self: flex-start;
}
.Message_role_user {
align-self: flex-end;
}
.Message__content {
padding: 0.75rem 1rem;
border-radius: 1.25rem;
line-height: 1.5;
}
.Message_role_assistant .Message__content {
background: var(--ifm-color-emphasis-100);
color: var(--ifm-font-color-base);
border-top-left-radius: 0.25rem;
}
.Message_role_user .Message__content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
border-bottom-right-radius: 0.25rem;
}
.Message__sources {
margin-top: 8px;
padding: 8px 1rem 0;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
.Message__sourcesLabel {
margin-bottom: 4px;
color: var(--ifm-color-emphasis-600);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.75rem;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.Message__sourceLink {
display: block;
overflow: hidden;
color: var(--ifm-color-primary);
font-size: 0.8rem;
white-space: nowrap;
text-decoration: none;
text-overflow: ellipsis;
}
.Message__sourceLink:hover {
text-decoration: underline;
}
@media (max-width: 576px) {
.Message {
max-width: 100%;
}
}
@@ -0,0 +1,42 @@
import Link from '@docusaurus/Link';
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { MD } from '@docuservix/entities/markdown';
import { IChatSource, sourceToPath, sourceToUrl } from '@docuservix/models/chat';
import styles from './Message.module.css';
const b = block(styles, 'Message');
interface MessageProps {
role: 'user' | 'assistant';
content: string;
sources?: IChatSource[];
}
export function Message({ role, content, sources }: MessageProps): ReactNode {
return (
<div className={b({ role })}>
<div className={b('content')}>
<MD>{content}</MD>
</div>
{sources && sources.length > 0 && (
<div className={b('sources')}>
<div className={b('sourcesLabel')}>Источники:</div>
{sources.map((src, j) => (
<Link
key={j}
to={sourceToUrl(src.file, src.anchor)}
className={b('sourceLink')}
>
{src.heading || sourceToPath(src.file)}
</Link>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
.Messages {
flex: 1;
overflow-y: auto;
padding: 1rem 1.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Typing indicator */
.Messages__typing {
display: flex;
align-items: center;
align-self: flex-start;
}
.Messages__typingIndicator {
display: flex;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: var(--ifm-color-emphasis-100);
border-radius: 1.25rem;
border-top-left-radius: 0.25rem;
}
.Messages__typingIndicator span {
width: 0.5rem;
height: 0.5rem;
background: var(--ifm-color-emphasis-500);
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
}
.Messages__typingIndicator span:nth-child(1) { animation-delay: -0.32s; }
.Messages__typingIndicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
/* Scrollbar */
.Messages::-webkit-scrollbar {
width: 6px;
}
.Messages::-webkit-scrollbar-track {
background: transparent;
border-radius: 3px;
}
.Messages::-webkit-scrollbar-thumb {
background: var(--ifm-color-emphasis-300);
border-radius: 3px;
}
.Messages::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400);
}
@media (min-width: 576px) {
.Messages {
padding: 1.5rem;
}
}
@@ -0,0 +1,39 @@
import block from 'bem-css-modules';
import React, { ReactNode } from 'react';
import { IChatMessage } from '@docuservix/models/chat';
import { Message } from './Message';
import styles from './Messages.module.css';
const b = block(styles, 'Messages');
interface MessagesProps {
messages: IChatMessage[];
typing?: boolean;
}
export function Messages({ messages, typing }: MessagesProps): ReactNode {
return (
<div className={b()}>
{messages.map((msg, i) => (
<Message
key={i}
role={msg.role}
content={msg.content}
sources={msg.sources}
/>
))}
{typing && (
<div className={b('typing')}>
<div className={b('typingIndicator')}>
<span></span>
<span></span>
<span></span>
</div>
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import React, { ReactNode } from 'react';
export function RobotIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M6 12.5a.5.5 0 0 1 .5-.5h3a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.5M3 8.062C3 6.76 4.235 5.765 5.53 5.886a26.6 26.6 0 0 0 4.94 0C11.765 5.765 13 6.76 13 8.062v1.157a.93.93 0 0 1-.765.935c-.845.147-2.34.346-4.235.346s-3.39-.2-4.235-.346A.93.93 0 0 1 3 9.219zm4.542-.827a.25.25 0 0 0-.217.068l-.92.9a25 25 0 0 1-1.871-.183.25.25 0 0 0-.068.495c.55.076 1.232.149 2.02.193a.25.25 0 0 0 .189-.071l.754-.736.847 1.71a.25.25 0 0 0 .404.062l.932-.97a25 25 0 0 0 1.922-.188.25.25 0 0 0-.068-.495c-.538.074-1.207.145-1.98.189a.25.25 0 0 0-.166.076l-.754.785-.842-1.7a.25.25 0 0 0-.182-.135" />
<path d="M8.5 1.866a1 1 0 1 0-1 0V3h-2A4.5 4.5 0 0 0 1 7.5V8a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1v1a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-1a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1v-.5A4.5 4.5 0 0 0 10.5 3h-2zM14 7.5V13a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V7.5A3.5 3.5 0 0 1 5.5 4h5A3.5 3.5 0 0 1 14 7.5" />
</svg>
);
}
export function PaperPlaneIcon(): ReactNode {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="currentColor"
viewBox="0 0 16 16"
>
<path d="M15.964.686a.5.5 0 0 0-.65-.65L.767 5.855H.766l-.452.18a.5.5 0 0 0-.082.887l.41.26.001.002 4.995 3.178 3.178 4.995.002.002.26.41a.5.5 0 0 0 .886-.083zm-1.833 1.89L6.637 10.07l-.215-.338a.5.5 0 0 0-.154-.154l-.338-.215 7.494-7.494 1.178-.471z" />
</svg>
);
}
+1
View File
@@ -0,0 +1 @@
export { Chat } from './Chat';
+26 -14
View File
@@ -1,3 +1,5 @@
/* eslint-disable no-console -- logs required */
import fs from 'fs';
import path from 'path';
@@ -9,23 +11,33 @@ pinIndexToTop();
* Гарантирует наличие sidebar_position: 0 в front matter файла index.md
*/
function pinIndexToTop() {
const indexPath = path.join(docsDir, 'index.md');
if (!fs.existsSync(indexPath)) return;
const indexPath = path.join(docsDir, 'index.md');
let content = fs.readFileSync(indexPath, 'utf8');
if (!fs.existsSync(indexPath)) {
return;
}
if (content.startsWith('---\n')) {
const endIdx = content.indexOf('\n---\n', 4);
if (endIdx === -1) return;
let content = fs.readFileSync(indexPath, 'utf8');
const frontMatter = content.slice(4, endIdx);
if (/^sidebar_position\s*:/m.test(frontMatter)) return;
if (content.startsWith('---\n')) {
const endIdx = content.indexOf('\n---\n', 4);
content = '---\nsidebar_position: 0\n' + frontMatter + '\n---\n' + content.slice(endIdx + 5);
} else {
content = '---\nsidebar_position: 0\n---\n' + content;
}
if (endIdx === -1) {
return;
}
fs.writeFileSync(indexPath, content);
console.log('prepare-docs: pinned index.md to sidebar top');
const frontMatter = content.slice(4, endIdx);
if (/^sidebar_position\s*:/m.test(frontMatter)) {
return;
}
content =
'---\nsidebar_position: 0\n' + frontMatter + '\n---\n' + content.slice(endIdx + 5);
} else {
content = '---\nsidebar_position: 0\n---\n' + content;
}
fs.writeFileSync(indexPath, content);
console.log('prepare-docs: pinned index.md to sidebar top');
}
+36 -29
View File
@@ -1,40 +1,47 @@
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import Layout from '@theme/Layout';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import styles from './index.module.css';
function HomepageHeader() {
const {siteConfig} = useDocusaurusContext();
return (
<header className={clsx('hero hero--primary', styles.heroBanner)}>
<div className="container">
<Heading as="h1" className="hero__title">
{siteConfig.title}
</Heading>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs">
Документация
</Link>
</div>
</div>
</header>
);
const { siteConfig } = useDocusaurusContext();
return (
<header className={clsx('hero hero--primary', styles.heroBanner)}>
<div className="container">
<Heading
as="h1"
className="hero__title"
>
{siteConfig.title}
</Heading>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link
className="button button--secondary button--lg"
to="/docs"
>
Документация
</Link>
</div>
</div>
</header>
);
}
export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />">
<HomepageHeader />
</Layout>
);
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`Hello from ${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
</Layout>
);
}

Some files were not shown because too many files have changed in this diff Show More