commit
3dec3ea720
75 changed files with 13806 additions and 0 deletions
@ -0,0 +1,9 @@ |
|||||||
|
**/node_modules |
||||||
|
**/.git |
||||||
|
**/.env |
||||||
|
**/.env.* |
||||||
|
!**/.env.example |
||||||
|
**/dist |
||||||
|
**/*.db |
||||||
|
**/*.db-journal |
||||||
|
.DS_Store |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
# Скопируйте в `.env` в корне репозитория для `docker compose up` |
||||||
|
APP_PORT=3000 |
||||||
|
POSTGRES_USER=edu |
||||||
|
POSTGRES_PASSWORD=edu |
||||||
|
POSTGRES_DB=edu_helper |
||||||
|
|
||||||
|
JWT_SECRET=замените-на-длинную-случайную-строку-минимум-32-символа |
||||||
|
|
||||||
|
# Пароли первичных пользователей (только при пустой таблице User) |
||||||
|
SEED_TUTOR_USERNAME=alexey |
||||||
|
SEED_STUDENT_USERNAME=konstantin |
||||||
|
SEED_TUTOR_PASSWORD= |
||||||
|
SEED_STUDENT_PASSWORD= |
||||||
|
|
||||||
|
# Опционально: положить ключ сюда, чтобы не заходить в настройки после деплоя |
||||||
|
DEEPSEEK_API_KEY= |
||||||
|
|
||||||
|
# Cookie с флагом Secure только при HTTPS (иначе авторизация по http:// не работает) |
||||||
|
# COOKIE_SECURE=true |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
node_modules/ |
||||||
|
dist/ |
||||||
|
.env |
||||||
|
*.db |
||||||
|
generated/ |
||||||
|
.DS_Store |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
FROM node:22-alpine AS frontend-build |
||||||
|
WORKDIR /app/frontend |
||||||
|
COPY frontend/package.json frontend/package-lock.json ./ |
||||||
|
RUN npm ci |
||||||
|
COPY frontend/ ./ |
||||||
|
RUN npm run build |
||||||
|
|
||||||
|
FROM node:22-alpine AS backend-build |
||||||
|
WORKDIR /app/backend |
||||||
|
COPY backend/package.json backend/package-lock.json ./ |
||||||
|
RUN npm ci |
||||||
|
COPY backend/ ./ |
||||||
|
RUN npx prisma generate |
||||||
|
RUN npm run build |
||||||
|
|
||||||
|
FROM node:22-alpine |
||||||
|
RUN apk add --no-cache openssl libc6-compat |
||||||
|
WORKDIR /app |
||||||
|
ENV NODE_ENV=production |
||||||
|
COPY --from=backend-build /app/backend/dist ./dist |
||||||
|
COPY --from=backend-build /app/backend/node_modules ./node_modules |
||||||
|
COPY --from=backend-build /app/backend/prisma ./prisma |
||||||
|
COPY --from=backend-build /app/backend/package.json ./package.json |
||||||
|
COPY --from=frontend-build /app/frontend/dist ./public |
||||||
|
COPY docker-entrypoint.sh /docker-entrypoint.sh |
||||||
|
RUN chmod +x /docker-entrypoint.sh |
||||||
|
EXPOSE 3000 |
||||||
|
ENTRYPOINT ["/docker-entrypoint.sh"] |
||||||
|
CMD ["node", "dist/index.js"] |
||||||
@ -0,0 +1,118 @@ |
|||||||
|
# Edu Helper |
||||||
|
|
||||||
|
Веб-приложение для обучения с ИИ: чат, вопросы, учебники, тесты, «Зал» с фотографиями по датам. Два типа пользователей — **наставник** и **ученик** (роли и связка наставник ↔ ученик задаются в БД). |
||||||
|
|
||||||
|
## Стек |
||||||
|
|
||||||
|
- **Frontend:** React 19, Vite, TypeScript, Tailwind CSS |
||||||
|
- **Backend:** Node.js, Express, Prisma ORM |
||||||
|
- **БД:** PostgreSQL 16 |
||||||
|
- **ИИ:** DeepSeek API (ключ в настройках или переменной окружения) |
||||||
|
- **Деплой:** Docker Compose (один контейнер с API + статикой фронта, отдельно Postgres) |
||||||
|
|
||||||
|
## Требования |
||||||
|
|
||||||
|
- [Docker](https://docs.docker.com/get-docker/) и Docker Compose (для продакшен-сборки) |
||||||
|
- или **Node.js 22+** и локальный PostgreSQL (для разработки без Docker) |
||||||
|
|
||||||
|
## Быстрый старт (Docker) |
||||||
|
|
||||||
|
1. Склонируйте репозиторий и перейдите в каталог проекта. |
||||||
|
|
||||||
|
2. Создайте файл `.env` в **корне** репозитория (можно скопировать шаблон): |
||||||
|
|
||||||
|
```bash |
||||||
|
cp .env.docker.example .env |
||||||
|
``` |
||||||
|
|
||||||
|
3. Отредактируйте `.env`: |
||||||
|
- задайте **`JWT_SECRET`** — длинная случайная строка (не короткий пароль); |
||||||
|
- задайте **`SEED_TUTOR_PASSWORD`** и **`SEED_STUDENT_PASSWORD`** — пароли первых пользователей (создаются только при пустой таблице `User`); |
||||||
|
- при необходимости укажите **`DEEPSEEK_API_KEY`**; |
||||||
|
- для работы по **HTTPS** за прокси выставьте **`COOKIE_SECURE=true`**; для обычного **http://localhost** оставьте **`COOKIE_SECURE=false`**, иначе cookie с сессией не установится. |
||||||
|
|
||||||
|
4. Запуск: |
||||||
|
|
||||||
|
```bash |
||||||
|
docker compose up -d --build |
||||||
|
``` |
||||||
|
|
||||||
|
5. Откройте в браузере: **http://localhost:3000** (или порт из `APP_PORT` в `.env`). |
||||||
|
|
||||||
|
6. Проверка API: |
||||||
|
|
||||||
|
```bash |
||||||
|
curl http://localhost:3000/api/health |
||||||
|
``` |
||||||
|
|
||||||
|
При первом старте контейнер приложения выполняет `prisma migrate deploy` и сид пользователей (если пользователей ещё нет). |
||||||
|
|
||||||
|
### Тома данных |
||||||
|
|
||||||
|
- **`pgdata`** — данные PostgreSQL. Пароль пользователя БД задаётся в **`POSTGRES_PASSWORD`** только при **первом** создании тома. Если сменить пароль в `.env` позже, сам PostgreSQL **не** пересоздаст пароль автоматически — приложение не подключится (ошибка Prisma `P1000`). Варианты: вернуть в `.env` старый пароль, либо сменить пароль вручную в Postgres, либо **один раз** пересоздать том (данные БД пропадут): |
||||||
|
|
||||||
|
```bash |
||||||
|
docker compose down -v |
||||||
|
docker compose up -d --build |
||||||
|
``` |
||||||
|
|
||||||
|
- **`uploads`** — загруженные файлы «Зала». |
||||||
|
|
||||||
|
## Локальная разработка (без Docker) |
||||||
|
|
||||||
|
1. Поднимите PostgreSQL и создайте базу (например `edu_helper`). |
||||||
|
|
||||||
|
2. В каталоге **`backend`** создайте `.env` с `DATABASE_URL` и остальными переменными по аналогии с корневым `.env.docker.example`. |
||||||
|
|
||||||
|
3. Установка и миграции: |
||||||
|
|
||||||
|
```bash |
||||||
|
cd backend && npm ci && npx prisma migrate deploy |
||||||
|
``` |
||||||
|
|
||||||
|
4. Запуск бэкенда (по умолчанию порт **3001**): |
||||||
|
|
||||||
|
```bash |
||||||
|
npm run dev |
||||||
|
``` |
||||||
|
|
||||||
|
5. В другом терминале — фронт: |
||||||
|
|
||||||
|
```bash |
||||||
|
cd frontend && npm ci && npm run dev |
||||||
|
``` |
||||||
|
|
||||||
|
Vite откроется на **http://localhost:5173**; запросы к `/api` проксируются на бэкенд (см. `frontend/vite.config.ts`). |
||||||
|
|
||||||
|
Корневой скрипт **`npm run dev`** (из корня репозитория, после `npm install` в корне) поднимает backend и frontend одновременно через `concurrently`. |
||||||
|
|
||||||
|
## Структура репозитория |
||||||
|
|
||||||
|
| Путь | Назначение | |
||||||
|
|------|------------| |
||||||
|
| `frontend/` | SPA (React + Vite) | |
||||||
|
| `backend/` | API, Prisma-схема и миграции | |
||||||
|
| `Dockerfile` | Сборка фронта и бэка, один образ Node | |
||||||
|
| `docker-compose.yml` | Сервисы `app` и `db`, тома `pgdata` и `uploads` | |
||||||
|
| `docker-entrypoint.sh` | Миграции Prisma и сид перед стартом Node | |
||||||
|
| `.env.docker.example` | Шаблон переменных для Docker | |
||||||
|
|
||||||
|
Файл **`.env`** в git не коммитится (см. `.gitignore`). |
||||||
|
|
||||||
|
## Переменные окружения (кратко) |
||||||
|
|
||||||
|
| Переменная | Описание | |
||||||
|
|------------|----------| |
||||||
|
| `POSTGRES_*` | Пользователь, пароль и имя БД для контейнера Postgres | |
||||||
|
| `DATABASE_URL` | Строка подключения Prisma (в Docker задаётся из `POSTGRES_*`) | |
||||||
|
| `JWT_SECRET` | Секрет подписи JWT (обязательно задать) | |
||||||
|
| `SEED_*_USERNAME` / `SEED_*_PASSWORD` | Логины и пароли для первичного сида | |
||||||
|
| `DEEPSEEK_API_KEY` | Опционально: ключ API по умолчанию | |
||||||
|
| `COOKIE_SECURE` | `true` только при HTTPS | |
||||||
|
| `APP_PORT` | Проброс порта хоста на контейнер приложения (по умолчанию 3000) | |
||||||
|
|
||||||
|
Полный список и комментарии — в **`.env.docker.example`**. |
||||||
|
|
||||||
|
## Лицензия |
||||||
|
|
||||||
|
Укажите лицензию при необходимости (файл `LICENSE`). |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
# Спринт: сервер, Docker, Postgres, роли |
||||||
|
|
||||||
|
## Обязательные требования |
||||||
|
|
||||||
|
- **Docker**: образ приложения + `docker-compose` с PostgreSQL; один контейнер отдаёт API и статику фронтенда. |
||||||
|
- **PostgreSQL** вместо SQLite; Prisma-миграции при старте контейнера (`migrate deploy`). |
||||||
|
- **Два аккаунта** (логины/пароли из переменных окружения при первом запуске): наставник **Алексей**, ученик **Константин**; связь «наставник ведёт ученика» в БД. |
||||||
|
- **Алексей** видит и создаёт контент для Константина (чат, вопросы, учебники, тесты, отчёты). |
||||||
|
- **Константин** удаляет **только свои** вопросы (`DELETE` только для роли STUDENT и только при совпадении `studentId`); наставник удалять вопросы не может. |
||||||
|
- **Настройки** (ключ API, промпты): только наставник (`GET /raw`, `PUT`). |
||||||
|
|
||||||
|
## Статус реализации |
||||||
|
|
||||||
|
См. коммит(ы) в репозитории после этой спецификации: `Dockerfile`, `docker-compose.yml`, `SPRINT.md`, обновлённые `schema.prisma`, auth, защита роутов, UI логина и удаления вопросов. |
||||||
|
|
||||||
|
## Запуск в Docker (обязательная упаковка) |
||||||
|
|
||||||
|
```bash |
||||||
|
cp .env.docker.example .env |
||||||
|
# Заполните JWT_SECRET, SEED_TUTOR_PASSWORD, SEED_STUDENT_PASSWORD; при желании DEEPSEEK_API_KEY |
||||||
|
docker compose up --build |
||||||
|
``` |
||||||
|
|
||||||
|
Приложение: http://localhost:3000 (логины по умолчанию `alexey` / `konstantin`, см. `SEED_*_USERNAME`). |
||||||
|
|
||||||
|
Файл-пример переменных: [.env.docker.example](.env.docker.example). |
||||||
|
|
||||||
|
## Локальная разработка |
||||||
|
|
||||||
|
- Поднять Postgres (или `docker compose up db -d`). |
||||||
|
- `DATABASE_URL=postgresql://...` в `backend/.env`. |
||||||
|
- `npm run dev` из корня (как раньше: backend :3001, frontend :5173 с прокси). |
||||||
@ -0,0 +1,730 @@ |
|||||||
|
# Учебник: Как устроен EduHelper |
||||||
|
|
||||||
|
Это пошаговый учебник для начинающего программиста. Здесь подробно объяснено, как работает каждая часть проекта EduHelper — веб-приложения для обучения с ИИ-ассистентом. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Оглавление |
||||||
|
|
||||||
|
1. [Что такое веб-приложение и как оно устроено](#1-что-такое-веб-приложение) |
||||||
|
2. [Архитектура: Frontend vs Backend](#2-архитектура-frontend-vs-backend) |
||||||
|
3. [Backend: Express.js — наш сервер](#3-backend-expressjs) |
||||||
|
4. [База данных: SQLite + Prisma](#4-база-данных-sqlite--prisma) |
||||||
|
5. [Frontend: React + Vite](#5-frontend-react--vite) |
||||||
|
6. [Компоненты UI: Shadcn подход](#6-компоненты-ui-shadcn) |
||||||
|
7. [Маршрутизация (роутинг)](#7-маршрутизация) |
||||||
|
8. [Как фронтенд общается с бэкендом](#8-как-фронтенд-общается-с-бэкендом) |
||||||
|
9. [Интеграция с ИИ (DeepSeek)](#9-интеграция-с-ии-deepseek) |
||||||
|
10. [Стриминг ответов (SSE)](#10-стриминг-ответов-sse) |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 1. Что такое веб-приложение |
||||||
|
|
||||||
|
Веб-приложение — это программа, которая работает в браузере. В отличие от обычного сайта, она **интерактивна**: вы можете нажимать кнопки, заполнять формы, получать ответы — всё без перезагрузки страницы. |
||||||
|
|
||||||
|
EduHelper — веб-приложение, которое позволяет: |
||||||
|
- Задавать вопросы ИИ-ассистенту |
||||||
|
- Генерировать учебники по любой теме |
||||||
|
- Проходить тесты |
||||||
|
- Получать ежедневные отчёты об обучении |
||||||
|
|
||||||
|
### Как это работает в общих чертах |
||||||
|
|
||||||
|
``` |
||||||
|
[Браузер (Chrome)] <--HTTP запросы--> [Сервер (Express)] <--SQL запросы--> [База данных (SQLite)] |
||||||
|
| |
||||||
|
v |
||||||
|
[DeepSeek API] (ИИ в облаке) |
||||||
|
``` |
||||||
|
|
||||||
|
1. Вы открываете сайт в браузере |
||||||
|
2. Браузер показывает интерфейс (это **frontend**) |
||||||
|
3. Когда нужны данные, браузер отправляет запрос на сервер (это **backend**) |
||||||
|
4. Сервер достаёт данные из базы данных или обращается к ИИ |
||||||
|
5. Сервер отправляет ответ обратно в браузер |
||||||
|
6. Браузер обновляет интерфейс |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 2. Архитектура: Frontend vs Backend |
||||||
|
|
||||||
|
В нашем проекте фронтенд и бэкенд **живут в разных папках** — это называется «разделённая архитектура»: |
||||||
|
|
||||||
|
``` |
||||||
|
Edu_helper/ |
||||||
|
├── backend/ ← Серверная часть (обработка данных, работа с БД и ИИ) |
||||||
|
├── frontend/ ← Клиентская часть (интерфейс, кнопки, формы) |
||||||
|
└── TEXTBOOK.md ← Этот учебник |
||||||
|
``` |
||||||
|
|
||||||
|
### Зачем разделять? |
||||||
|
|
||||||
|
| Преимущество | Пояснение | |
||||||
|
|---|---| |
||||||
|
| **Понятность** | Серверный и клиентский код не перемешаны. Легче найти нужный файл | |
||||||
|
| **Независимость** | Можно менять фронтенд, не трогая бэкенд, и наоборот | |
||||||
|
| **Масштабирование** | В будущем можно запустить несколько бэкендов или поменять фронтенд на мобильное приложение | |
||||||
|
| **Командная работа** | Один разработчик делает фронтенд, другой — бэкенд | |
||||||
|
|
||||||
|
### Как они общаются? |
||||||
|
|
||||||
|
Через **HTTP-запросы** (API). Фронтенд отправляет запрос на адрес вроде `http://localhost:3001/api/questions`, а бэкенд возвращает данные в формате JSON. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 3. Backend: Express.js |
||||||
|
|
||||||
|
### Что такое Express.js? |
||||||
|
|
||||||
|
Express — это **фреймворк** (набор готовых инструментов) для создания серверов на Node.js. Он позволяет легко описать: «когда приходит запрос на такой-то адрес, делай вот это». |
||||||
|
|
||||||
|
### Структура нашего бэкенда |
||||||
|
|
||||||
|
``` |
||||||
|
backend/ |
||||||
|
├── src/ |
||||||
|
│ ├── index.ts ← Точка входа. Здесь создаётся и настраивается сервер |
||||||
|
│ ├── routes/ ← Маршруты (какой запрос куда ведёт) |
||||||
|
│ │ ├── settings.ts ← /api/settings — настройки приложения |
||||||
|
│ │ ├── chat.ts ← /api/chat — чат с ИИ |
||||||
|
│ │ ├── questions.ts ← /api/questions — ежедневные вопросы |
||||||
|
│ │ ├── textbooks.ts ← /api/textbooks — генерация учебников |
||||||
|
│ │ ├── tests.ts ← /api/tests — тестирование |
||||||
|
│ │ └── reports.ts ← /api/reports — ежедневные отчёты |
||||||
|
│ ├── lib/ ← Вспомогательные модули |
||||||
|
│ │ ├── prisma.ts ← Подключение к базе данных |
||||||
|
│ │ └── deepseek.ts ← Подключение к ИИ (DeepSeek) |
||||||
|
│ └── middleware/ ← Промежуточные обработчики |
||||||
|
│ └── errorHandler.ts ← Обработка ошибок |
||||||
|
├── prisma/ |
||||||
|
│ └── schema.prisma ← Описание структуры базы данных |
||||||
|
├── data/ |
||||||
|
│ └── edu_helper.db ← Файл базы данных SQLite |
||||||
|
├── .env ← Переменные окружения (секретные настройки) |
||||||
|
├── tsconfig.json ← Настройки TypeScript |
||||||
|
└── package.json ← Зависимости проекта и команды запуска |
||||||
|
``` |
||||||
|
|
||||||
|
### Как работает маршрут (Route)? |
||||||
|
|
||||||
|
Маршрут — это правило: «если пришёл запрос такого-то типа на такой-то адрес, выполни такой-то код». |
||||||
|
|
||||||
|
Пример из `routes/settings.ts`: |
||||||
|
```typescript |
||||||
|
router.get("/", async (req, res) => { |
||||||
|
// GET /api/settings — вернуть все настройки |
||||||
|
const settings = await prisma.setting.findMany(); |
||||||
|
res.json(settings); // отправить как JSON |
||||||
|
}); |
||||||
|
|
||||||
|
router.put("/", async (req, res) => { |
||||||
|
// PUT /api/settings — обновить настройки |
||||||
|
const entries = req.body; // данные из запроса |
||||||
|
// ... сохранить в БД ... |
||||||
|
res.json({ success: true }); |
||||||
|
}); |
||||||
|
``` |
||||||
|
|
||||||
|
### Типы HTTP-запросов |
||||||
|
|
||||||
|
| Метод | Для чего | Пример | |
||||||
|
|-------|---------|--------| |
||||||
|
| `GET` | Получить данные | Загрузить список вопросов | |
||||||
|
| `POST` | Создать что-то новое | Сохранить новый вопрос | |
||||||
|
| `PUT` | Обновить существующее | Изменить настройки | |
||||||
|
| `DELETE` | Удалить | Очистить историю чата | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 4. База данных: SQLite + Prisma |
||||||
|
|
||||||
|
### Что такое база данных? |
||||||
|
|
||||||
|
База данных — это **структурированное хранилище информации**. Представьте таблицу в Excel: |
||||||
|
|
||||||
|
| id | text | answer | date | |
||||||
|
|----|------|--------|------| |
||||||
|
| 1 | Что такое React? | React — это... | 2026-03-27 | |
||||||
|
| 2 | Как работает async? | Async позволяет... | 2026-03-27 | |
||||||
|
|
||||||
|
Каждая **строка** — это одна запись (вопрос). Каждый **столбец** — это поле (свойство). |
||||||
|
|
||||||
|
### SQLite |
||||||
|
|
||||||
|
SQLite — это база данных, которая хранится в **одном файле** (у нас `data/edu_helper.db`). Не нужно ставить отдельную программу — всё работает «из коробки». |
||||||
|
|
||||||
|
### Prisma |
||||||
|
|
||||||
|
Prisma — это **ORM** (Object-Relational Mapping). Она позволяет работать с базой данных **на TypeScript** вместо SQL. |
||||||
|
|
||||||
|
Без Prisma (чистый SQL): |
||||||
|
```sql |
||||||
|
SELECT * FROM Question WHERE date = '2026-03-27'; |
||||||
|
``` |
||||||
|
|
||||||
|
С Prisma (TypeScript): |
||||||
|
```typescript |
||||||
|
const questions = await prisma.question.findMany({ |
||||||
|
where: { date: '2026-03-27' } |
||||||
|
}); |
||||||
|
``` |
||||||
|
|
||||||
|
### Схема базы данных (`schema.prisma`) |
||||||
|
|
||||||
|
Схема описывает, какие таблицы и поля есть в нашей БД: |
||||||
|
|
||||||
|
```prisma |
||||||
|
model Question { |
||||||
|
id Int @id @default(autoincrement()) // уникальный номер, растёт автоматически |
||||||
|
text String // текст вопроса |
||||||
|
answer String? // ответ (? = может быть пустым) |
||||||
|
date String // дата в формате YYYY-MM-DD |
||||||
|
createdAt DateTime @default(now()) // дата создания (автоматически) |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Наши таблицы: |
||||||
|
- **Setting** — настройки (API-ключ, промпты) |
||||||
|
- **ChatMessage** — история чата |
||||||
|
- **Question** — ежедневные вопросы и ответы |
||||||
|
- **Textbook** — сгенерированные учебники |
||||||
|
- **Test** — тесты (вопросы в формате JSON) |
||||||
|
- **TestResult** — результаты прохождения тестов |
||||||
|
- **Report** — ежедневные отчёты |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 5. Frontend: React + Vite |
||||||
|
|
||||||
|
### Что такое React? |
||||||
|
|
||||||
|
React — это библиотека для создания интерфейсов. Главная идея: интерфейс состоит из **компонентов** — переиспользуемых «кирпичиков». |
||||||
|
|
||||||
|
Например, кнопка — это компонент. Карточка — компонент. Вся страница — тоже компонент, собранный из других. |
||||||
|
|
||||||
|
### Что такое Vite? |
||||||
|
|
||||||
|
Vite — это **сборщик** (build tool). Он берёт все ваши файлы TypeScript, React-компоненты, CSS — и собирает их в один пакет, который понимает браузер. Плюс он поддерживает **горячую перезагрузку** — вы сохраняете файл, и браузер обновляется мгновенно. |
||||||
|
|
||||||
|
### Структура нашего фронтенда |
||||||
|
|
||||||
|
``` |
||||||
|
frontend/src/ |
||||||
|
├── main.tsx ← Точка входа. Монтирует React-приложение в HTML |
||||||
|
├── App.tsx ← Корневой компонент с маршрутизацией |
||||||
|
├── index.css ← Глобальные стили и тема |
||||||
|
├── pages/ ← Страницы приложения (по одной на раздел) |
||||||
|
│ ├── HomePage.tsx ← Главная: приветствие + чат |
||||||
|
│ ├── QuestionsPage.tsx ← Ежедневные вопросы |
||||||
|
│ ├── TextbookPage.tsx ← Генерация учебника |
||||||
|
│ ├── TestPage.tsx ← Тестирование |
||||||
|
│ ├── ReportPage.tsx ← Ежедневный отчёт |
||||||
|
│ └── SettingsPage.tsx ← Настройки (ключ, промпты) |
||||||
|
├── components/ ← Переиспользуемые компоненты |
||||||
|
│ ├── Layout.tsx ← Общий каркас: боковое меню + область контента |
||||||
|
│ └── ui/ ← Базовые UI-компоненты (кнопки, инпуты, карточки) |
||||||
|
│ ├── button.tsx |
||||||
|
│ ├── input.tsx |
||||||
|
│ ├── textarea.tsx |
||||||
|
│ ├── card.tsx |
||||||
|
│ └── label.tsx |
||||||
|
└── lib/ ← Утилиты |
||||||
|
└── utils.ts ← Вспомогательные функции (запросы к API, форматирование) |
||||||
|
``` |
||||||
|
|
||||||
|
### Как работает React-компонент? |
||||||
|
|
||||||
|
Компонент — это функция, которая возвращает **JSX** (разметку, похожую на HTML): |
||||||
|
|
||||||
|
```tsx |
||||||
|
function Greeting() { |
||||||
|
const name = "Константин"; |
||||||
|
return <h1>Привет, {name}!</h1>; |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
В фигурных скобках `{}` можно писать JavaScript-выражения. |
||||||
|
|
||||||
|
### Состояние (State) |
||||||
|
|
||||||
|
Состояние — это данные, которые могут **меняться**. Когда состояние меняется, React перерисовывает компонент. |
||||||
|
|
||||||
|
```tsx |
||||||
|
const [count, setCount] = useState(0); |
||||||
|
// count — текущее значение (изначально 0) |
||||||
|
// setCount — функция для изменения |
||||||
|
|
||||||
|
<button onClick={() => setCount(count + 1)}> |
||||||
|
Нажали {count} раз |
||||||
|
</button> |
||||||
|
``` |
||||||
|
|
||||||
|
### Эффекты (useEffect) |
||||||
|
|
||||||
|
`useEffect` выполняет код **после** рендера компонента. Используется для загрузки данных, подписок и т.д. |
||||||
|
|
||||||
|
```tsx |
||||||
|
useEffect(() => { |
||||||
|
// Этот код выполнится один раз при первом рендере |
||||||
|
fetch("/api/questions").then(res => res.json()).then(setQuestions); |
||||||
|
}, []); // пустой массив = выполнить только один раз |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 6. Компоненты UI: Shadcn подход |
||||||
|
|
||||||
|
### Что такое Shadcn UI? |
||||||
|
|
||||||
|
Shadcn UI — это **не библиотека**, а **коллекция компонентов**, которые вы копируете прямо в свой проект. Это значит: |
||||||
|
- Вы полностью контролируете код каждого компонента |
||||||
|
- Можно настроить внешний вид под свои нужды |
||||||
|
- Нет «чёрного ящика» — всё прозрачно |
||||||
|
|
||||||
|
### Как устроен компонент Button? |
||||||
|
|
||||||
|
```tsx |
||||||
|
const buttonVariants = cva( |
||||||
|
"inline-flex items-center justify-center rounded-md text-sm font-medium", |
||||||
|
{ |
||||||
|
variants: { |
||||||
|
variant: { |
||||||
|
default: "bg-primary text-primary-foreground", // синяя кнопка |
||||||
|
outline: "border border-input bg-background", // кнопка с рамкой |
||||||
|
ghost: "hover:bg-accent", // прозрачная кнопка |
||||||
|
}, |
||||||
|
size: { |
||||||
|
default: "h-10 px-4 py-2", |
||||||
|
sm: "h-9 px-3", |
||||||
|
lg: "h-11 px-8", |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
); |
||||||
|
``` |
||||||
|
|
||||||
|
`cva` (class-variance-authority) — утилита для создания **вариантов стилей**. Вместо кучи `if/else` для классов CSS, мы декларативно описываем все варианты. |
||||||
|
|
||||||
|
### Tailwind CSS |
||||||
|
|
||||||
|
Tailwind — это CSS-фреймворк, где стили задаются через **классы** прямо в HTML: |
||||||
|
|
||||||
|
```html |
||||||
|
<!-- Обычный CSS --> |
||||||
|
<div style="display: flex; padding: 16px; background: white; border-radius: 8px;"> |
||||||
|
|
||||||
|
<!-- Tailwind CSS --> |
||||||
|
<div class="flex p-4 bg-white rounded-lg"> |
||||||
|
``` |
||||||
|
|
||||||
|
Каждый класс = одно CSS-свойство. Это быстрее писать и легче менять. |
||||||
|
|
||||||
|
### Двухцветная тема |
||||||
|
|
||||||
|
По требованию заказчика, дизайн в двух цветах (белый + акцентный). Наши цвета определены в `index.css`: |
||||||
|
|
||||||
|
```css |
||||||
|
@theme { |
||||||
|
--color-background: #ffffff; /* белый фон */ |
||||||
|
--color-primary: #2563eb; /* синий — акцентный цвет */ |
||||||
|
--color-foreground: #0f172a; /* тёмный текст */ |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 7. Маршрутизация |
||||||
|
|
||||||
|
### Что такое маршрутизация? |
||||||
|
|
||||||
|
Когда вы нажимаете на ссылку «Вопросы», URL меняется на `/questions` и показывается нужная страница. Но **страница не перезагружается** — React Router просто меняет компонент. |
||||||
|
|
||||||
|
```tsx |
||||||
|
<Routes> |
||||||
|
<Route element={<Layout />}> {/* общий каркас */} |
||||||
|
<Route path="/" element={<HomePage />} /> |
||||||
|
<Route path="/questions" element={<QuestionsPage />} /> |
||||||
|
<Route path="/textbook" element={<TextbookPage />} /> |
||||||
|
<Route path="/test" element={<TestPage />} /> |
||||||
|
<Route path="/report" element={<ReportPage />} /> |
||||||
|
<Route path="/settings" element={<SettingsPage />} /> |
||||||
|
</Route> |
||||||
|
</Routes> |
||||||
|
``` |
||||||
|
|
||||||
|
`<Layout />` — это **обёртка**: боковое меню слева, а справа — `<Outlet />`, куда подставляется текущая страница. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 8. Как фронтенд общается с бэкендом |
||||||
|
|
||||||
|
### API-запросы |
||||||
|
|
||||||
|
Фронтенд отправляет запросы к бэкенду с помощью функции `fetch`: |
||||||
|
|
||||||
|
```typescript |
||||||
|
// Утилита из lib/utils.ts |
||||||
|
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> { |
||||||
|
const res = await fetch(`/api${path}`, { |
||||||
|
headers: { "Content-Type": "application/json" }, |
||||||
|
...options, |
||||||
|
}); |
||||||
|
return res.json(); |
||||||
|
} |
||||||
|
|
||||||
|
// Использование |
||||||
|
const questions = await apiFetch<Question[]>("/questions?date=2026-03-27"); |
||||||
|
``` |
||||||
|
|
||||||
|
### Прокси в Vite |
||||||
|
|
||||||
|
Фронтенд работает на порту `5173`, бэкенд — на `3001`. Чтобы не было проблем с CORS, Vite проксирует запросы: всё, что начинается с `/api`, перенаправляется на бэкенд. |
||||||
|
|
||||||
|
```typescript |
||||||
|
// vite.config.ts |
||||||
|
server: { |
||||||
|
proxy: { |
||||||
|
"/api": { |
||||||
|
target: "http://localhost:3001", |
||||||
|
changeOrigin: true, |
||||||
|
}, |
||||||
|
}, |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 9. Интеграция с ИИ (DeepSeek) |
||||||
|
|
||||||
|
### Что такое DeepSeek? |
||||||
|
|
||||||
|
DeepSeek — это **языковая модель** (LLM), похожая на ChatGPT. Она принимает текст и генерирует ответ. |
||||||
|
|
||||||
|
### Как мы подключаемся? |
||||||
|
|
||||||
|
DeepSeek использует **OpenAI-совместимый API**. Это значит, мы можем использовать библиотеку `openai` от OpenAI, просто поменяв адрес сервера: |
||||||
|
|
||||||
|
```typescript |
||||||
|
import OpenAI from "openai"; |
||||||
|
|
||||||
|
const client = new OpenAI({ |
||||||
|
baseURL: "https://api.deepseek.com", // адрес DeepSeek вместо OpenAI |
||||||
|
apiKey: "ваш-ключ", |
||||||
|
}); |
||||||
|
``` |
||||||
|
|
||||||
|
### Промпты |
||||||
|
|
||||||
|
**Промпт** (prompt) — это инструкция для ИИ. Мы используем разные промпты для разных задач: |
||||||
|
|
||||||
|
- **prompt_answer** — для ответов на вопросы: «Ответь понятно, используй примеры» |
||||||
|
- **prompt_textbook** — для учебника: «Объясняй просто, структурируй текст» |
||||||
|
- **prompt_test** — для тестов: «Сгенерируй 10 вопросов с вариантами ответов» |
||||||
|
- **prompt_report** — для отчёта: «Составь отчёт в таком-то формате» |
||||||
|
|
||||||
|
Все промпты можно редактировать на странице «Настройки». |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 10. Стриминг ответов (SSE) |
||||||
|
|
||||||
|
### Проблема |
||||||
|
|
||||||
|
Когда ИИ генерирует длинный текст, ожидание может занять 10-30 секунд. Без стриминга пользователь видит пустой экран и думает, что приложение зависло. |
||||||
|
|
||||||
|
### Решение: Server-Sent Events (SSE) |
||||||
|
|
||||||
|
SSE — это технология, которая позволяет серверу **отправлять данные частями** по мере их генерации. |
||||||
|
|
||||||
|
Как это работает: |
||||||
|
|
||||||
|
1. Фронтенд отправляет запрос: «Ответь на вопрос» |
||||||
|
2. Бэкенд начинает получать ответ от ИИ **по кусочкам** |
||||||
|
3. Каждый кусочек сразу отправляется на фронтенд |
||||||
|
4. Фронтенд показывает текст по мере поступления — как будто ИИ «печатает» |
||||||
|
|
||||||
|
``` |
||||||
|
Сервер → Фронтенд: |
||||||
|
data: {"content": "React"} |
||||||
|
data: {"content": " — это"} |
||||||
|
data: {"content": " библиотека"} |
||||||
|
data: {"content": " для"} |
||||||
|
data: {"content": " создания"} |
||||||
|
data: {"content": " интерфейсов."} |
||||||
|
data: [DONE] |
||||||
|
``` |
||||||
|
|
||||||
|
На бэкенде: |
||||||
|
```typescript |
||||||
|
res.setHeader("Content-Type", "text/event-stream"); |
||||||
|
|
||||||
|
const stream = await client.chat.completions.create({ |
||||||
|
model: "deepseek-chat", |
||||||
|
messages: [...], |
||||||
|
stream: true, // ← включаем стриминг |
||||||
|
}); |
||||||
|
|
||||||
|
for await (const chunk of stream) { |
||||||
|
const content = chunk.choices[0]?.delta?.content || ""; |
||||||
|
res.write(`data: ${JSON.stringify({ content })}\n\n`); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
На фронтенде: |
||||||
|
```typescript |
||||||
|
const reader = res.body.getReader(); |
||||||
|
const decoder = new TextDecoder(); |
||||||
|
|
||||||
|
while (true) { |
||||||
|
const { done, value } = await reader.read(); |
||||||
|
if (done) break; |
||||||
|
const text = decoder.decode(value); |
||||||
|
// обработать полученные кусочки и обновить UI |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Словарь терминов |
||||||
|
|
||||||
|
| Термин | Объяснение | |
||||||
|
|--------|-----------| |
||||||
|
| **API** | Application Programming Interface — способ общения между программами | |
||||||
|
| **HTTP** | Протокол передачи данных в вебе (GET, POST, PUT, DELETE) | |
||||||
|
| **JSON** | Формат данных: `{"name": "Константин", "age": 25}` | |
||||||
|
| **REST** | Стиль проектирования API — каждый ресурс имеет свой URL | |
||||||
|
| **ORM** | Object-Relational Mapping — работа с БД через объекты вместо SQL | |
||||||
|
| **SSE** | Server-Sent Events — односторонняя отправка данных от сервера к клиенту | |
||||||
|
| **LLM** | Large Language Model — большая языковая модель (ИИ) | |
||||||
|
| **Промпт** | Текстовая инструкция для ИИ | |
||||||
|
| **Компонент** | Переиспользуемый блок интерфейса в React | |
||||||
|
| **Состояние** | Данные компонента, при изменении которых он перерисовывается | |
||||||
|
| **Маршрут** | Связь между URL и страницей/обработчиком | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 11. Дизайн: CSS-анимации и визуальные эффекты |
||||||
|
|
||||||
|
### Почему дизайн важен? |
||||||
|
|
||||||
|
Интерфейс — это первое, что видит пользователь. Если приложение выглядит современно и приятно, им хочется пользоваться. Claude, ChatGPT и другие ИИ-продукты ставят высокую планку дизайна. |
||||||
|
|
||||||
|
### CSS-анимации |
||||||
|
|
||||||
|
Анимации делают интерфейс «живым». В CSS анимация описывается через `@keyframes`: |
||||||
|
|
||||||
|
```css |
||||||
|
@keyframes fade-in { |
||||||
|
from { opacity: 0; transform: translateY(8px); } |
||||||
|
to { opacity: 1; transform: translateY(0); } |
||||||
|
} |
||||||
|
|
||||||
|
.animate-fade-in { |
||||||
|
animation: fade-in 0.4s ease-out both; |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Что происходит: |
||||||
|
1. `from` — начальное состояние (элемент невидим и смещён вниз на 8px) |
||||||
|
2. `to` — конечное состояние (полностью виден, на своём месте) |
||||||
|
3. `0.4s` — длительность анимации |
||||||
|
4. `ease-out` — плавное замедление в конце |
||||||
|
5. `both` — применить конечное состояние после анимации |
||||||
|
|
||||||
|
### Glow-эффект (свечение) |
||||||
|
|
||||||
|
Свечение создаётся через `box-shadow` с цветом акцента: |
||||||
|
|
||||||
|
```css |
||||||
|
.glow-card:hover { |
||||||
|
box-shadow: |
||||||
|
0 0 0 1px rgba(79, 70, 229, 0.08), /* тонкая рамка */ |
||||||
|
0 4px 20px rgba(79, 70, 229, 0.08), /* мягкое свечение */ |
||||||
|
0 1px 3px rgba(0, 0, 0, 0.04); /* лёгкая тень */ |
||||||
|
transform: translateY(-1px); /* лёгкий подъём */ |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Glassmorphism (стеклянный эффект) |
||||||
|
|
||||||
|
Популярный тренд — полупрозрачный фон с размытием: |
||||||
|
|
||||||
|
```css |
||||||
|
.glass { |
||||||
|
background: rgba(255, 255, 255, 0.85); /* полупрозрачный белый */ |
||||||
|
backdrop-filter: blur(12px); /* размытие фона за элементом */ |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Пульсирующее свечение |
||||||
|
|
||||||
|
Для привлечения внимания (например, иконка логотипа): |
||||||
|
|
||||||
|
```css |
||||||
|
@keyframes pulse-glow { |
||||||
|
0%, 100% { box-shadow: 0 0 15px rgba(79, 70, 229, 0.15); } |
||||||
|
50% { box-shadow: 0 0 30px rgba(79, 70, 229, 0.25); } |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Свечение плавно усиливается и ослабевает — создаёт ощущение «дыхания». |
||||||
|
|
||||||
|
### Принципы хорошей анимации |
||||||
|
|
||||||
|
1. **Быстро** — не более 0.3-0.5 секунды (длиннее раздражает) |
||||||
|
2. **Плавно** — используйте `ease-out` или `cubic-bezier` |
||||||
|
3. **Ненавязчиво** — анимация не должна мешать работе |
||||||
|
4. **Осмысленно** — анимируйте только то, что привлекает внимание к действию |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 12. Electron: веб-приложение как десктоп-программа |
||||||
|
|
||||||
|
### Что такое Electron? |
||||||
|
|
||||||
|
Electron — это фреймворк, который позволяет запускать веб-приложение **как обычную программу** на Windows, macOS и Linux. Технически, он встраивает браузер Chromium внутрь окна приложения. |
||||||
|
|
||||||
|
Пример приложений на Electron: VS Code, Discord, Slack, Notion. |
||||||
|
|
||||||
|
### Как это работает |
||||||
|
|
||||||
|
``` |
||||||
|
┌──────────────────────────────────┐ |
||||||
|
│ Окно Electron (Chromium) │ |
||||||
|
│ ┌────────────────────────────┐ │ |
||||||
|
│ │ Наш React-фронтенд │ │ |
||||||
|
│ │ (http://localhost:5173) │ │ |
||||||
|
│ └────────────────────────────┘ │ |
||||||
|
│ │ |
||||||
|
│ Node.js (запускает бэкенд) │ |
||||||
|
└──────────────────────────────────┘ |
||||||
|
``` |
||||||
|
|
||||||
|
1. Electron запускает **бэкенд** (Express-сервер) как дочерний процесс |
||||||
|
2. Открывает **окно** и загружает в него фронтенд |
||||||
|
3. Пользователь видит обычное приложение, без адресной строки браузера |
||||||
|
|
||||||
|
### Главный файл Electron (`electron/main.js`) |
||||||
|
|
||||||
|
```javascript |
||||||
|
const { app, BrowserWindow } = require("electron"); |
||||||
|
|
||||||
|
function createWindow() { |
||||||
|
const win = new BrowserWindow({ |
||||||
|
width: 1200, |
||||||
|
height: 800, |
||||||
|
titleBarStyle: "hiddenInset", // macOS-стиль: кнопки светофора встроены |
||||||
|
}); |
||||||
|
|
||||||
|
win.loadURL("http://localhost:5173"); |
||||||
|
} |
||||||
|
|
||||||
|
app.whenReady().then(createWindow); |
||||||
|
``` |
||||||
|
|
||||||
|
### Команды запуска |
||||||
|
|
||||||
|
```bash |
||||||
|
# Как десктоп-программу (Electron + бэкенд + фронтенд): |
||||||
|
npm run dev |
||||||
|
|
||||||
|
# Как обычное веб-приложение в браузере: |
||||||
|
npm run dev:web |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## Словарь терминов |
||||||
|
|
||||||
|
| Термин | Объяснение | |
||||||
|
|--------|-----------| |
||||||
|
| **API** | Application Programming Interface — способ общения между программами | |
||||||
|
| **HTTP** | Протокол передачи данных в вебе (GET, POST, PUT, DELETE) | |
||||||
|
| **JSON** | Формат данных: `{"name": "Константин", "age": 25}` | |
||||||
|
| **REST** | Стиль проектирования API — каждый ресурс имеет свой URL | |
||||||
|
| **ORM** | Object-Relational Mapping — работа с БД через объекты вместо SQL | |
||||||
|
| **SSE** | Server-Sent Events — односторонняя отправка данных от сервера к клиенту | |
||||||
|
| **LLM** | Large Language Model — большая языковая модель (ИИ) | |
||||||
|
| **Промпт** | Текстовая инструкция для ИИ | |
||||||
|
| **Компонент** | Переиспользуемый блок интерфейса в React | |
||||||
|
| **Состояние** | Данные компонента, при изменении которых он перерисовывается | |
||||||
|
| **Маршрут** | Связь между URL и страницей/обработчиком | |
||||||
|
| **Electron** | Фреймворк для создания десктоп-приложений из веб-технологий | |
||||||
|
| **Glassmorphism** | Дизайн-тренд: полупрозрачные элементы с размытием фона | |
||||||
|
| **@keyframes** | CSS-конструкция для описания шагов анимации | |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 13. Архив и поиск: работа с накопленными данными |
||||||
|
|
||||||
|
### Зачем нужен архив? |
||||||
|
|
||||||
|
Со временем в приложении накапливаются данные: вопросы, учебники, отчёты. Без архива старые данные «теряются» — их не видно на текущей странице. Архив собирает **всё** в одном месте. |
||||||
|
|
||||||
|
### Группировка по датам |
||||||
|
|
||||||
|
Данные в архиве организованы по дням. Для этого мы берём массив объектов и группируем: |
||||||
|
|
||||||
|
```typescript |
||||||
|
function groupByDate(items) { |
||||||
|
const groups = {}; |
||||||
|
for (const item of items) { |
||||||
|
const date = item.createdAt.split("T")[0]; // "2026-03-27T15:30:00" → "2026-03-27" |
||||||
|
if (!groups[date]) groups[date] = []; |
||||||
|
groups[date].push(item); |
||||||
|
} |
||||||
|
return groups; |
||||||
|
// Результат: { "2026-03-27": [...], "2026-03-26": [...] } |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
### Поиск (фильтрация) |
||||||
|
|
||||||
|
Поиск на фронтенде — это **фильтрация** массива по введённому тексту: |
||||||
|
|
||||||
|
```typescript |
||||||
|
const filtered = questions.filter( |
||||||
|
(q) => q.text.toLowerCase().includes(search.toLowerCase()) |
||||||
|
); |
||||||
|
``` |
||||||
|
|
||||||
|
`includes()` проверяет, содержит ли строка подстроку. `toLowerCase()` делает поиск нечувствительным к регистру. |
||||||
|
|
||||||
|
### Табы (вкладки) |
||||||
|
|
||||||
|
Табы — это состояние, определяющее какой контент показывать: |
||||||
|
|
||||||
|
```typescript |
||||||
|
const [tab, setTab] = useState("questions"); // "questions" | "textbooks" | "reports" |
||||||
|
|
||||||
|
// В зависимости от tab показываем разные списки |
||||||
|
{tab === "questions" && <QuestionsList />} |
||||||
|
{tab === "textbooks" && <TextbooksList />} |
||||||
|
``` |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
## 14. Умное управление полями ввода |
||||||
|
|
||||||
|
### Задача |
||||||
|
|
||||||
|
По ТЗ нужно заполнить **минимум 5 вопросов в день**. Но если 3 уже сохранены — показывать 5 полей глупо. Нужно показать только 2 оставшихся (плюс кнопку «Ещё»). |
||||||
|
|
||||||
|
### Решение |
||||||
|
|
||||||
|
```typescript |
||||||
|
function updateFieldCount(savedCount: number) { |
||||||
|
const needed = Math.max(1, 5 - savedCount); |
||||||
|
// Если сохранено 0 → 5 полей |
||||||
|
// Если сохранено 3 → 2 поля |
||||||
|
// Если сохранено 5+ → 1 поле (всегда можно добавить ещё) |
||||||
|
setFields(Array(needed).fill("")); |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
`Math.max(1, ...)` гарантирует, что **хотя бы одно поле** всегда доступно. |
||||||
|
|
||||||
|
--- |
||||||
|
|
||||||
|
*Учебник обновлён. Новые разделы: архив с поиском, умное управление полями.* |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
node_modules |
||||||
|
# Keep environment variables out of version control |
||||||
|
.env |
||||||
|
|
||||||
|
/generated/prisma |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
# Локальная разработка с PostgreSQL (например docker compose up db -d) |
||||||
|
DATABASE_URL="postgresql://edu:edu@localhost:5432/edu_helper" |
||||||
|
|
||||||
|
JWT_SECRET="dev-secret-min-32-characters-long-string!" |
||||||
|
|
||||||
|
# Первый запуск при пустой БД (npm run db:seed) |
||||||
|
SEED_TUTOR_USERNAME=alexey |
||||||
|
SEED_STUDENT_USERNAME=konstantin |
||||||
|
SEED_TUTOR_PASSWORD=your-tutor-password |
||||||
|
SEED_STUDENT_PASSWORD=your-student-password |
||||||
|
|
||||||
|
# Опционально |
||||||
|
# DEEPSEEK_API_KEY= |
||||||
|
# PORT=3001 |
||||||
|
# COOKIE_SECURE=true |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,45 @@ |
|||||||
|
{ |
||||||
|
"name": "backend", |
||||||
|
"version": "1.0.0", |
||||||
|
"description": "", |
||||||
|
"main": "dist/index.js", |
||||||
|
"scripts": { |
||||||
|
"dev": "tsx watch src/index.ts", |
||||||
|
"build": "tsc", |
||||||
|
"start": "node dist/index.js", |
||||||
|
"db:migrate": "prisma migrate dev", |
||||||
|
"db:push": "prisma db push", |
||||||
|
"db:studio": "prisma studio", |
||||||
|
"db:seed": "tsx src/seed.ts" |
||||||
|
}, |
||||||
|
"keywords": [], |
||||||
|
"author": "", |
||||||
|
"license": "ISC", |
||||||
|
"type": "commonjs", |
||||||
|
"prisma": { |
||||||
|
"seed": "tsx src/seed.ts" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@prisma/client": "^5.22.0", |
||||||
|
"@types/multer": "^2.1.0", |
||||||
|
"bcryptjs": "^2.4.3", |
||||||
|
"cookie-parser": "^1.4.7", |
||||||
|
"cors": "^2.8.6", |
||||||
|
"dotenv": "^17.3.1", |
||||||
|
"express": "^5.2.1", |
||||||
|
"jsonwebtoken": "^9.0.2", |
||||||
|
"multer": "^2.1.1", |
||||||
|
"openai": "^6.33.0", |
||||||
|
"prisma": "^5.22.0" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/bcryptjs": "^2.4.6", |
||||||
|
"@types/cookie-parser": "^1.4.8", |
||||||
|
"@types/cors": "^2.8.19", |
||||||
|
"@types/express": "^5.0.6", |
||||||
|
"@types/jsonwebtoken": "^9.0.9", |
||||||
|
"@types/node": "^25.5.0", |
||||||
|
"tsx": "^4.21.0", |
||||||
|
"typescript": "^6.0.2" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,128 @@ |
|||||||
|
-- CreateEnum |
||||||
|
CREATE TYPE "Role" AS ENUM ('TUTOR', 'STUDENT'); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "User" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"username" TEXT NOT NULL, |
||||||
|
"passwordHash" TEXT NOT NULL, |
||||||
|
"role" "Role" NOT NULL, |
||||||
|
"displayName" TEXT, |
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "TutorAssignment" ( |
||||||
|
"tutorId" INTEGER NOT NULL, |
||||||
|
"studentId" INTEGER NOT NULL, |
||||||
|
|
||||||
|
CONSTRAINT "TutorAssignment_pkey" PRIMARY KEY ("tutorId","studentId") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "Setting" ( |
||||||
|
"key" TEXT NOT NULL, |
||||||
|
"value" TEXT NOT NULL, |
||||||
|
|
||||||
|
CONSTRAINT "Setting_pkey" PRIMARY KEY ("key") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "ChatMessage" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"studentId" INTEGER NOT NULL, |
||||||
|
"role" TEXT NOT NULL, |
||||||
|
"content" TEXT NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "Question" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"studentId" INTEGER NOT NULL, |
||||||
|
"text" TEXT NOT NULL, |
||||||
|
"answer" TEXT, |
||||||
|
"date" TEXT NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "Question_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "Textbook" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"studentId" INTEGER NOT NULL, |
||||||
|
"topic" TEXT NOT NULL, |
||||||
|
"content" TEXT NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "Textbook_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "Test" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"studentId" INTEGER NOT NULL, |
||||||
|
"topic" TEXT NOT NULL, |
||||||
|
"questions" TEXT NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "Test_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "TestResult" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"testId" INTEGER NOT NULL, |
||||||
|
"answers" TEXT NOT NULL, |
||||||
|
"score" INTEGER NOT NULL, |
||||||
|
"total" INTEGER NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "TestResult_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "Report" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"studentId" INTEGER NOT NULL, |
||||||
|
"date" TEXT NOT NULL, |
||||||
|
"content" TEXT NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "Report_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- CreateIndex |
||||||
|
CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); |
||||||
|
|
||||||
|
-- CreateIndex |
||||||
|
CREATE UNIQUE INDEX "TutorAssignment_studentId_key" ON "TutorAssignment"("studentId"); |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "TutorAssignment" ADD CONSTRAINT "TutorAssignment_tutorId_fkey" FOREIGN KEY ("tutorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "TutorAssignment" ADD CONSTRAINT "TutorAssignment_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "Question" ADD CONSTRAINT "Question_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "Textbook" ADD CONSTRAINT "Textbook_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "Test" ADD CONSTRAINT "Test_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "TestResult" ADD CONSTRAINT "TestResult_testId_fkey" FOREIGN KEY ("testId") REFERENCES "Test"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "Report" ADD CONSTRAINT "Report_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
|
|
||||||
@ -0,0 +1,14 @@ |
|||||||
|
-- CreateTable |
||||||
|
CREATE TABLE "HallPhoto" ( |
||||||
|
"id" SERIAL NOT NULL, |
||||||
|
"studentId" INTEGER NOT NULL, |
||||||
|
"date" TEXT NOT NULL, |
||||||
|
"fileName" TEXT NOT NULL, |
||||||
|
"mimeType" TEXT NOT NULL, |
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||||
|
|
||||||
|
CONSTRAINT "HallPhoto_pkey" PRIMARY KEY ("id") |
||||||
|
); |
||||||
|
|
||||||
|
-- AddForeignKey |
||||||
|
ALTER TABLE "HallPhoto" ADD CONSTRAINT "HallPhoto_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; |
||||||
@ -0,0 +1,2 @@ |
|||||||
|
-- AlterTable |
||||||
|
ALTER TABLE "HallPhoto" ADD COLUMN "originalName" TEXT NOT NULL DEFAULT ''; |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
# Please do not edit this file manually |
||||||
|
# It should be added in your version-control system (i.e. Git) |
||||||
|
provider = "postgresql" |
||||||
@ -0,0 +1,114 @@ |
|||||||
|
generator client { |
||||||
|
provider = "prisma-client-js" |
||||||
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x", "linux-musl-arm64-openssl-3.0.x"] |
||||||
|
} |
||||||
|
|
||||||
|
datasource db { |
||||||
|
provider = "postgresql" |
||||||
|
url = env("DATABASE_URL") |
||||||
|
} |
||||||
|
|
||||||
|
enum Role { |
||||||
|
TUTOR |
||||||
|
STUDENT |
||||||
|
} |
||||||
|
|
||||||
|
model User { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
username String @unique |
||||||
|
passwordHash String |
||||||
|
role Role |
||||||
|
displayName String? |
||||||
|
|
||||||
|
tutoringAssignments TutorAssignment[] @relation("TutorInAssignment") |
||||||
|
studentAssignment TutorAssignment? @relation("StudentInAssignment") |
||||||
|
|
||||||
|
questions Question[] |
||||||
|
chatMessages ChatMessage[] |
||||||
|
textbooks Textbook[] |
||||||
|
tests Test[] |
||||||
|
reports Report[] |
||||||
|
hallPhotos HallPhoto[] |
||||||
|
} |
||||||
|
|
||||||
|
model TutorAssignment { |
||||||
|
tutorId Int |
||||||
|
studentId Int @unique |
||||||
|
tutor User @relation("TutorInAssignment", fields: [tutorId], references: [id], onDelete: Cascade) |
||||||
|
student User @relation("StudentInAssignment", fields: [studentId], references: [id], onDelete: Cascade) |
||||||
|
|
||||||
|
@@id([tutorId, studentId]) |
||||||
|
} |
||||||
|
|
||||||
|
model Setting { |
||||||
|
key String @id |
||||||
|
value String |
||||||
|
} |
||||||
|
|
||||||
|
model ChatMessage { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
studentId Int |
||||||
|
student User @relation(fields: [studentId], references: [id], onDelete: Cascade) |
||||||
|
role String |
||||||
|
content String |
||||||
|
createdAt DateTime @default(now()) |
||||||
|
} |
||||||
|
|
||||||
|
model Question { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
studentId Int |
||||||
|
student User @relation(fields: [studentId], references: [id], onDelete: Cascade) |
||||||
|
text String |
||||||
|
answer String? |
||||||
|
date String |
||||||
|
createdAt DateTime @default(now()) |
||||||
|
} |
||||||
|
|
||||||
|
model Textbook { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
studentId Int |
||||||
|
student User @relation(fields: [studentId], references: [id], onDelete: Cascade) |
||||||
|
topic String |
||||||
|
content String |
||||||
|
createdAt DateTime @default(now()) |
||||||
|
} |
||||||
|
|
||||||
|
model Test { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
studentId Int |
||||||
|
student User @relation(fields: [studentId], references: [id], onDelete: Cascade) |
||||||
|
topic String |
||||||
|
questions String |
||||||
|
createdAt DateTime @default(now()) |
||||||
|
results TestResult[] |
||||||
|
} |
||||||
|
|
||||||
|
model TestResult { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
testId Int |
||||||
|
answers String |
||||||
|
score Int |
||||||
|
total Int |
||||||
|
createdAt DateTime @default(now()) |
||||||
|
test Test @relation(fields: [testId], references: [id], onDelete: Cascade) |
||||||
|
} |
||||||
|
|
||||||
|
model Report { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
studentId Int |
||||||
|
student User @relation(fields: [studentId], references: [id], onDelete: Cascade) |
||||||
|
date String |
||||||
|
content String |
||||||
|
createdAt DateTime @default(now()) |
||||||
|
} |
||||||
|
|
||||||
|
model HallPhoto { |
||||||
|
id Int @id @default(autoincrement()) |
||||||
|
studentId Int |
||||||
|
student User @relation(fields: [studentId], references: [id], onDelete: Cascade) |
||||||
|
date String |
||||||
|
fileName String |
||||||
|
originalName String @default("") |
||||||
|
mimeType String |
||||||
|
createdAt DateTime @default(now()) |
||||||
|
} |
||||||
@ -0,0 +1,67 @@ |
|||||||
|
/// <reference path="./types/express.d.ts" />
|
||||||
|
import fs from "fs"; |
||||||
|
import path from "path"; |
||||||
|
import express from "express"; |
||||||
|
import cors from "cors"; |
||||||
|
import cookieParser from "cookie-parser"; |
||||||
|
import dotenv from "dotenv"; |
||||||
|
import { errorHandler } from "./middleware/errorHandler"; |
||||||
|
import { requireAuth } from "./middleware/auth"; |
||||||
|
import { attachStudentId } from "./middleware/studentContext"; |
||||||
|
import authRouter from "./routes/auth"; |
||||||
|
import settingsRouter from "./routes/settings"; |
||||||
|
import chatRouter from "./routes/chat"; |
||||||
|
import questionsRouter from "./routes/questions"; |
||||||
|
import textbooksRouter from "./routes/textbooks"; |
||||||
|
import testsRouter from "./routes/tests"; |
||||||
|
import reportsRouter from "./routes/reports"; |
||||||
|
|
||||||
|
dotenv.config(); |
||||||
|
|
||||||
|
const app = express(); |
||||||
|
const PORT = Number(process.env.PORT) || 3001; |
||||||
|
const isProd = process.env.NODE_ENV === "production"; |
||||||
|
|
||||||
|
app.use( |
||||||
|
cors({ |
||||||
|
origin: true, |
||||||
|
credentials: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
app.use(cookieParser()); |
||||||
|
app.use(express.json({ limit: "10mb" })); |
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => { |
||||||
|
res.json({ status: "ok", timestamp: new Date().toISOString() }); |
||||||
|
}); |
||||||
|
|
||||||
|
app.use("/api/auth", authRouter); |
||||||
|
|
||||||
|
app.use("/api/settings", requireAuth, settingsRouter); |
||||||
|
app.use("/api/chat", requireAuth, attachStudentId, chatRouter); |
||||||
|
app.use("/api/questions", requireAuth, attachStudentId, questionsRouter); |
||||||
|
app.use("/api/textbooks", requireAuth, attachStudentId, textbooksRouter); |
||||||
|
app.use("/api/tests", requireAuth, attachStudentId, testsRouter); |
||||||
|
app.use("/api/reports", requireAuth, attachStudentId, reportsRouter); |
||||||
|
|
||||||
|
const publicDir = path.join(__dirname, "../public"); |
||||||
|
if (isProd && fs.existsSync(publicDir)) { |
||||||
|
app.use(express.static(publicDir)); |
||||||
|
app.use((req, res, next) => { |
||||||
|
if (req.method !== "GET" && req.method !== "HEAD") { |
||||||
|
next(); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (req.path.startsWith("/api")) { |
||||||
|
res.status(404).json({ error: "Not found" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
res.sendFile(path.join(publicDir, "index.html")); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
app.use(errorHandler); |
||||||
|
|
||||||
|
app.listen(PORT, "0.0.0.0", () => { |
||||||
|
console.log(`Server listening on http://0.0.0.0:${PORT}`); |
||||||
|
}); |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
import jwt from "jsonwebtoken"; |
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET || "dev-only-change-me-use-JWT_SECRET-in-production-32chars"; |
||||||
|
|
||||||
|
export interface JwtPayload { |
||||||
|
sub: number; |
||||||
|
role: "TUTOR" | "STUDENT"; |
||||||
|
username: string; |
||||||
|
} |
||||||
|
|
||||||
|
export function signToken(p: JwtPayload): string { |
||||||
|
return jwt.sign( |
||||||
|
{ sub: p.sub, role: p.role, username: p.username }, |
||||||
|
SECRET, |
||||||
|
{ expiresIn: "7d" } |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function verifyToken(token: string): JwtPayload { |
||||||
|
const decoded = jwt.verify(token, SECRET); |
||||||
|
return decoded as unknown as JwtPayload; |
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
import OpenAI from "openai"; |
||||||
|
import prisma from "./prisma"; |
||||||
|
|
||||||
|
let clientInstance: OpenAI | null = null; |
||||||
|
let cachedKey: string | null = null; |
||||||
|
|
||||||
|
export async function getDeepSeekClient(): Promise<OpenAI> { |
||||||
|
const setting = await prisma.setting.findUnique({ where: { key: "deepseek_api_key" } }); |
||||||
|
const apiKey = setting?.value; |
||||||
|
|
||||||
|
if (!apiKey) { |
||||||
|
throw new Error("DeepSeek API key not configured. Go to Settings to add it."); |
||||||
|
} |
||||||
|
|
||||||
|
if (clientInstance && cachedKey === apiKey) { |
||||||
|
return clientInstance; |
||||||
|
} |
||||||
|
|
||||||
|
clientInstance = new OpenAI({ |
||||||
|
baseURL: "https://api.deepseek.com", |
||||||
|
apiKey, |
||||||
|
}); |
||||||
|
cachedKey = apiKey; |
||||||
|
|
||||||
|
return clientInstance; |
||||||
|
} |
||||||
|
|
||||||
|
export async function getPrompt(key: string, fallback: string): Promise<string> { |
||||||
|
const setting = await prisma.setting.findUnique({ where: { key } }); |
||||||
|
return setting?.value || fallback; |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
import { PrismaClient } from "@prisma/client"; |
||||||
|
|
||||||
|
const prisma = new PrismaClient(); |
||||||
|
|
||||||
|
export default prisma; |
||||||
@ -0,0 +1,12 @@ |
|||||||
|
import prisma from "./prisma"; |
||||||
|
import type { AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
export async function getStudentIdForRequest(req: AuthRequest): Promise<number> { |
||||||
|
if (!req.user) throw new Error("No user"); |
||||||
|
if (req.user.role === "STUDENT") return req.user.id; |
||||||
|
const a = await prisma.tutorAssignment.findFirst({ |
||||||
|
where: { tutorId: req.user.id }, |
||||||
|
}); |
||||||
|
if (!a) throw new Error("Tutor has no assigned student"); |
||||||
|
return a.studentId; |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import { Request, Response, NextFunction } from "express"; |
||||||
|
import { verifyToken } from "../lib/authTokens"; |
||||||
|
|
||||||
|
export interface AuthUser { |
||||||
|
id: number; |
||||||
|
role: "TUTOR" | "STUDENT"; |
||||||
|
username: string; |
||||||
|
} |
||||||
|
|
||||||
|
export type AuthRequest = Request & { user?: AuthUser }; |
||||||
|
|
||||||
|
export function requireAuth(req: AuthRequest, res: Response, next: NextFunction): void { |
||||||
|
const token = req.cookies?.token; |
||||||
|
if (!token) { |
||||||
|
res.status(401).json({ error: "Unauthorized" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
try { |
||||||
|
const p = verifyToken(token); |
||||||
|
req.user = { id: p.sub, role: p.role, username: p.username }; |
||||||
|
next(); |
||||||
|
} catch { |
||||||
|
res.status(401).json({ error: "Unauthorized" }); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
import { Request, Response, NextFunction } from "express"; |
||||||
|
|
||||||
|
export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction) { |
||||||
|
console.error("[Error]", err.message); |
||||||
|
res.status(500).json({ error: err.message }); |
||||||
|
} |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
import { Response, NextFunction } from "express"; |
||||||
|
import type { AuthRequest } from "./auth"; |
||||||
|
import { getStudentIdForRequest } from "../lib/studentContext"; |
||||||
|
|
||||||
|
export async function attachStudentId( |
||||||
|
req: AuthRequest, |
||||||
|
res: Response, |
||||||
|
next: NextFunction |
||||||
|
): Promise<void> { |
||||||
|
try { |
||||||
|
req.studentId = await getStudentIdForRequest(req); |
||||||
|
next(); |
||||||
|
} catch { |
||||||
|
res.status(403).json({ error: "Не назначен ученик для наставника" }); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,69 @@ |
|||||||
|
import { Router, Request, Response } from "express"; |
||||||
|
import bcrypt from "bcryptjs"; |
||||||
|
import type { CookieOptions } from "express"; |
||||||
|
import prisma from "../lib/prisma"; |
||||||
|
import { signToken } from "../lib/authTokens"; |
||||||
|
import { requireAuth, type AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
|
||||||
|
// В Docker/проде часто открывают по HTTP — при secure:true браузер не сохраняет cookie.
|
||||||
|
// Включайте COOKIE_SECURE=true только за HTTPS (reverse proxy + TLS).
|
||||||
|
const cookieSecure = process.env.COOKIE_SECURE === "true"; |
||||||
|
|
||||||
|
const cookieOpts: CookieOptions = { |
||||||
|
httpOnly: true, |
||||||
|
secure: cookieSecure, |
||||||
|
sameSite: "lax", |
||||||
|
maxAge: 7 * 24 * 3600 * 1000, |
||||||
|
path: "/", |
||||||
|
}; |
||||||
|
|
||||||
|
router.post("/login", async (req: Request, res: Response) => { |
||||||
|
const { username, password } = req.body as { username?: string; password?: string }; |
||||||
|
if (!username || !password) { |
||||||
|
res.status(400).json({ error: "Нужны имя пользователя и пароль" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } }); |
||||||
|
if (!user || !(await bcrypt.compare(password, user.passwordHash))) { |
||||||
|
res.status(401).json({ error: "Неверный логин или пароль" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const token = signToken({ |
||||||
|
sub: user.id, |
||||||
|
role: user.role, |
||||||
|
username: user.username, |
||||||
|
}); |
||||||
|
|
||||||
|
res.cookie("token", token, cookieOpts); |
||||||
|
res.json({ |
||||||
|
user: { |
||||||
|
id: user.id, |
||||||
|
username: user.username, |
||||||
|
role: user.role, |
||||||
|
displayName: user.displayName, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}); |
||||||
|
|
||||||
|
router.post("/logout", (_req, res: Response) => { |
||||||
|
res.clearCookie("token", { ...cookieOpts, maxAge: undefined }); |
||||||
|
res.json({ success: true }); |
||||||
|
}); |
||||||
|
|
||||||
|
router.get("/me", requireAuth, async (req: AuthRequest, res: Response) => { |
||||||
|
const user = await prisma.user.findUnique({ |
||||||
|
where: { id: req.user!.id }, |
||||||
|
select: { id: true, username: true, role: true, displayName: true }, |
||||||
|
}); |
||||||
|
if (!user) { |
||||||
|
res.status(401).json({ error: "Unauthorized" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
res.json({ user }); |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,83 @@ |
|||||||
|
import { Router, Response } from "express"; |
||||||
|
import prisma from "../lib/prisma"; |
||||||
|
import { getDeepSeekClient } from "../lib/deepseek"; |
||||||
|
import type { AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
|
||||||
|
router.get("/history", async (req: AuthRequest, res: Response) => { |
||||||
|
const studentId = req.studentId!; |
||||||
|
const messages = await prisma.chatMessage.findMany({ |
||||||
|
where: { studentId }, |
||||||
|
orderBy: { createdAt: "asc" }, |
||||||
|
take: 100, |
||||||
|
}); |
||||||
|
res.json(messages); |
||||||
|
}); |
||||||
|
|
||||||
|
router.post("/", async (req: AuthRequest, res: Response) => { |
||||||
|
const { message } = req.body; |
||||||
|
const studentId = req.studentId!; |
||||||
|
|
||||||
|
if (!message || typeof message !== "string") { |
||||||
|
res.status(400).json({ error: "Message is required" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await prisma.chatMessage.create({ |
||||||
|
data: { role: "user", content: message, studentId }, |
||||||
|
}); |
||||||
|
|
||||||
|
const history = await prisma.chatMessage.findMany({ |
||||||
|
where: { studentId }, |
||||||
|
orderBy: { createdAt: "asc" }, |
||||||
|
take: 50, |
||||||
|
}); |
||||||
|
|
||||||
|
const client = await getDeepSeekClient(); |
||||||
|
|
||||||
|
const messages = history.map((m: { role: string; content: string }) => ({ |
||||||
|
role: m.role as "user" | "assistant", |
||||||
|
content: m.content, |
||||||
|
})); |
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/event-stream"); |
||||||
|
res.setHeader("Cache-Control", "no-cache"); |
||||||
|
res.setHeader("Connection", "keep-alive"); |
||||||
|
|
||||||
|
try { |
||||||
|
const stream = await client.chat.completions.create({ |
||||||
|
model: "deepseek-chat", |
||||||
|
messages, |
||||||
|
stream: true, |
||||||
|
}); |
||||||
|
|
||||||
|
let fullResponse = ""; |
||||||
|
|
||||||
|
for await (const chunk of stream) { |
||||||
|
const content = chunk.choices[0]?.delta?.content || ""; |
||||||
|
if (content) { |
||||||
|
fullResponse += content; |
||||||
|
res.write(`data: ${JSON.stringify({ content })}\n\n`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await prisma.chatMessage.create({ |
||||||
|
data: { role: "assistant", content: fullResponse, studentId }, |
||||||
|
}); |
||||||
|
|
||||||
|
res.write(`data: [DONE]\n\n`); |
||||||
|
res.end(); |
||||||
|
} catch (err: any) { |
||||||
|
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`); |
||||||
|
res.end(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
router.delete("/history", async (req: AuthRequest, res: Response) => { |
||||||
|
const studentId = req.studentId!; |
||||||
|
await prisma.chatMessage.deleteMany({ where: { studentId } }); |
||||||
|
res.json({ success: true }); |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,132 @@ |
|||||||
|
import { Router, Response } from "express"; |
||||||
|
import prisma from "../lib/prisma"; |
||||||
|
import { getDeepSeekClient, getPrompt } from "../lib/deepseek"; |
||||||
|
import type { AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
|
||||||
|
const DEFAULT_ANSWER_PROMPT = `Ты — терпеливый и дружелюбный репетитор. Твоя задача — ответить на вопрос ученика.
|
||||||
|
|
||||||
|
Правила: |
||||||
|
- Объясняй максимально простым языком, как будто объясняешь человеку, который вообще не разбирается в теме |
||||||
|
- Используй аналогии из повседневной жизни |
||||||
|
- Приводи конкретные примеры |
||||||
|
- Если вопрос сложный — разбей ответ на шаги |
||||||
|
- Форматируй ответ с использованием Markdown (заголовки, списки, выделение) |
||||||
|
- Не используй сложные термины без объяснения`;
|
||||||
|
|
||||||
|
router.get("/", async (req: AuthRequest, res: Response) => { |
||||||
|
const date = req.query.date as string | undefined; |
||||||
|
const search = req.query.search as string | undefined; |
||||||
|
const studentId = req.studentId!; |
||||||
|
const where: { studentId: number; date?: string; text?: { contains: string } } = { studentId }; |
||||||
|
if (date) where.date = date; |
||||||
|
if (search) where.text = { contains: search }; |
||||||
|
const questions = await prisma.question.findMany({ |
||||||
|
where, |
||||||
|
orderBy: { createdAt: "desc" }, |
||||||
|
}); |
||||||
|
res.json(questions); |
||||||
|
}); |
||||||
|
|
||||||
|
router.get("/dates", async (req: AuthRequest, res: Response) => { |
||||||
|
const studentId = req.studentId!; |
||||||
|
const questions = await prisma.question.findMany({ |
||||||
|
where: { studentId }, |
||||||
|
select: { date: true }, |
||||||
|
distinct: ["date"], |
||||||
|
orderBy: { createdAt: "desc" }, |
||||||
|
}); |
||||||
|
res.json(questions.map((q: { date: string }) => q.date)); |
||||||
|
}); |
||||||
|
|
||||||
|
router.post("/", async (req: AuthRequest, res: Response) => { |
||||||
|
const { questions } = req.body as { questions: string[] }; |
||||||
|
const studentId = req.studentId!; |
||||||
|
|
||||||
|
if (!Array.isArray(questions) || questions.length === 0) { |
||||||
|
res.status(400).json({ error: "At least 1 question is required" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const date = new Date().toISOString().split("T")[0]; |
||||||
|
|
||||||
|
const created = await Promise.all( |
||||||
|
questions.map((text) => |
||||||
|
prisma.question.create({ data: { text, date, studentId } }) |
||||||
|
) |
||||||
|
); |
||||||
|
|
||||||
|
res.json(created); |
||||||
|
}); |
||||||
|
|
||||||
|
router.delete("/:id", async (req: AuthRequest, res: Response) => { |
||||||
|
if (req.user!.role !== "STUDENT") { |
||||||
|
res.status(403).json({ error: "Удалять вопросы может только ученик — только свои" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const id = parseInt(req.params.id as string, 10); |
||||||
|
const studentId = req.user!.id; |
||||||
|
|
||||||
|
const existing = await prisma.question.findFirst({ |
||||||
|
where: { id, studentId }, |
||||||
|
}); |
||||||
|
|
||||||
|
if (!existing) { |
||||||
|
res.status(404).json({ error: "Вопрос не найден" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
await prisma.question.delete({ where: { id } }); |
||||||
|
res.json({ success: true }); |
||||||
|
}); |
||||||
|
|
||||||
|
router.post("/:id/answer", async (req: AuthRequest, res: Response) => { |
||||||
|
const id = parseInt(req.params.id as string, 10); |
||||||
|
const studentId = req.studentId!; |
||||||
|
const question = await prisma.question.findFirst({ where: { id, studentId } }); |
||||||
|
|
||||||
|
if (!question) { |
||||||
|
res.status(404).json({ error: "Question not found" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const client = await getDeepSeekClient(); |
||||||
|
const systemPrompt = await getPrompt("prompt_answer", DEFAULT_ANSWER_PROMPT); |
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/event-stream"); |
||||||
|
res.setHeader("Cache-Control", "no-cache"); |
||||||
|
res.setHeader("Connection", "keep-alive"); |
||||||
|
|
||||||
|
try { |
||||||
|
const stream = await client.chat.completions.create({ |
||||||
|
model: "deepseek-chat", |
||||||
|
messages: [ |
||||||
|
{ role: "system", content: systemPrompt }, |
||||||
|
{ role: "user", content: question.text }, |
||||||
|
], |
||||||
|
stream: true, |
||||||
|
}); |
||||||
|
|
||||||
|
let fullAnswer = ""; |
||||||
|
|
||||||
|
for await (const chunk of stream) { |
||||||
|
const content = chunk.choices[0]?.delta?.content || ""; |
||||||
|
if (content) { |
||||||
|
fullAnswer += content; |
||||||
|
res.write(`data: ${JSON.stringify({ content })}\n\n`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
await prisma.question.update({ where: { id }, data: { answer: fullAnswer } }); |
||||||
|
|
||||||
|
res.write(`data: [DONE]\n\n`); |
||||||
|
res.end(); |
||||||
|
} catch (err: any) { |
||||||
|
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`); |
||||||
|
res.end(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,192 @@ |
|||||||
|
import fs from "fs"; |
||||||
|
import path from "path"; |
||||||
|
import { Router, Response } from "express"; |
||||||
|
import multer from "multer"; |
||||||
|
import prisma from "../lib/prisma"; |
||||||
|
import type { AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
const uploadsDir = process.env.UPLOADS_DIR || path.join(process.cwd(), "uploads"); |
||||||
|
fs.mkdirSync(uploadsDir, { recursive: true }); |
||||||
|
|
||||||
|
const storage = multer.diskStorage({ |
||||||
|
destination: (_req, _file, cb) => cb(null, uploadsDir), |
||||||
|
filename: (_req, file, cb) => { |
||||||
|
const ext = path.extname(file.originalname || "").toLowerCase(); |
||||||
|
cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`); |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const upload = multer({ |
||||||
|
storage, |
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, |
||||||
|
fileFilter: (_req, file, cb) => { |
||||||
|
if (file.mimetype.startsWith("image/")) cb(null, true); |
||||||
|
else cb(new Error("Можно загружать только изображения")); |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
/** UTF-8 имя, ошибочно прочитанное как Latin-1 (типично для multipart). */ |
||||||
|
function normalizeUtf8Filename(s: string): string { |
||||||
|
if (!s) return s; |
||||||
|
if (/[\u0400-\u04FF]/.test(s)) return s; |
||||||
|
try { |
||||||
|
const recovered = Buffer.from(s, "latin1").toString("utf8"); |
||||||
|
if (/[\u0400-\u04FF]/.test(recovered)) return recovered; |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} |
||||||
|
return s; |
||||||
|
} |
||||||
|
|
||||||
|
function safeOriginalName(name: string | undefined): string { |
||||||
|
if (!name) return ""; |
||||||
|
const base = path.basename(name.replace(/\0/g, "")); |
||||||
|
const normalized = normalizeUtf8Filename(base); |
||||||
|
return normalized.length > 255 ? normalized.slice(0, 255) : normalized; |
||||||
|
} |
||||||
|
|
||||||
|
/** Загрузка и удаление фото в «Зале» — только ученик konstantin. */ |
||||||
|
function canManageHallPhotos(req: AuthRequest): boolean { |
||||||
|
return req.user!.username.toLowerCase() === "konstantin"; |
||||||
|
} |
||||||
|
|
||||||
|
router.get("/", async (req: AuthRequest, res: Response) => { |
||||||
|
const studentId = req.studentId!; |
||||||
|
const date = req.query.date ? String(req.query.date) : undefined; |
||||||
|
const items = await prisma.hallPhoto.findMany({ |
||||||
|
where: date ? { studentId, date } : { studentId }, |
||||||
|
orderBy: { createdAt: "desc" }, |
||||||
|
}); |
||||||
|
|
||||||
|
res.json( |
||||||
|
items.map((p) => { |
||||||
|
const raw = p.originalName.trim() || p.fileName; |
||||||
|
const displayName = normalizeUtf8Filename(raw); |
||||||
|
return { |
||||||
|
id: p.id, |
||||||
|
date: p.date, |
||||||
|
createdAt: p.createdAt, |
||||||
|
imageUrl: `/api/reports/${p.id}/image`, |
||||||
|
fileName: p.fileName, |
||||||
|
originalName: p.originalName, |
||||||
|
displayName, |
||||||
|
}; |
||||||
|
}) |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
router.post( |
||||||
|
"/upload", |
||||||
|
(req: AuthRequest, res, next) => { |
||||||
|
if (!canManageHallPhotos(req)) { |
||||||
|
res.status(403).json({ error: "Загрузка фото доступна только ученику" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
next(); |
||||||
|
}, |
||||||
|
(req, res, next) => { |
||||||
|
upload.single("photo")(req, res, (err) => { |
||||||
|
if (err) { |
||||||
|
res.status(400).json({ error: err.message || "Ошибка загрузки файла" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
next(); |
||||||
|
}); |
||||||
|
}, |
||||||
|
async (req: AuthRequest, res: Response) => { |
||||||
|
const studentId = req.studentId!; |
||||||
|
if (!req.file) { |
||||||
|
res.status(400).json({ error: "Файл не передан" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const date = |
||||||
|
typeof req.body.date === "string" && req.body.date |
||||||
|
? req.body.date |
||||||
|
: new Date().toISOString().split("T")[0]; |
||||||
|
|
||||||
|
const originalName = safeOriginalName(req.file.originalname); |
||||||
|
|
||||||
|
const created = await prisma.hallPhoto.create({ |
||||||
|
data: { |
||||||
|
studentId, |
||||||
|
date, |
||||||
|
fileName: req.file.filename, |
||||||
|
originalName, |
||||||
|
mimeType: req.file.mimetype || "application/octet-stream", |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const displayName = normalizeUtf8Filename( |
||||||
|
created.originalName.trim() || created.fileName |
||||||
|
); |
||||||
|
|
||||||
|
res.json({ |
||||||
|
id: created.id, |
||||||
|
date: created.date, |
||||||
|
createdAt: created.createdAt, |
||||||
|
imageUrl: `/api/reports/${created.id}/image`, |
||||||
|
fileName: created.fileName, |
||||||
|
originalName: created.originalName, |
||||||
|
displayName, |
||||||
|
}); |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
router.delete("/:id", async (req: AuthRequest, res: Response) => { |
||||||
|
if (!canManageHallPhotos(req)) { |
||||||
|
res.status(403).json({ error: "Удаление фото доступно только ученику" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
const studentId = req.studentId!; |
||||||
|
const id = Number(req.params.id); |
||||||
|
if (!Number.isFinite(id)) { |
||||||
|
res.status(400).json({ error: "Некорректный id" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const photo = await prisma.hallPhoto.findFirst({ where: { id, studentId } }); |
||||||
|
if (!photo) { |
||||||
|
res.status(404).json({ error: "Фото не найдено" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const fullPath = path.join(uploadsDir, path.basename(photo.fileName)); |
||||||
|
await prisma.hallPhoto.delete({ where: { id } }); |
||||||
|
if (fs.existsSync(fullPath)) { |
||||||
|
try { |
||||||
|
fs.unlinkSync(fullPath); |
||||||
|
} catch { |
||||||
|
/* ignore */ |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
res.json({ success: true }); |
||||||
|
}); |
||||||
|
|
||||||
|
router.get("/:id/image", async (req: AuthRequest, res: Response) => { |
||||||
|
const studentId = req.studentId!; |
||||||
|
const id = Number(req.params.id); |
||||||
|
if (!Number.isFinite(id)) { |
||||||
|
res.status(400).json({ error: "Некорректный id" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const photo = await prisma.hallPhoto.findFirst({ where: { id, studentId } }); |
||||||
|
if (!photo) { |
||||||
|
res.status(404).json({ error: "Фото не найдено" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const fullPath = path.join(uploadsDir, path.basename(photo.fileName)); |
||||||
|
if (!fs.existsSync(fullPath)) { |
||||||
|
res.status(404).json({ error: "Файл не найден на диске" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
res.setHeader("Content-Type", photo.mimeType); |
||||||
|
res.sendFile(fullPath); |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
import { Router, Response } from "express"; |
||||||
|
import prisma from "../lib/prisma"; |
||||||
|
import type { AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
|
||||||
|
router.get("/", async (_req: AuthRequest, res: Response) => { |
||||||
|
const settings = await prisma.setting.findMany(); |
||||||
|
const result: Record<string, string> = {}; |
||||||
|
for (const s of settings) { |
||||||
|
result[s.key] = s.key === "deepseek_api_key" ? "••••••" + s.value.slice(-4) : s.value; |
||||||
|
} |
||||||
|
res.json(result); |
||||||
|
}); |
||||||
|
|
||||||
|
router.get("/raw", async (req: AuthRequest, res: Response) => { |
||||||
|
if (req.user!.role !== "TUTOR") { |
||||||
|
res.status(403).json({ error: "Только наставник может просматривать полные настройки" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
const settings = await prisma.setting.findMany(); |
||||||
|
const result: Record<string, string> = {}; |
||||||
|
for (const s of settings) { |
||||||
|
result[s.key] = s.value; |
||||||
|
} |
||||||
|
res.json(result); |
||||||
|
}); |
||||||
|
|
||||||
|
router.put("/", async (req: AuthRequest, res: Response) => { |
||||||
|
if (req.user!.role !== "TUTOR") { |
||||||
|
res.status(403).json({ error: "Только наставник может менять настройки" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const entries: Record<string, string> = req.body; |
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(entries)) { |
||||||
|
await prisma.setting.upsert({ |
||||||
|
where: { key }, |
||||||
|
update: { value }, |
||||||
|
create: { key, value }, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
res.json({ success: true }); |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,143 @@ |
|||||||
|
import { Router, Response } from "express"; |
||||||
|
import prisma from "../lib/prisma"; |
||||||
|
import { getDeepSeekClient, getPrompt } from "../lib/deepseek"; |
||||||
|
import type { AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
|
||||||
|
const DEFAULT_TEST_PROMPT = `Ты — составитель тестов. Сгенерируй тест из РОВНО 10 вопросов.
|
||||||
|
|
||||||
|
Требования к вопросам: |
||||||
|
- Вопросы должны проверять ПОНИМАНИЕ темы, а не запоминание фактов |
||||||
|
- Сложность: 3 лёгких, 4 средних, 3 сложных |
||||||
|
- Каждый вопрос имеет 4 варианта ответа (a, b, c, d) и ровно один правильный |
||||||
|
- Неправильные варианты должны быть правдоподобными (не абсурдными) |
||||||
|
- Вопросы на русском языке |
||||||
|
|
||||||
|
ВАЖНО: Верни ТОЛЬКО JSON-массив, без markdown-обёрток (\`\`\`json), без пояснений, ЧИСТЫЙ JSON:
|
||||||
|
[{"question":"текст вопроса","options":{"a":"вариант А","b":"вариант Б","c":"вариант В","d":"вариант Г"},"correct":"a"}]`;
|
||||||
|
|
||||||
|
/** Тесты привязаны к текущему пользователю (ученик — свои; наставник — свои черновые, не к ученику). */ |
||||||
|
function testOwnerId(req: AuthRequest): number { |
||||||
|
return req.user!.id; |
||||||
|
} |
||||||
|
|
||||||
|
router.get("/", async (req: AuthRequest, res: Response) => { |
||||||
|
const ownerId = testOwnerId(req); |
||||||
|
const tests = await prisma.test.findMany({ |
||||||
|
where: { studentId: ownerId }, |
||||||
|
orderBy: { createdAt: "desc" }, |
||||||
|
include: { results: true }, |
||||||
|
}); |
||||||
|
res.json(tests); |
||||||
|
}); |
||||||
|
|
||||||
|
router.get("/:id", async (req: AuthRequest, res: Response) => { |
||||||
|
const id = parseInt(req.params.id as string, 10); |
||||||
|
const ownerId = testOwnerId(req); |
||||||
|
const test = await prisma.test.findFirst({ |
||||||
|
where: { id, studentId: ownerId }, |
||||||
|
include: { results: true }, |
||||||
|
}); |
||||||
|
|
||||||
|
if (!test) { |
||||||
|
res.status(404).json({ error: "Test not found" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
res.json(test); |
||||||
|
}); |
||||||
|
|
||||||
|
router.post("/generate", async (req: AuthRequest, res: Response) => { |
||||||
|
const { topic, fromQuestions } = req.body; |
||||||
|
const ownerId = testOwnerId(req); |
||||||
|
/** Вопросы «по моим вопросам» берутся из базы назначенного ученика (для наставника — его ученик). */ |
||||||
|
const questionBankStudentId = req.studentId!; |
||||||
|
|
||||||
|
const client = await getDeepSeekClient(); |
||||||
|
const systemPrompt = await getPrompt("prompt_test", DEFAULT_TEST_PROMPT); |
||||||
|
|
||||||
|
let userMessage: string; |
||||||
|
|
||||||
|
if (fromQuestions) { |
||||||
|
const questions = await prisma.question.findMany({ |
||||||
|
where: { studentId: questionBankStudentId }, |
||||||
|
orderBy: { createdAt: "desc" }, |
||||||
|
take: 20, |
||||||
|
}); |
||||||
|
const questionTexts = questions.map((q: { text: string }) => q.text).join("\n"); |
||||||
|
userMessage = `Составь тест на основе этих вопросов, которые задавал пользователь ранее:\n${questionTexts}`; |
||||||
|
} else { |
||||||
|
if (!topic) { |
||||||
|
res.status(400).json({ error: "Topic is required" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
userMessage = `Тема: ${topic}`; |
||||||
|
} |
||||||
|
|
||||||
|
try { |
||||||
|
const response = await client.chat.completions.create({ |
||||||
|
model: "deepseek-chat", |
||||||
|
messages: [ |
||||||
|
{ role: "system", content: systemPrompt }, |
||||||
|
{ role: "user", content: userMessage }, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
const raw = response.choices[0]?.message?.content || "[]"; |
||||||
|
const jsonMatch = raw.match(/\[[\s\S]*\]/); |
||||||
|
const questionsJson = jsonMatch ? jsonMatch[0] : "[]"; |
||||||
|
|
||||||
|
const test = await prisma.test.create({ |
||||||
|
data: { |
||||||
|
topic: topic || "По прошлым вопросам", |
||||||
|
questions: questionsJson, |
||||||
|
studentId: ownerId, |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
res.json(test); |
||||||
|
} catch (err: any) { |
||||||
|
res.status(500).json({ error: err.message }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
router.post("/:id/submit", async (req: AuthRequest, res: Response) => { |
||||||
|
const id = parseInt(req.params.id as string, 10); |
||||||
|
const ownerId = testOwnerId(req); |
||||||
|
const { answers } = req.body as { answers: Record<string, string> }; |
||||||
|
|
||||||
|
const test = await prisma.test.findFirst({ where: { id, studentId: ownerId } }); |
||||||
|
if (!test) { |
||||||
|
res.status(404).json({ error: "Test not found" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const questions = JSON.parse(test.questions); |
||||||
|
let score = 0; |
||||||
|
const total = questions.length; |
||||||
|
|
||||||
|
for (const q of questions as { question: string; correct: string }[]) { |
||||||
|
if (answers[q.question] === q.correct) { |
||||||
|
score++; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (req.user!.role === "TUTOR") { |
||||||
|
res.json({ score, total, persisted: false as const }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const result = await prisma.testResult.create({ |
||||||
|
data: { |
||||||
|
testId: id, |
||||||
|
answers: JSON.stringify(answers), |
||||||
|
score, |
||||||
|
total, |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
res.json({ ...result, persisted: true as const }); |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,99 @@ |
|||||||
|
import { Router, Response } from "express"; |
||||||
|
import prisma from "../lib/prisma"; |
||||||
|
import { getDeepSeekClient, getPrompt } from "../lib/deepseek"; |
||||||
|
import type { AuthRequest } from "../middleware/auth"; |
||||||
|
|
||||||
|
const router = Router(); |
||||||
|
|
||||||
|
const DEFAULT_TEXTBOOK_PROMPT = `Ты — автор учебника для абсолютных новичков. Твоя аудитория — люди, которые ВООБЩЕ ничего не знают по теме (и не стесняются этого).
|
||||||
|
|
||||||
|
Структура учебника: |
||||||
|
1. **Введение** — зачем это нужно, где применяется в реальной жизни |
||||||
|
2. **Основные понятия** — каждое на отдельном подзаголовке, с определением простыми словами |
||||||
|
3. **Подробное объяснение** — для каждого понятия: аналогия из жизни + конкретный пример |
||||||
|
4. **Как это работает на практике** — пошаговый разбор |
||||||
|
5. **Частые ошибки и заблуждения** — что обычно путают новички |
||||||
|
6. **Итог** — краткое резюме в 5-7 пунктах |
||||||
|
|
||||||
|
Правила: |
||||||
|
- Пиши так, будто объясняешь другу за чашкой кофе |
||||||
|
- Никаких сложных терминов без немедленного объяснения |
||||||
|
- Используй аналогии из повседневной жизни (кухня, транспорт, магазин и т.д.) |
||||||
|
- Формат — Markdown с заголовками, списками, выделением важного |
||||||
|
- Учебник должен быть ДЛИННЫМ и ПОДРОБНЫМ — не менее 2000 слов`;
|
||||||
|
|
||||||
|
router.get("/", async (req: AuthRequest, res: Response) => { |
||||||
|
const search = req.query.search as string | undefined; |
||||||
|
const studentId = req.studentId!; |
||||||
|
const where: { studentId: number; topic?: { contains: string } } = { studentId }; |
||||||
|
if (search) where.topic = { contains: search }; |
||||||
|
const textbooks = await prisma.textbook.findMany({ |
||||||
|
where, |
||||||
|
orderBy: { createdAt: "desc" }, |
||||||
|
}); |
||||||
|
res.json(textbooks); |
||||||
|
}); |
||||||
|
|
||||||
|
router.get("/:id", async (req: AuthRequest, res: Response) => { |
||||||
|
const id = parseInt(req.params.id as string, 10); |
||||||
|
const studentId = req.studentId!; |
||||||
|
const textbook = await prisma.textbook.findFirst({ where: { id, studentId } }); |
||||||
|
|
||||||
|
if (!textbook) { |
||||||
|
res.status(404).json({ error: "Textbook not found" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
res.json(textbook); |
||||||
|
}); |
||||||
|
|
||||||
|
router.post("/generate", async (req: AuthRequest, res: Response) => { |
||||||
|
const { topic } = req.body; |
||||||
|
const studentId = req.studentId!; |
||||||
|
|
||||||
|
if (!topic || typeof topic !== "string") { |
||||||
|
res.status(400).json({ error: "Topic is required" }); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const client = await getDeepSeekClient(); |
||||||
|
const systemPrompt = await getPrompt("prompt_textbook", DEFAULT_TEXTBOOK_PROMPT); |
||||||
|
|
||||||
|
res.setHeader("Content-Type", "text/event-stream"); |
||||||
|
res.setHeader("Cache-Control", "no-cache"); |
||||||
|
res.setHeader("Connection", "keep-alive"); |
||||||
|
|
||||||
|
try { |
||||||
|
const stream = await client.chat.completions.create({ |
||||||
|
model: "deepseek-chat", |
||||||
|
messages: [ |
||||||
|
{ role: "system", content: systemPrompt }, |
||||||
|
{ role: "user", content: `Тема: ${topic}` }, |
||||||
|
], |
||||||
|
stream: true, |
||||||
|
}); |
||||||
|
|
||||||
|
let fullContent = ""; |
||||||
|
|
||||||
|
for await (const chunk of stream) { |
||||||
|
const content = chunk.choices[0]?.delta?.content || ""; |
||||||
|
if (content) { |
||||||
|
fullContent += content; |
||||||
|
res.write(`data: ${JSON.stringify({ content })}\n\n`); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const textbook = await prisma.textbook.create({ |
||||||
|
data: { topic, content: fullContent, studentId }, |
||||||
|
}); |
||||||
|
|
||||||
|
res.write(`data: ${JSON.stringify({ id: textbook.id })}\n\n`); |
||||||
|
res.write(`data: [DONE]\n\n`); |
||||||
|
res.end(); |
||||||
|
} catch (err: any) { |
||||||
|
res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`); |
||||||
|
res.end(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,65 @@ |
|||||||
|
import "dotenv/config"; |
||||||
|
import bcrypt from "bcryptjs"; |
||||||
|
import prisma from "./lib/prisma"; |
||||||
|
|
||||||
|
async function main() { |
||||||
|
const n = await prisma.user.count(); |
||||||
|
if (n > 0) { |
||||||
|
console.log("Users already exist, skipping seed."); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const tutorUser = process.env.SEED_TUTOR_USERNAME?.trim().toLowerCase() || "alexey"; |
||||||
|
const studentUser = process.env.SEED_STUDENT_USERNAME?.trim().toLowerCase() || "konstantin"; |
||||||
|
const tutorPass = process.env.SEED_TUTOR_PASSWORD; |
||||||
|
const studentPass = process.env.SEED_STUDENT_PASSWORD; |
||||||
|
|
||||||
|
if (!tutorPass || !studentPass) { |
||||||
|
throw new Error( |
||||||
|
"Первый запуск: задайте SEED_TUTOR_PASSWORD и SEED_STUDENT_PASSWORD в окружении." |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const tutor = await prisma.user.create({ |
||||||
|
data: { |
||||||
|
username: tutorUser, |
||||||
|
passwordHash: await bcrypt.hash(tutorPass, 10), |
||||||
|
role: "TUTOR", |
||||||
|
displayName: "Алексей", |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
const student = await prisma.user.create({ |
||||||
|
data: { |
||||||
|
username: studentUser, |
||||||
|
passwordHash: await bcrypt.hash(studentPass, 10), |
||||||
|
role: "STUDENT", |
||||||
|
displayName: "Константин", |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
await prisma.tutorAssignment.create({ |
||||||
|
data: { tutorId: tutor.id, studentId: student.id }, |
||||||
|
}); |
||||||
|
|
||||||
|
const apiKey = process.env.DEEPSEEK_API_KEY?.trim(); |
||||||
|
if (apiKey) { |
||||||
|
await prisma.setting.upsert({ |
||||||
|
where: { key: "deepseek_api_key" }, |
||||||
|
update: { value: apiKey }, |
||||||
|
create: { key: "deepseek_api_key", value: apiKey }, |
||||||
|
}); |
||||||
|
console.log("Seeded deepseek_api_key from DEEPSEEK_API_KEY"); |
||||||
|
} |
||||||
|
|
||||||
|
console.log(`Seeded tutor=${tutorUser}, student=${studentUser}`); |
||||||
|
} |
||||||
|
|
||||||
|
main() |
||||||
|
.catch((e) => { |
||||||
|
console.error(e); |
||||||
|
process.exit(1); |
||||||
|
}) |
||||||
|
.finally(async () => { |
||||||
|
await prisma.$disconnect(); |
||||||
|
}); |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
declare global { |
||||||
|
namespace Express { |
||||||
|
interface Request { |
||||||
|
user?: { id: number; role: "TUTOR" | "STUDENT"; username: string }; |
||||||
|
studentId?: number; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export {}; |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "ES2022", |
||||||
|
"module": "commonjs", |
||||||
|
"lib": ["ES2022"], |
||||||
|
"outDir": "./dist", |
||||||
|
"rootDir": "./src", |
||||||
|
"strict": true, |
||||||
|
"esModuleInterop": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"forceConsistentCasingInFileNames": true, |
||||||
|
"resolveJsonModule": true, |
||||||
|
"declaration": true, |
||||||
|
"declarationMap": true, |
||||||
|
"sourceMap": true |
||||||
|
}, |
||||||
|
"include": ["src/**/*"], |
||||||
|
"exclude": ["node_modules", "dist"] |
||||||
|
} |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
services: |
||||||
|
db: |
||||||
|
image: postgres:16-alpine |
||||||
|
environment: |
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-edu} |
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-edu} |
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-edu_helper} |
||||||
|
volumes: |
||||||
|
- pgdata:/var/lib/postgresql/data |
||||||
|
healthcheck: |
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-edu} -d ${POSTGRES_DB:-edu_helper}"] |
||||||
|
interval: 3s |
||||||
|
timeout: 5s |
||||||
|
retries: 10 |
||||||
|
|
||||||
|
app: |
||||||
|
build: . |
||||||
|
ports: |
||||||
|
- "${APP_PORT:-3000}:3000" |
||||||
|
environment: |
||||||
|
NODE_ENV: production |
||||||
|
PORT: "3000" |
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-edu}:${POSTGRES_PASSWORD:-edu}@db:5432/${POSTGRES_DB:-edu_helper} |
||||||
|
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET in .env} |
||||||
|
SEED_TUTOR_USERNAME: ${SEED_TUTOR_USERNAME:-alexey} |
||||||
|
SEED_STUDENT_USERNAME: ${SEED_STUDENT_USERNAME:-konstantin} |
||||||
|
SEED_TUTOR_PASSWORD: ${SEED_TUTOR_PASSWORD:?Set SEED_TUTOR_PASSWORD in .env} |
||||||
|
SEED_STUDENT_PASSWORD: ${SEED_STUDENT_PASSWORD:?Set SEED_STUDENT_PASSWORD in .env} |
||||||
|
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-} |
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false} |
||||||
|
depends_on: |
||||||
|
db: |
||||||
|
condition: service_healthy |
||||||
|
volumes: |
||||||
|
- uploads:/app/uploads |
||||||
|
|
||||||
|
volumes: |
||||||
|
pgdata: |
||||||
|
uploads: |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
#!/bin/sh |
||||||
|
set -e |
||||||
|
cd /app |
||||||
|
npx prisma migrate deploy |
||||||
|
node dist/seed.js |
||||||
|
exec "$@" |
||||||
@ -0,0 +1,24 @@ |
|||||||
|
# Logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
pnpm-debug.log* |
||||||
|
lerna-debug.log* |
||||||
|
|
||||||
|
node_modules |
||||||
|
dist |
||||||
|
dist-ssr |
||||||
|
*.local |
||||||
|
|
||||||
|
# Editor directories and files |
||||||
|
.vscode/* |
||||||
|
!.vscode/extensions.json |
||||||
|
.idea |
||||||
|
.DS_Store |
||||||
|
*.suo |
||||||
|
*.ntvs* |
||||||
|
*.njsproj |
||||||
|
*.sln |
||||||
|
*.sw? |
||||||
@ -0,0 +1,73 @@ |
|||||||
|
# React + TypeScript + Vite |
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. |
||||||
|
|
||||||
|
Currently, two official plugins are available: |
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) |
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) |
||||||
|
|
||||||
|
## React Compiler |
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). |
||||||
|
|
||||||
|
## Expanding the ESLint configuration |
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: |
||||||
|
|
||||||
|
```js |
||||||
|
export default defineConfig([ |
||||||
|
globalIgnores(['dist']), |
||||||
|
{ |
||||||
|
files: ['**/*.{ts,tsx}'], |
||||||
|
extends: [ |
||||||
|
// Other configs... |
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this |
||||||
|
tseslint.configs.recommendedTypeChecked, |
||||||
|
// Alternatively, use this for stricter rules |
||||||
|
tseslint.configs.strictTypeChecked, |
||||||
|
// Optionally, add this for stylistic rules |
||||||
|
tseslint.configs.stylisticTypeChecked, |
||||||
|
|
||||||
|
// Other configs... |
||||||
|
], |
||||||
|
languageOptions: { |
||||||
|
parserOptions: { |
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'], |
||||||
|
tsconfigRootDir: import.meta.dirname, |
||||||
|
}, |
||||||
|
// other options... |
||||||
|
}, |
||||||
|
}, |
||||||
|
]) |
||||||
|
``` |
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: |
||||||
|
|
||||||
|
```js |
||||||
|
// eslint.config.js |
||||||
|
import reactX from 'eslint-plugin-react-x' |
||||||
|
import reactDom from 'eslint-plugin-react-dom' |
||||||
|
|
||||||
|
export default defineConfig([ |
||||||
|
globalIgnores(['dist']), |
||||||
|
{ |
||||||
|
files: ['**/*.{ts,tsx}'], |
||||||
|
extends: [ |
||||||
|
// Other configs... |
||||||
|
// Enable lint rules for React |
||||||
|
reactX.configs['recommended-typescript'], |
||||||
|
// Enable lint rules for React DOM |
||||||
|
reactDom.configs.recommended, |
||||||
|
], |
||||||
|
languageOptions: { |
||||||
|
parserOptions: { |
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'], |
||||||
|
tsconfigRootDir: import.meta.dirname, |
||||||
|
}, |
||||||
|
// other options... |
||||||
|
}, |
||||||
|
}, |
||||||
|
]) |
||||||
|
``` |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
import js from '@eslint/js' |
||||||
|
import globals from 'globals' |
||||||
|
import reactHooks from 'eslint-plugin-react-hooks' |
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh' |
||||||
|
import tseslint from 'typescript-eslint' |
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config' |
||||||
|
|
||||||
|
export default defineConfig([ |
||||||
|
globalIgnores(['dist']), |
||||||
|
{ |
||||||
|
files: ['**/*.{ts,tsx}'], |
||||||
|
extends: [ |
||||||
|
js.configs.recommended, |
||||||
|
tseslint.configs.recommended, |
||||||
|
reactHooks.configs.flat.recommended, |
||||||
|
reactRefresh.configs.vite, |
||||||
|
], |
||||||
|
languageOptions: { |
||||||
|
ecmaVersion: 2020, |
||||||
|
globals: globals.browser, |
||||||
|
}, |
||||||
|
}, |
||||||
|
]) |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>frontend</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="root"></div> |
||||||
|
<script type="module" src="/src/main.tsx"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,41 @@ |
|||||||
|
{ |
||||||
|
"name": "frontend", |
||||||
|
"private": true, |
||||||
|
"version": "0.0.0", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "tsc -b && vite build", |
||||||
|
"lint": "eslint .", |
||||||
|
"preview": "vite preview" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"class-variance-authority": "^0.7.1", |
||||||
|
"clsx": "^2.1.1", |
||||||
|
"html2canvas": "^1.4.1", |
||||||
|
"jspdf": "^4.2.1", |
||||||
|
"lucide-react": "^1.7.0", |
||||||
|
"react": "^19.2.4", |
||||||
|
"react-dom": "^19.2.4", |
||||||
|
"react-markdown": "^10.1.0", |
||||||
|
"react-router-dom": "^7.13.2", |
||||||
|
"remark-gfm": "^4.0.1", |
||||||
|
"tailwind-merge": "^3.5.0" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@eslint/js": "^9.39.4", |
||||||
|
"@tailwindcss/vite": "^4.2.2", |
||||||
|
"@types/node": "^24.12.0", |
||||||
|
"@types/react": "^19.2.14", |
||||||
|
"@types/react-dom": "^19.2.3", |
||||||
|
"@vitejs/plugin-react": "^6.0.1", |
||||||
|
"eslint": "^9.39.4", |
||||||
|
"eslint-plugin-react-hooks": "^7.0.1", |
||||||
|
"eslint-plugin-react-refresh": "^0.5.2", |
||||||
|
"globals": "^17.4.0", |
||||||
|
"tailwindcss": "^4.2.2", |
||||||
|
"typescript": "~5.9.3", |
||||||
|
"typescript-eslint": "^8.57.0", |
||||||
|
"vite": "^8.0.1" |
||||||
|
} |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 9.3 KiB |
@ -0,0 +1,49 @@ |
|||||||
|
import { BrowserRouter, Routes, Route, Navigate, Outlet } from "react-router-dom"; |
||||||
|
import { Loader2 } from "lucide-react"; |
||||||
|
import { AuthProvider, useAuth } from "@/context/AuthContext"; |
||||||
|
import Layout from "@/components/Layout"; |
||||||
|
import HomePage from "@/pages/HomePage"; |
||||||
|
import QuestionsPage from "@/pages/QuestionsPage"; |
||||||
|
import TextbookPage from "@/pages/TextbookPage"; |
||||||
|
import TestPage from "@/pages/TestPage"; |
||||||
|
import ReportPage from "@/pages/ReportPage"; |
||||||
|
import ArchivePage from "@/pages/ArchivePage"; |
||||||
|
import SettingsPage from "@/pages/SettingsPage"; |
||||||
|
import LoginPage from "@/pages/LoginPage"; |
||||||
|
|
||||||
|
function RequireAuth() { |
||||||
|
const { user, loading } = useAuth(); |
||||||
|
if (loading) { |
||||||
|
return ( |
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background"> |
||||||
|
<Loader2 className="animate-spin text-primary" size={28} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
if (!user) return <Navigate to="/login" replace />; |
||||||
|
return <Outlet />; |
||||||
|
} |
||||||
|
|
||||||
|
export default function App() { |
||||||
|
return ( |
||||||
|
<BrowserRouter> |
||||||
|
<AuthProvider> |
||||||
|
<Routes> |
||||||
|
<Route path="/login" element={<LoginPage />} /> |
||||||
|
<Route element={<RequireAuth />}> |
||||||
|
<Route element={<Layout />}> |
||||||
|
<Route path="/" element={<HomePage />} /> |
||||||
|
<Route path="/questions" element={<QuestionsPage />} /> |
||||||
|
<Route path="/textbook" element={<TextbookPage />} /> |
||||||
|
<Route path="/test" element={<TestPage />} /> |
||||||
|
<Route path="/report" element={<ReportPage />} /> |
||||||
|
<Route path="/archive" element={<ArchivePage />} /> |
||||||
|
<Route path="/settings" element={<SettingsPage />} /> |
||||||
|
</Route> |
||||||
|
</Route> |
||||||
|
<Route path="*" element={<Navigate to="/" replace />} /> |
||||||
|
</Routes> |
||||||
|
</AuthProvider> |
||||||
|
</BrowserRouter> |
||||||
|
); |
||||||
|
} |
||||||
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
@ -0,0 +1,108 @@ |
|||||||
|
import { NavLink, Outlet, useLocation } from "react-router-dom"; |
||||||
|
import { |
||||||
|
MessageCircle, |
||||||
|
HelpCircle, |
||||||
|
BookOpen, |
||||||
|
ClipboardList, |
||||||
|
FileText, |
||||||
|
Settings, |
||||||
|
Sparkles, |
||||||
|
Archive, |
||||||
|
LogOut, |
||||||
|
} from "lucide-react"; |
||||||
|
import { cn } from "@/lib/utils"; |
||||||
|
import { useAuth } from "@/context/AuthContext"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
|
||||||
|
const baseNavItems = [ |
||||||
|
{ to: "/", icon: MessageCircle, label: "Чат" }, |
||||||
|
{ to: "/questions", icon: HelpCircle, label: "Вопросы" }, |
||||||
|
{ to: "/textbook", icon: BookOpen, label: "Учебник" }, |
||||||
|
{ to: "/test", icon: ClipboardList, label: "Тест" }, |
||||||
|
{ to: "/report", icon: FileText, label: "Зал" }, |
||||||
|
{ to: "/archive", icon: Archive, label: "Архив" }, |
||||||
|
] as const; |
||||||
|
|
||||||
|
export default function Layout() { |
||||||
|
const location = useLocation(); |
||||||
|
const { user, logout } = useAuth(); |
||||||
|
const isTutor = user?.role === "TUTOR"; |
||||||
|
|
||||||
|
const navItems = isTutor |
||||||
|
? [...baseNavItems, { to: "/settings", icon: Settings, label: "Настройки" } as const] |
||||||
|
: [...baseNavItems]; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex h-screen bg-background"> |
||||||
|
<aside className="w-[220px] border-r border-border/60 flex flex-col shrink-0 bg-sidebar"> |
||||||
|
<div className="p-5 pb-4"> |
||||||
|
<div className="flex items-center gap-2.5"> |
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary flex items-center justify-center animate-pulse-glow"> |
||||||
|
<Sparkles size={16} className="text-white" /> |
||||||
|
</div> |
||||||
|
<span className="text-[15px] font-semibold tracking-tight text-foreground"> |
||||||
|
EduHelper |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
{user && ( |
||||||
|
<p className="text-[11px] text-muted-foreground mt-2 truncate" title={user.username}> |
||||||
|
{user.displayName || user.username} |
||||||
|
{isTutor ? ( |
||||||
|
<span className="block text-[10px] opacity-70">наставник</span> |
||||||
|
) : ( |
||||||
|
<span className="block text-[10px] opacity-70">ученик</span> |
||||||
|
)} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
<nav className="flex-1 px-3 space-y-0.5"> |
||||||
|
{navItems.map(({ to, icon: Icon, label }, i) => { |
||||||
|
const isActive = |
||||||
|
to === "/" ? location.pathname === "/" : location.pathname.startsWith(to); |
||||||
|
|
||||||
|
return ( |
||||||
|
<NavLink |
||||||
|
key={to} |
||||||
|
to={to} |
||||||
|
style={{ animationDelay: `${i * 40}ms` }} |
||||||
|
className={cn( |
||||||
|
"flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-[13px] font-medium transition-all duration-200 animate-slide-in", |
||||||
|
isActive |
||||||
|
? "bg-sidebar-active text-primary shadow-sm" |
||||||
|
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground" |
||||||
|
)} |
||||||
|
> |
||||||
|
<Icon size={17} strokeWidth={isActive ? 2 : 1.7} /> |
||||||
|
{label} |
||||||
|
</NavLink> |
||||||
|
); |
||||||
|
})} |
||||||
|
</nav> |
||||||
|
|
||||||
|
<div className="p-3 pb-4 space-y-2"> |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="sm" |
||||||
|
className="w-full justify-start text-muted-foreground text-[13px]" |
||||||
|
onClick={() => logout()} |
||||||
|
> |
||||||
|
<LogOut size={16} className="mr-2" /> |
||||||
|
Выйти |
||||||
|
</Button> |
||||||
|
<div className="rounded-xl bg-accent/50 p-3 text-center"> |
||||||
|
<p className="text-[11px] text-muted-foreground leading-relaxed"> |
||||||
|
Powered by DeepSeek |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</aside> |
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto"> |
||||||
|
<div className="max-w-3xl mx-auto px-6 py-6 animate-fade-in" key={location.pathname}> |
||||||
|
<Outlet /> |
||||||
|
</div> |
||||||
|
</main> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,85 @@ |
|||||||
|
import { useRef } from "react"; |
||||||
|
import ReactMarkdown from "react-markdown"; |
||||||
|
import remarkGfm from "remark-gfm"; |
||||||
|
import { cn } from "@/lib/utils"; |
||||||
|
|
||||||
|
interface MarkdownProps { |
||||||
|
children: string; |
||||||
|
className?: string; |
||||||
|
exportable?: boolean; |
||||||
|
exportTitle?: string; |
||||||
|
} |
||||||
|
|
||||||
|
export default function Markdown({ |
||||||
|
children, |
||||||
|
className = "", |
||||||
|
exportable = false, |
||||||
|
exportTitle = "document", |
||||||
|
}: MarkdownProps) { |
||||||
|
const ref = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
async function exportPdf() { |
||||||
|
const el = ref.current; |
||||||
|
if (!el) return; |
||||||
|
|
||||||
|
const { default: html2canvas } = await import("html2canvas"); |
||||||
|
const { default: jsPDF } = await import("jspdf"); |
||||||
|
|
||||||
|
const canvas = await html2canvas(el, { |
||||||
|
scale: 2, |
||||||
|
useCORS: true, |
||||||
|
backgroundColor: "#ffffff", |
||||||
|
}); |
||||||
|
|
||||||
|
const imgData = canvas.toDataURL("image/png"); |
||||||
|
const imgWidth = 190; |
||||||
|
const pageHeight = 277; |
||||||
|
const imgHeight = (canvas.height * imgWidth) / canvas.width; |
||||||
|
|
||||||
|
const pdf = new jsPDF("p", "mm", "a4"); |
||||||
|
let heightLeft = imgHeight; |
||||||
|
let position = 10; |
||||||
|
|
||||||
|
pdf.addImage(imgData, "PNG", 10, position, imgWidth, imgHeight); |
||||||
|
heightLeft -= pageHeight; |
||||||
|
|
||||||
|
while (heightLeft > 0) { |
||||||
|
position = heightLeft - imgHeight + 10; |
||||||
|
pdf.addPage(); |
||||||
|
pdf.addImage(imgData, "PNG", 10, position, imgWidth, imgHeight); |
||||||
|
heightLeft -= pageHeight; |
||||||
|
} |
||||||
|
|
||||||
|
pdf.save(`${exportTitle}.pdf`); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="relative group"> |
||||||
|
{exportable && ( |
||||||
|
<button |
||||||
|
onClick={exportPdf} |
||||||
|
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-xs bg-primary text-primary-foreground px-3 py-1.5 rounded-lg shadow-sm hover:bg-primary/90 cursor-pointer z-10" |
||||||
|
> |
||||||
|
PDF |
||||||
|
</button> |
||||||
|
)} |
||||||
|
<div |
||||||
|
ref={ref} |
||||||
|
className={cn("prose prose-sm max-w-none text-[14px]", className)} |
||||||
|
> |
||||||
|
<ReactMarkdown |
||||||
|
remarkPlugins={[remarkGfm]} |
||||||
|
components={{ |
||||||
|
table: ({ children, ...props }) => ( |
||||||
|
<div className="overflow-x-auto rounded-lg my-3"> |
||||||
|
<table {...props}>{children}</table> |
||||||
|
</div> |
||||||
|
), |
||||||
|
}} |
||||||
|
> |
||||||
|
{children} |
||||||
|
</ReactMarkdown> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
import { type ButtonHTMLAttributes, forwardRef } from "react"; |
||||||
|
import { cva, type VariantProps } from "class-variance-authority"; |
||||||
|
import { cn } from "@/lib/utils"; |
||||||
|
|
||||||
|
const buttonVariants = cva( |
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer active:scale-[0.98]", |
||||||
|
{ |
||||||
|
variants: { |
||||||
|
variant: { |
||||||
|
default: |
||||||
|
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md hover:shadow-primary/20", |
||||||
|
destructive: |
||||||
|
"bg-destructive text-destructive-foreground hover:bg-destructive/90", |
||||||
|
outline: |
||||||
|
"border border-input bg-background hover:bg-accent hover:text-accent-foreground hover:border-primary/30", |
||||||
|
secondary: |
||||||
|
"bg-secondary text-secondary-foreground hover:bg-muted", |
||||||
|
ghost: |
||||||
|
"hover:bg-accent hover:text-accent-foreground", |
||||||
|
link: |
||||||
|
"text-primary underline-offset-4 hover:underline", |
||||||
|
}, |
||||||
|
size: { |
||||||
|
default: "h-10 px-4 py-2", |
||||||
|
sm: "h-9 rounded-lg px-3 text-xs", |
||||||
|
lg: "h-11 rounded-xl px-6", |
||||||
|
icon: "h-10 w-10", |
||||||
|
}, |
||||||
|
}, |
||||||
|
defaultVariants: { |
||||||
|
variant: "default", |
||||||
|
size: "default", |
||||||
|
}, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
export interface ButtonProps |
||||||
|
extends ButtonHTMLAttributes<HTMLButtonElement>, |
||||||
|
VariantProps<typeof buttonVariants> {} |
||||||
|
|
||||||
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>( |
||||||
|
({ className, variant, size, ...props }, ref) => ( |
||||||
|
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> |
||||||
|
) |
||||||
|
); |
||||||
|
Button.displayName = "Button"; |
||||||
|
|
||||||
|
export { Button, buttonVariants }; |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
import { type HTMLAttributes, forwardRef } from "react"; |
||||||
|
import { cn } from "@/lib/utils"; |
||||||
|
|
||||||
|
const Card = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
||||||
|
({ className, ...props }, ref) => ( |
||||||
|
<div |
||||||
|
ref={ref} |
||||||
|
className={cn( |
||||||
|
"rounded-2xl border bg-card text-card-foreground shadow-sm glow-card", |
||||||
|
className |
||||||
|
)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
); |
||||||
|
Card.displayName = "Card"; |
||||||
|
|
||||||
|
const CardHeader = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
||||||
|
({ className, ...props }, ref) => ( |
||||||
|
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-5 pb-3", className)} {...props} /> |
||||||
|
) |
||||||
|
); |
||||||
|
CardHeader.displayName = "CardHeader"; |
||||||
|
|
||||||
|
const CardTitle = forwardRef<HTMLHeadingElement, HTMLAttributes<HTMLHeadingElement>>( |
||||||
|
({ className, ...props }, ref) => ( |
||||||
|
<h3 |
||||||
|
ref={ref} |
||||||
|
className={cn("text-base font-semibold leading-none tracking-tight", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
); |
||||||
|
CardTitle.displayName = "CardTitle"; |
||||||
|
|
||||||
|
const CardContent = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>( |
||||||
|
({ className, ...props }, ref) => ( |
||||||
|
<div ref={ref} className={cn("p-5 pt-0", className)} {...props} /> |
||||||
|
) |
||||||
|
); |
||||||
|
CardContent.displayName = "CardContent"; |
||||||
|
|
||||||
|
export { Card, CardHeader, CardTitle, CardContent }; |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
import { type InputHTMLAttributes, forwardRef } from "react"; |
||||||
|
import { cn } from "@/lib/utils"; |
||||||
|
|
||||||
|
const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>( |
||||||
|
({ className, type, ...props }, ref) => ( |
||||||
|
<input |
||||||
|
type={type} |
||||||
|
className={cn( |
||||||
|
"flex h-10 w-full rounded-xl border border-input bg-background px-3.5 py-2 text-sm transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary/40 disabled:cursor-not-allowed disabled:opacity-50", |
||||||
|
className |
||||||
|
)} |
||||||
|
ref={ref} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
); |
||||||
|
Input.displayName = "Input"; |
||||||
|
|
||||||
|
export { Input }; |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
import { type LabelHTMLAttributes, forwardRef } from "react"; |
||||||
|
import { cn } from "@/lib/utils"; |
||||||
|
|
||||||
|
const Label = forwardRef<HTMLLabelElement, LabelHTMLAttributes<HTMLLabelElement>>( |
||||||
|
({ className, ...props }, ref) => ( |
||||||
|
<label |
||||||
|
ref={ref} |
||||||
|
className={cn("text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", className)} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
); |
||||||
|
Label.displayName = "Label"; |
||||||
|
|
||||||
|
export { Label }; |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
import { type TextareaHTMLAttributes, forwardRef } from "react"; |
||||||
|
import { cn } from "@/lib/utils"; |
||||||
|
|
||||||
|
const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>( |
||||||
|
({ className, ...props }, ref) => ( |
||||||
|
<textarea |
||||||
|
className={cn( |
||||||
|
"flex min-h-[80px] w-full rounded-xl border border-input bg-background px-3.5 py-3 text-sm transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:border-primary/40 disabled:cursor-not-allowed disabled:opacity-50 resize-none", |
||||||
|
className |
||||||
|
)} |
||||||
|
ref={ref} |
||||||
|
{...props} |
||||||
|
/> |
||||||
|
) |
||||||
|
); |
||||||
|
Textarea.displayName = "Textarea"; |
||||||
|
|
||||||
|
export { Textarea }; |
||||||
@ -0,0 +1,68 @@ |
|||||||
|
import { |
||||||
|
createContext, |
||||||
|
useCallback, |
||||||
|
useContext, |
||||||
|
useEffect, |
||||||
|
useMemo, |
||||||
|
useState, |
||||||
|
type ReactNode, |
||||||
|
} from "react"; |
||||||
|
import { API_BASE, apiFetch } from "@/lib/utils"; |
||||||
|
|
||||||
|
export type UserRole = "TUTOR" | "STUDENT"; |
||||||
|
|
||||||
|
export interface AuthUser { |
||||||
|
id: number; |
||||||
|
username: string; |
||||||
|
role: UserRole; |
||||||
|
displayName: string | null; |
||||||
|
} |
||||||
|
|
||||||
|
interface AuthState { |
||||||
|
user: AuthUser | null; |
||||||
|
loading: boolean; |
||||||
|
refresh: () => Promise<void>; |
||||||
|
logout: () => Promise<void>; |
||||||
|
setUser: (u: AuthUser | null) => void; |
||||||
|
} |
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null); |
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) { |
||||||
|
const [user, setUser] = useState<AuthUser | null>(null); |
||||||
|
const [loading, setLoading] = useState(true); |
||||||
|
|
||||||
|
const refresh = useCallback(async () => { |
||||||
|
try { |
||||||
|
const data = await apiFetch<{ user: AuthUser }>("/auth/me", { skipAuthRedirect: true }); |
||||||
|
setUser(data.user); |
||||||
|
} catch { |
||||||
|
setUser(null); |
||||||
|
} finally { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
}, []); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
refresh(); |
||||||
|
}, [refresh]); |
||||||
|
|
||||||
|
const logout = useCallback(async () => { |
||||||
|
await fetch(`${API_BASE}/auth/logout`, { method: "POST", credentials: "include" }); |
||||||
|
setUser(null); |
||||||
|
window.location.assign("/login"); |
||||||
|
}, []); |
||||||
|
|
||||||
|
const value = useMemo( |
||||||
|
() => ({ user, loading, refresh, logout, setUser }), |
||||||
|
[user, loading, refresh, logout] |
||||||
|
); |
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; |
||||||
|
} |
||||||
|
|
||||||
|
export function useAuth(): AuthState { |
||||||
|
const ctx = useContext(AuthContext); |
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider"); |
||||||
|
return ctx; |
||||||
|
} |
||||||
@ -0,0 +1,224 @@ |
|||||||
|
@import "tailwindcss"; |
||||||
|
|
||||||
|
@theme { |
||||||
|
--color-background: #ffffff; |
||||||
|
--color-foreground: #0f172a; |
||||||
|
--color-primary: #4f46e5; |
||||||
|
--color-primary-foreground: #ffffff; |
||||||
|
--color-secondary: #f8fafc; |
||||||
|
--color-secondary-foreground: #1e293b; |
||||||
|
--color-muted: #f1f5f9; |
||||||
|
--color-muted-foreground: #64748b; |
||||||
|
--color-accent: #eef2ff; |
||||||
|
--color-accent-foreground: #3730a3; |
||||||
|
--color-destructive: #ef4444; |
||||||
|
--color-destructive-foreground: #ffffff; |
||||||
|
--color-border: #e2e8f0; |
||||||
|
--color-input: #e2e8f0; |
||||||
|
--color-ring: #4f46e5; |
||||||
|
--color-card: #ffffff; |
||||||
|
--color-card-foreground: #0f172a; |
||||||
|
--color-sidebar: #fafbfc; |
||||||
|
--color-sidebar-active: #eef2ff; |
||||||
|
--color-glow: rgba(79, 70, 229, 0.15); |
||||||
|
--color-glow-strong: rgba(79, 70, 229, 0.25); |
||||||
|
--radius-sm: 0.375rem; |
||||||
|
--radius-md: 0.5rem; |
||||||
|
--radius-lg: 0.75rem; |
||||||
|
--radius-xl: 1rem; |
||||||
|
--radius-2xl: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes fade-in { |
||||||
|
from { opacity: 0; transform: translateY(8px); } |
||||||
|
to { opacity: 1; transform: translateY(0); } |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes slide-in-left { |
||||||
|
from { opacity: 0; transform: translateX(-12px); } |
||||||
|
to { opacity: 1; transform: translateX(0); } |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes pulse-glow { |
||||||
|
0%, 100% { box-shadow: 0 0 15px var(--color-glow); } |
||||||
|
50% { box-shadow: 0 0 30px var(--color-glow-strong); } |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes typing-dot { |
||||||
|
0%, 60%, 100% { opacity: 0.3; transform: scale(0.8); } |
||||||
|
30% { opacity: 1; transform: scale(1); } |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes shimmer { |
||||||
|
0% { background-position: -200% 0; } |
||||||
|
100% { background-position: 200% 0; } |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes float { |
||||||
|
0%, 100% { transform: translateY(0); } |
||||||
|
50% { transform: translateY(-4px); } |
||||||
|
} |
||||||
|
|
||||||
|
* { |
||||||
|
border-color: var(--color-border); |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; |
||||||
|
background-color: var(--color-background); |
||||||
|
color: var(--color-foreground); |
||||||
|
-webkit-font-smoothing: antialiased; |
||||||
|
-moz-osx-font-smoothing: grayscale; |
||||||
|
letter-spacing: -0.011em; |
||||||
|
} |
||||||
|
|
||||||
|
.animate-fade-in { |
||||||
|
animation: fade-in 0.4s ease-out both; |
||||||
|
} |
||||||
|
|
||||||
|
.animate-slide-in { |
||||||
|
animation: slide-in-left 0.3s ease-out both; |
||||||
|
} |
||||||
|
|
||||||
|
.animate-pulse-glow { |
||||||
|
animation: pulse-glow 2.5s ease-in-out infinite; |
||||||
|
} |
||||||
|
|
||||||
|
.animate-float { |
||||||
|
animation: float 3s ease-in-out infinite; |
||||||
|
} |
||||||
|
|
||||||
|
.glow-card { |
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); |
||||||
|
} |
||||||
|
|
||||||
|
.glow-card:hover { |
||||||
|
box-shadow: |
||||||
|
0 0 0 1px rgba(79, 70, 229, 0.08), |
||||||
|
0 4px 20px rgba(79, 70, 229, 0.08), |
||||||
|
0 1px 3px rgba(0, 0, 0, 0.04); |
||||||
|
transform: translateY(-1px); |
||||||
|
} |
||||||
|
|
||||||
|
.glass { |
||||||
|
background: rgba(255, 255, 255, 0.85); |
||||||
|
backdrop-filter: blur(12px); |
||||||
|
-webkit-backdrop-filter: blur(12px); |
||||||
|
} |
||||||
|
|
||||||
|
.typing-indicator span { |
||||||
|
display: inline-block; |
||||||
|
width: 6px; |
||||||
|
height: 6px; |
||||||
|
border-radius: 50%; |
||||||
|
background: var(--color-primary); |
||||||
|
margin: 0 2px; |
||||||
|
} |
||||||
|
|
||||||
|
.typing-indicator span:nth-child(1) { animation: typing-dot 1.4s ease-in-out infinite 0s; } |
||||||
|
.typing-indicator span:nth-child(2) { animation: typing-dot 1.4s ease-in-out infinite 0.2s; } |
||||||
|
.typing-indicator span:nth-child(3) { animation: typing-dot 1.4s ease-in-out infinite 0.4s; } |
||||||
|
|
||||||
|
.shimmer { |
||||||
|
background: linear-gradient(90deg, transparent 0%, rgba(79, 70, 229, 0.04) 50%, transparent 100%); |
||||||
|
background-size: 200% 100%; |
||||||
|
animation: shimmer 2s ease-in-out infinite; |
||||||
|
} |
||||||
|
|
||||||
|
.prose h1, .prose h2, .prose h3, .prose h4 { |
||||||
|
color: var(--color-foreground); |
||||||
|
font-weight: 600; |
||||||
|
letter-spacing: -0.02em; |
||||||
|
} |
||||||
|
|
||||||
|
.prose p { |
||||||
|
line-height: 1.7; |
||||||
|
color: #374151; |
||||||
|
} |
||||||
|
|
||||||
|
.prose code { |
||||||
|
background: var(--color-muted); |
||||||
|
padding: 2px 6px; |
||||||
|
border-radius: 4px; |
||||||
|
font-size: 0.875em; |
||||||
|
color: var(--color-primary); |
||||||
|
} |
||||||
|
|
||||||
|
.prose pre { |
||||||
|
background: #1e293b; |
||||||
|
color: #e2e8f0; |
||||||
|
border-radius: 0.75rem; |
||||||
|
padding: 1rem; |
||||||
|
overflow-x: auto; |
||||||
|
} |
||||||
|
|
||||||
|
.prose table { |
||||||
|
width: 100%; |
||||||
|
border-collapse: collapse; |
||||||
|
margin: 1em 0; |
||||||
|
font-size: 0.85em; |
||||||
|
display: table; |
||||||
|
overflow-x: auto; |
||||||
|
border-radius: 8px; |
||||||
|
border: 1px solid var(--color-border); |
||||||
|
} |
||||||
|
|
||||||
|
.prose thead { |
||||||
|
background: var(--color-muted); |
||||||
|
} |
||||||
|
|
||||||
|
.prose th, .prose td { |
||||||
|
border: 1px solid var(--color-border); |
||||||
|
padding: 10px 14px; |
||||||
|
text-align: left; |
||||||
|
vertical-align: top; |
||||||
|
line-height: 1.5; |
||||||
|
word-break: break-word; |
||||||
|
} |
||||||
|
|
||||||
|
.prose th { |
||||||
|
font-weight: 600; |
||||||
|
color: var(--color-foreground); |
||||||
|
white-space: nowrap; |
||||||
|
} |
||||||
|
|
||||||
|
.prose td code { |
||||||
|
font-size: 0.82em; |
||||||
|
white-space: pre-wrap; |
||||||
|
} |
||||||
|
|
||||||
|
.prose tr:nth-child(even) { |
||||||
|
background: var(--color-secondary); |
||||||
|
} |
||||||
|
|
||||||
|
.prose tr:hover { |
||||||
|
background: var(--color-accent); |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes spin-glow { |
||||||
|
0% { transform: rotate(0deg); filter: drop-shadow(0 0 3px var(--color-glow)); } |
||||||
|
50% { filter: drop-shadow(0 0 8px var(--color-glow-strong)); } |
||||||
|
100% { transform: rotate(360deg); filter: drop-shadow(0 0 3px var(--color-glow)); } |
||||||
|
} |
||||||
|
|
||||||
|
.animate-spin-glow { |
||||||
|
animation: spin-glow 2s linear infinite; |
||||||
|
} |
||||||
|
|
||||||
|
::-webkit-scrollbar { |
||||||
|
width: 6px; |
||||||
|
} |
||||||
|
|
||||||
|
::-webkit-scrollbar-track { |
||||||
|
background: transparent; |
||||||
|
} |
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb { |
||||||
|
background: #cbd5e1; |
||||||
|
border-radius: 3px; |
||||||
|
} |
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover { |
||||||
|
background: #94a3b8; |
||||||
|
} |
||||||
@ -0,0 +1,52 @@ |
|||||||
|
import { type ClassValue, clsx } from "clsx"; |
||||||
|
import { twMerge } from "tailwind-merge"; |
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) { |
||||||
|
return twMerge(clsx(inputs)); |
||||||
|
} |
||||||
|
|
||||||
|
export const API_BASE = "/api"; |
||||||
|
|
||||||
|
export type ApiFetchOptions = RequestInit & { |
||||||
|
skipAuthRedirect?: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export async function apiFetch<T>(path: string, options?: ApiFetchOptions): Promise<T> { |
||||||
|
const { skipAuthRedirect, ...init } = options || {}; |
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { |
||||||
|
credentials: "include", |
||||||
|
headers: { "Content-Type": "application/json", ...(init.headers || {}) }, |
||||||
|
...init, |
||||||
|
}); |
||||||
|
|
||||||
|
if (res.status === 401) { |
||||||
|
if ( |
||||||
|
typeof window !== "undefined" && |
||||||
|
!window.location.pathname.startsWith("/login") && |
||||||
|
!skipAuthRedirect |
||||||
|
) { |
||||||
|
window.location.assign("/login"); |
||||||
|
} |
||||||
|
const err = await res.json().catch(() => ({ error: "Network error" })); |
||||||
|
throw new Error(err.error || `HTTP ${res.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
if (!res.ok) { |
||||||
|
const err = await res.json().catch(() => ({ error: "Network error" })); |
||||||
|
throw new Error(err.error || `HTTP ${res.status}`); |
||||||
|
} |
||||||
|
|
||||||
|
return res.json(); |
||||||
|
} |
||||||
|
|
||||||
|
export function getGreeting(): string { |
||||||
|
const hour = new Date().getHours(); |
||||||
|
if (hour < 12) return "Доброе утро"; |
||||||
|
if (hour < 18) return "Добрый день"; |
||||||
|
return "Добрый вечер"; |
||||||
|
} |
||||||
|
|
||||||
|
export function todayDate(): string { |
||||||
|
return new Date().toISOString().split("T")[0]; |
||||||
|
} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
import { StrictMode } from 'react' |
||||||
|
import { createRoot } from 'react-dom/client' |
||||||
|
import './index.css' |
||||||
|
import App from './App.tsx' |
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render( |
||||||
|
<StrictMode> |
||||||
|
<App /> |
||||||
|
</StrictMode>, |
||||||
|
) |
||||||
@ -0,0 +1,342 @@ |
|||||||
|
import { useState, useEffect } from "react"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { Card, CardContent } from "@/components/ui/card"; |
||||||
|
import { Search, HelpCircle, BookOpen, Image as ImageIcon, ChevronDown, Trash2 } from "lucide-react"; |
||||||
|
import { apiFetch } from "@/lib/utils"; |
||||||
|
import { useAuth } from "@/context/AuthContext"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import Markdown from "@/components/Markdown"; |
||||||
|
|
||||||
|
type Tab = "questions" | "textbooks" | "hall"; |
||||||
|
|
||||||
|
interface Question { |
||||||
|
id: number; |
||||||
|
text: string; |
||||||
|
answer: string | null; |
||||||
|
date: string; |
||||||
|
createdAt: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface Textbook { |
||||||
|
id: number; |
||||||
|
topic: string; |
||||||
|
content: string; |
||||||
|
createdAt: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface HallPhoto { |
||||||
|
id: number; |
||||||
|
date: string; |
||||||
|
imageUrl: string; |
||||||
|
createdAt: string; |
||||||
|
fileName: string; |
||||||
|
displayName: string; |
||||||
|
} |
||||||
|
|
||||||
|
function groupByDate<T extends { date?: string; createdAt: string }>( |
||||||
|
items: T[], |
||||||
|
dateKey: "date" | "createdAt" = "createdAt" |
||||||
|
): Record<string, T[]> { |
||||||
|
const groups: Record<string, T[]> = {}; |
||||||
|
for (const item of items) { |
||||||
|
const d = dateKey === "date" && "date" in item && item.date |
||||||
|
? item.date |
||||||
|
: item.createdAt.split("T")[0]; |
||||||
|
if (!groups[d]) groups[d] = []; |
||||||
|
groups[d].push(item); |
||||||
|
} |
||||||
|
return groups; |
||||||
|
} |
||||||
|
|
||||||
|
function formatDate(d: string): string { |
||||||
|
const date = new Date(d); |
||||||
|
const today = new Date(); |
||||||
|
const yesterday = new Date(); |
||||||
|
yesterday.setDate(yesterday.getDate() - 1); |
||||||
|
|
||||||
|
if (d === today.toISOString().split("T")[0]) return "Сегодня"; |
||||||
|
if (d === yesterday.toISOString().split("T")[0]) return "Вчера"; |
||||||
|
return date.toLocaleDateString("ru", { day: "numeric", month: "long", year: "numeric" }); |
||||||
|
} |
||||||
|
|
||||||
|
export default function ArchivePage() { |
||||||
|
const { user } = useAuth(); |
||||||
|
const canDeleteQuestions = user?.role === "STUDENT"; |
||||||
|
const canManageHallPhotos = user?.username?.toLowerCase() === "konstantin"; |
||||||
|
const [tab, setTab] = useState<Tab>("questions"); |
||||||
|
const [search, setSearch] = useState(""); |
||||||
|
const [questions, setQuestions] = useState<Question[]>([]); |
||||||
|
const [textbooks, setTextbooks] = useState<Textbook[]>([]); |
||||||
|
const [hall, setHall] = useState<HallPhoto[]>([]); |
||||||
|
const [expandedTextbook, setExpandedTextbook] = useState<number | null>(null); |
||||||
|
const [expandedQuestion, setExpandedQuestion] = useState<number | null>(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
loadAll(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
async function loadAll() { |
||||||
|
try { setQuestions(await apiFetch<Question[]>("/questions")); } catch {} |
||||||
|
try { setTextbooks(await apiFetch<Textbook[]>("/textbooks")); } catch {} |
||||||
|
try { setHall(await apiFetch<HallPhoto[]>("/reports")); } catch {} |
||||||
|
} |
||||||
|
|
||||||
|
async function deleteQuestion(id: number) { |
||||||
|
if (!canDeleteQuestions || !confirm("Удалить этот вопрос безвозвратно?")) return; |
||||||
|
try { |
||||||
|
await apiFetch(`/questions/${id}`, { method: "DELETE" }); |
||||||
|
setQuestions((prev) => prev.filter((q) => q.id !== id)); |
||||||
|
} catch (err: unknown) { |
||||||
|
alert(err instanceof Error ? err.message : "Ошибка"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function deleteHallPhoto(id: number) { |
||||||
|
if (!canManageHallPhotos || !confirm("Удалить это фото?")) return; |
||||||
|
try { |
||||||
|
await apiFetch(`/reports/${id}`, { method: "DELETE" }); |
||||||
|
setHall((prev) => prev.filter((h) => h.id !== id)); |
||||||
|
} catch (err: unknown) { |
||||||
|
alert(err instanceof Error ? err.message : "Ошибка"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const tabs: { key: Tab; label: string; icon: typeof HelpCircle; count: number }[] = [ |
||||||
|
{ key: "questions", label: "Вопросы", icon: HelpCircle, count: questions.length }, |
||||||
|
{ key: "textbooks", label: "Учебники", icon: BookOpen, count: textbooks.length }, |
||||||
|
{ key: "hall", label: "Зал", icon: ImageIcon, count: hall.length }, |
||||||
|
]; |
||||||
|
|
||||||
|
const filteredQuestions = questions.filter( |
||||||
|
(q) => q.text.toLowerCase().includes(search.toLowerCase()) || |
||||||
|
(q.answer && q.answer.toLowerCase().includes(search.toLowerCase())) |
||||||
|
); |
||||||
|
|
||||||
|
const filteredTextbooks = textbooks.filter( |
||||||
|
(t) => t.topic.toLowerCase().includes(search.toLowerCase()) || |
||||||
|
t.content.toLowerCase().includes(search.toLowerCase()) |
||||||
|
); |
||||||
|
|
||||||
|
const filteredHall = hall.filter((r) => |
||||||
|
r.date.toLowerCase().includes(search.toLowerCase()) |
||||||
|
); |
||||||
|
|
||||||
|
const questionGroups = groupByDate(filteredQuestions, "date"); |
||||||
|
const textbookGroups = groupByDate(filteredTextbooks); |
||||||
|
const hallGroups = groupByDate(filteredHall, "date"); |
||||||
|
|
||||||
|
const sortedDates = (groups: Record<string, unknown[]>) => |
||||||
|
Object.keys(groups).sort((a, b) => b.localeCompare(a)); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-5"> |
||||||
|
<div> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Архив</h1> |
||||||
|
<p className="text-sm text-muted-foreground mt-1">Все материалы, сгруппированные по дням</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Tabs */} |
||||||
|
<div className="flex gap-1 p-1 bg-muted rounded-xl"> |
||||||
|
{tabs.map(({ key, label, icon: Icon, count }) => ( |
||||||
|
<button |
||||||
|
key={key} |
||||||
|
onClick={() => { setTab(key); setSearch(""); }} |
||||||
|
className={`flex-1 flex items-center justify-center gap-2 py-2 rounded-lg text-xs font-medium transition-all duration-200 cursor-pointer ${ |
||||||
|
tab === key |
||||||
|
? "bg-background text-foreground shadow-sm" |
||||||
|
: "text-muted-foreground hover:text-foreground" |
||||||
|
}`}
|
||||||
|
> |
||||||
|
<Icon size={14} /> |
||||||
|
{label} |
||||||
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${ |
||||||
|
tab === key ? "bg-primary/10 text-primary" : "bg-muted-foreground/10" |
||||||
|
}`}>
|
||||||
|
{count} |
||||||
|
</span> |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Search */} |
||||||
|
<div className="relative"> |
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /> |
||||||
|
<Input |
||||||
|
value={search} |
||||||
|
onChange={(e) => setSearch(e.target.value)} |
||||||
|
placeholder={`Поиск по ${tab === "questions" ? "вопросам" : tab === "textbooks" ? "учебникам" : "фото"}...`} |
||||||
|
className="pl-9" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Questions */} |
||||||
|
{tab === "questions" && ( |
||||||
|
<div className="space-y-5"> |
||||||
|
{sortedDates(questionGroups).map((date) => ( |
||||||
|
<div key={date} className="animate-fade-in"> |
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2"> |
||||||
|
{formatDate(date)} |
||||||
|
<span className="ml-2 text-[10px] opacity-60">({questionGroups[date].length})</span> |
||||||
|
</h3> |
||||||
|
<div className="space-y-2"> |
||||||
|
{questionGroups[date].map((q) => ( |
||||||
|
<Card key={q.id}> |
||||||
|
<CardContent className="pt-4 pb-4"> |
||||||
|
<div className="flex items-start gap-1"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
onClick={() => setExpandedQuestion(expandedQuestion === q.id ? null : q.id)} |
||||||
|
className="flex-1 min-w-0 text-left flex items-start justify-between gap-2 cursor-pointer" |
||||||
|
> |
||||||
|
<div className="flex-1 min-w-0"> |
||||||
|
<p className="text-sm font-medium">{q.text}</p> |
||||||
|
{q.answer && expandedQuestion !== q.id && ( |
||||||
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{q.answer.slice(0, 100)}...</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{q.answer && ( |
||||||
|
<ChevronDown |
||||||
|
size={15} |
||||||
|
className={`text-muted-foreground shrink-0 mt-0.5 transition-transform duration-200 ${ |
||||||
|
expandedQuestion === q.id ? "rotate-180" : "" |
||||||
|
}`}
|
||||||
|
/> |
||||||
|
)} |
||||||
|
</button> |
||||||
|
{canDeleteQuestions && ( |
||||||
|
<Button |
||||||
|
variant="ghost" |
||||||
|
size="icon" |
||||||
|
className="h-8 w-8 shrink-0 text-destructive" |
||||||
|
title="Удалить вопрос" |
||||||
|
type="button" |
||||||
|
onClick={() => deleteQuestion(q.id)} |
||||||
|
> |
||||||
|
<Trash2 size={14} /> |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
{expandedQuestion === q.id && q.answer && ( |
||||||
|
<div className="mt-3 pt-3 border-t animate-fade-in"> |
||||||
|
<Markdown className="text-[13px]">{q.answer}</Markdown> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
{filteredQuestions.length === 0 && ( |
||||||
|
<p className="text-center text-sm text-muted-foreground py-12"> |
||||||
|
{search ? "Ничего не найдено" : "Нет вопросов"} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Textbooks */} |
||||||
|
{tab === "textbooks" && ( |
||||||
|
<div className="space-y-5"> |
||||||
|
{sortedDates(textbookGroups).map((date) => ( |
||||||
|
<div key={date} className="animate-fade-in"> |
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2"> |
||||||
|
{formatDate(date)} |
||||||
|
</h3> |
||||||
|
<div className="space-y-2"> |
||||||
|
{textbookGroups[date].map((tb) => ( |
||||||
|
<Card key={tb.id}> |
||||||
|
<CardContent className="pt-4 pb-4"> |
||||||
|
<button |
||||||
|
onClick={() => setExpandedTextbook(expandedTextbook === tb.id ? null : tb.id)} |
||||||
|
className="w-full text-left flex items-center justify-between cursor-pointer" |
||||||
|
> |
||||||
|
<span className="text-sm font-medium">{tb.topic}</span> |
||||||
|
<ChevronDown |
||||||
|
size={15} |
||||||
|
className={`text-muted-foreground shrink-0 transition-transform duration-200 ${ |
||||||
|
expandedTextbook === tb.id ? "rotate-180" : "" |
||||||
|
}`}
|
||||||
|
/> |
||||||
|
</button> |
||||||
|
{expandedTextbook === tb.id && ( |
||||||
|
<div className="mt-3 pt-3 border-t animate-fade-in"> |
||||||
|
<Markdown className="text-[13px]">{tb.content}</Markdown> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
{filteredTextbooks.length === 0 && ( |
||||||
|
<p className="text-center text-sm text-muted-foreground py-12"> |
||||||
|
{search ? "Ничего не найдено" : "Нет учебников"} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Hall */} |
||||||
|
{tab === "hall" && ( |
||||||
|
<div className="space-y-5"> |
||||||
|
{sortedDates(hallGroups).map((date) => ( |
||||||
|
<div key={date} className="animate-fade-in"> |
||||||
|
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2"> |
||||||
|
{formatDate(date)} |
||||||
|
</h3> |
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3"> |
||||||
|
{hallGroups[date].map((r) => { |
||||||
|
const label = r.displayName || r.fileName; |
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={r.id} |
||||||
|
className="relative flex flex-col rounded-xl overflow-hidden border bg-accent/20 hover:shadow-sm transition" |
||||||
|
> |
||||||
|
<div className="relative"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="block w-full" |
||||||
|
onClick={() => window.open(r.imageUrl, "_blank", "noopener,noreferrer")} |
||||||
|
> |
||||||
|
<img src={r.imageUrl} alt={label} className="w-full h-36 object-cover" /> |
||||||
|
</button> |
||||||
|
{canManageHallPhotos && ( |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="destructive" |
||||||
|
size="icon" |
||||||
|
className="absolute top-2 right-2 h-8 w-8 opacity-90 shadow-md" |
||||||
|
title="Удалить" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation(); |
||||||
|
deleteHallPhoto(r.id); |
||||||
|
}} |
||||||
|
> |
||||||
|
<Trash2 size={14} /> |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<p |
||||||
|
className="text-[11px] px-2 py-1.5 text-muted-foreground truncate border-t bg-background/95" |
||||||
|
title={label} |
||||||
|
> |
||||||
|
{label} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
{filteredHall.length === 0 && ( |
||||||
|
<p className="text-center text-sm text-muted-foreground py-12"> |
||||||
|
{search ? "Ничего не найдено" : "Нет фото"} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,201 @@ |
|||||||
|
import { useState, useEffect, useRef } from "react"; |
||||||
|
import { Send, Trash2, Sparkles } from "lucide-react"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { getGreeting, apiFetch, API_BASE } from "@/lib/utils"; |
||||||
|
import { useAuth } from "@/context/AuthContext"; |
||||||
|
import Markdown from "@/components/Markdown"; |
||||||
|
|
||||||
|
interface ChatMsg { |
||||||
|
id?: number; |
||||||
|
role: "user" | "assistant"; |
||||||
|
content: string; |
||||||
|
} |
||||||
|
|
||||||
|
export default function HomePage() { |
||||||
|
const { user } = useAuth(); |
||||||
|
const [messages, setMessages] = useState<ChatMsg[]>([]); |
||||||
|
const [input, setInput] = useState(""); |
||||||
|
const [loading, setLoading] = useState(false); |
||||||
|
const bottomRef = useRef<HTMLDivElement>(null); |
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
apiFetch<ChatMsg[]>("/chat/history").then(setMessages).catch(() => {}); |
||||||
|
}, []); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" }); |
||||||
|
}, [messages]); |
||||||
|
|
||||||
|
function autoResize() { |
||||||
|
const el = inputRef.current; |
||||||
|
if (el) { |
||||||
|
el.style.height = "auto"; |
||||||
|
el.style.height = Math.min(el.scrollHeight, 150) + "px"; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function sendMessage() { |
||||||
|
if (!input.trim() || loading) return; |
||||||
|
const text = input.trim(); |
||||||
|
setInput(""); |
||||||
|
if (inputRef.current) inputRef.current.style.height = "auto"; |
||||||
|
setMessages((prev) => [...prev, { role: "user", content: text }]); |
||||||
|
setLoading(true); |
||||||
|
|
||||||
|
try { |
||||||
|
const res = await fetch(`${API_BASE}/chat`, { |
||||||
|
method: "POST", |
||||||
|
credentials: "include", |
||||||
|
headers: { "Content-Type": "application/json" }, |
||||||
|
body: JSON.stringify({ message: text }), |
||||||
|
}); |
||||||
|
|
||||||
|
const reader = res.body?.getReader(); |
||||||
|
const decoder = new TextDecoder(); |
||||||
|
let assistantContent = ""; |
||||||
|
setMessages((prev) => [...prev, { role: "assistant", content: "" }]); |
||||||
|
|
||||||
|
while (reader) { |
||||||
|
const { done, value } = await reader.read(); |
||||||
|
if (done) break; |
||||||
|
const chunk = decoder.decode(value); |
||||||
|
const lines = chunk.split("\n").filter((l) => l.startsWith("data: ")); |
||||||
|
|
||||||
|
for (const line of lines) { |
||||||
|
const data = line.slice(6); |
||||||
|
if (data === "[DONE]") break; |
||||||
|
try { |
||||||
|
const parsed = JSON.parse(data); |
||||||
|
if (parsed.content) { |
||||||
|
assistantContent += parsed.content; |
||||||
|
setMessages((prev) => { |
||||||
|
const updated = [...prev]; |
||||||
|
updated[updated.length - 1] = { role: "assistant", content: assistantContent }; |
||||||
|
return updated; |
||||||
|
}); |
||||||
|
} |
||||||
|
} catch { /* skip */ } |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err: any) { |
||||||
|
setMessages((prev) => [ |
||||||
|
...prev, |
||||||
|
{ role: "assistant", content: `Ошибка: ${err.message}` }, |
||||||
|
]); |
||||||
|
} |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
|
||||||
|
async function clearHistory() { |
||||||
|
await apiFetch("/chat/history", { method: "DELETE" }); |
||||||
|
setMessages([]); |
||||||
|
} |
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) { |
||||||
|
if (e.key === "Enter" && !e.shiftKey) { |
||||||
|
e.preventDefault(); |
||||||
|
sendMessage(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const isEmpty = messages.length === 0; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="flex flex-col h-[calc(100vh-3rem)]"> |
||||||
|
{/* Header */} |
||||||
|
<div className="flex items-center justify-between mb-2"> |
||||||
|
<div> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight"> |
||||||
|
{getGreeting()} |
||||||
|
{user?.displayName ? `, ${user.displayName}` : user?.username ? `, ${user.username}` : ""} |
||||||
|
</h1> |
||||||
|
<p className="text-sm text-muted-foreground mt-0.5"> |
||||||
|
Чем могу помочь сегодня? |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
{!isEmpty && ( |
||||||
|
<Button variant="ghost" size="sm" onClick={clearHistory} className="text-muted-foreground"> |
||||||
|
<Trash2 size={15} /> |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Messages */} |
||||||
|
<div className="flex-1 overflow-y-auto py-4 space-y-5"> |
||||||
|
{isEmpty && ( |
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center animate-fade-in"> |
||||||
|
<div className="w-14 h-14 rounded-2xl bg-accent flex items-center justify-center mb-5 animate-float"> |
||||||
|
<Sparkles size={26} className="text-primary" /> |
||||||
|
</div> |
||||||
|
<h2 className="text-lg font-semibold mb-2">ИИ-ассистент</h2> |
||||||
|
<p className="text-sm text-muted-foreground max-w-sm leading-relaxed"> |
||||||
|
Задайте любой вопрос — я постараюсь объяснить максимально понятно |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{messages.map((msg, i) => ( |
||||||
|
<div |
||||||
|
key={i} |
||||||
|
className="animate-fade-in" |
||||||
|
style={{ animationDelay: `${Math.min(i * 30, 200)}ms` }} |
||||||
|
> |
||||||
|
{msg.role === "user" ? ( |
||||||
|
<div className="flex justify-end"> |
||||||
|
<div className="max-w-[75%] bg-primary text-primary-foreground rounded-2xl rounded-br-md px-4 py-2.5 text-[14px] leading-relaxed shadow-sm"> |
||||||
|
{msg.content} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) : ( |
||||||
|
<div className="flex gap-3"> |
||||||
|
<div className={`w-7 h-7 rounded-lg bg-accent flex items-center justify-center shrink-0 mt-0.5 ${ |
||||||
|
loading && i === messages.length - 1 ? "" : "" |
||||||
|
}`}>
|
||||||
|
<Sparkles |
||||||
|
size={14} |
||||||
|
className={`text-primary ${loading && i === messages.length - 1 ? "animate-spin-glow" : ""}`} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="flex-1 min-w-0"> |
||||||
|
{msg.content ? ( |
||||||
|
<Markdown>{msg.content}</Markdown> |
||||||
|
) : ( |
||||||
|
<div className="typing-indicator flex items-center gap-1 py-3"> |
||||||
|
<span /><span /><span /> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
))} |
||||||
|
<div ref={bottomRef} /> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* Input */} |
||||||
|
<div className="pt-3 pb-1"> |
||||||
|
<div className="relative flex items-end gap-2 rounded-2xl border bg-background p-2 shadow-sm transition-shadow focus-within:shadow-md focus-within:shadow-primary/5 focus-within:border-primary/30"> |
||||||
|
<textarea |
||||||
|
ref={inputRef} |
||||||
|
value={input} |
||||||
|
onChange={(e) => { setInput(e.target.value); autoResize(); }} |
||||||
|
onKeyDown={handleKeyDown} |
||||||
|
placeholder="Напишите сообщение..." |
||||||
|
disabled={loading} |
||||||
|
rows={1} |
||||||
|
className="flex-1 resize-none bg-transparent px-2 py-1.5 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50 max-h-[150px]" |
||||||
|
/> |
||||||
|
<Button |
||||||
|
onClick={sendMessage} |
||||||
|
disabled={loading || !input.trim()} |
||||||
|
size="icon" |
||||||
|
className="shrink-0 h-8 w-8 rounded-xl" |
||||||
|
> |
||||||
|
<Send size={15} /> |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,98 @@ |
|||||||
|
import { useState, useEffect } from "react"; |
||||||
|
import { useNavigate } from "react-router-dom"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
||||||
|
import { Label } from "@/components/ui/label"; |
||||||
|
import { Loader2, Sparkles } from "lucide-react"; |
||||||
|
import { useAuth } from "@/context/AuthContext"; |
||||||
|
import { API_BASE } from "@/lib/utils"; |
||||||
|
|
||||||
|
export default function LoginPage() { |
||||||
|
const navigate = useNavigate(); |
||||||
|
const { user, loading, setUser } = useAuth(); |
||||||
|
const [username, setUsername] = useState(""); |
||||||
|
const [password, setPassword] = useState(""); |
||||||
|
const [submitting, setSubmitting] = useState(false); |
||||||
|
const [error, setError] = useState(""); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!loading && user) navigate("/", { replace: true }); |
||||||
|
}, [loading, user, navigate]); |
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent) { |
||||||
|
e.preventDefault(); |
||||||
|
setError(""); |
||||||
|
setSubmitting(true); |
||||||
|
try { |
||||||
|
const res = await fetch(`${API_BASE}/auth/login`, { |
||||||
|
method: "POST", |
||||||
|
credentials: "include", |
||||||
|
headers: { "Content-Type": "application/json" }, |
||||||
|
body: JSON.stringify({ username: username.trim(), password }), |
||||||
|
}); |
||||||
|
const data = await res.json().catch(() => ({})); |
||||||
|
if (!res.ok) { |
||||||
|
setError(data.error || `Ошибка ${res.status}`); |
||||||
|
return; |
||||||
|
} |
||||||
|
setUser(data.user); |
||||||
|
navigate("/", { replace: true }); |
||||||
|
} catch { |
||||||
|
setError("Сеть недоступна"); |
||||||
|
} finally { |
||||||
|
setSubmitting(false); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (loading) { |
||||||
|
return ( |
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background"> |
||||||
|
<Loader2 className="animate-spin text-primary" size={28} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="min-h-screen flex items-center justify-center bg-background p-4"> |
||||||
|
<Card className="w-full max-w-md border-border/80 shadow-lg"> |
||||||
|
<CardHeader className="text-center space-y-2"> |
||||||
|
<div className="mx-auto w-10 h-10 rounded-xl bg-primary flex items-center justify-center"> |
||||||
|
<Sparkles size={20} className="text-white" /> |
||||||
|
</div> |
||||||
|
<CardTitle className="text-lg">EduHelper</CardTitle> |
||||||
|
<p className="text-sm text-muted-foreground">Войдите, чтобы продолжить</p> |
||||||
|
</CardHeader> |
||||||
|
<CardContent> |
||||||
|
<form onSubmit={onSubmit} className="space-y-4"> |
||||||
|
<div className="space-y-2"> |
||||||
|
<Label htmlFor="username">Имя пользователя</Label> |
||||||
|
<Input |
||||||
|
id="username" |
||||||
|
autoComplete="username" |
||||||
|
value={username} |
||||||
|
onChange={(e) => setUsername(e.target.value)} |
||||||
|
disabled={submitting} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
<div className="space-y-2"> |
||||||
|
<Label htmlFor="password">Пароль</Label> |
||||||
|
<Input |
||||||
|
id="password" |
||||||
|
type="password" |
||||||
|
autoComplete="current-password" |
||||||
|
value={password} |
||||||
|
onChange={(e) => setPassword(e.target.value)} |
||||||
|
disabled={submitting} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>} |
||||||
|
<Button type="submit" className="w-full" disabled={submitting || !username.trim() || !password}> |
||||||
|
{submitting ? <Loader2 size={16} className="animate-spin" /> : "Войти"} |
||||||
|
</Button> |
||||||
|
</form> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,240 @@ |
|||||||
|
import { useState, useEffect } from "react"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { Card, CardContent } from "@/components/ui/card"; |
||||||
|
import { Loader2, Sparkles, Plus, Send, Search, Trash2 } from "lucide-react"; |
||||||
|
import { apiFetch, todayDate, API_BASE } from "@/lib/utils"; |
||||||
|
import { useAuth } from "@/context/AuthContext"; |
||||||
|
import Markdown from "@/components/Markdown"; |
||||||
|
|
||||||
|
interface Question { |
||||||
|
id: number; |
||||||
|
text: string; |
||||||
|
answer: string | null; |
||||||
|
date: string; |
||||||
|
} |
||||||
|
|
||||||
|
export default function QuestionsPage() { |
||||||
|
const { user } = useAuth(); |
||||||
|
const canDeleteOwn = user?.role === "STUDENT"; |
||||||
|
const [fields, setFields] = useState<string[]>([]); |
||||||
|
const [saved, setSaved] = useState<Question[]>([]); |
||||||
|
const [saving, setSaving] = useState(false); |
||||||
|
const [answeringId, setAnsweringId] = useState<number | null>(null); |
||||||
|
const [search, setSearch] = useState(""); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
loadQuestions(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
async function loadQuestions() { |
||||||
|
try { |
||||||
|
const data = await apiFetch<Question[]>(`/questions?date=${todayDate()}`); |
||||||
|
setSaved(data); |
||||||
|
updateFieldCount(data.length); |
||||||
|
} catch { |
||||||
|
updateFieldCount(0); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function updateFieldCount(savedCount: number) { |
||||||
|
const needed = Math.max(1, 5 - savedCount); |
||||||
|
setFields(Array(needed).fill("")); |
||||||
|
} |
||||||
|
|
||||||
|
function updateField(index: number, value: string) { |
||||||
|
setFields((prev) => { const c = [...prev]; c[index] = value; return c; }); |
||||||
|
} |
||||||
|
|
||||||
|
async function saveQuestions() { |
||||||
|
const nonEmpty = fields.filter((f) => f.trim()); |
||||||
|
if (nonEmpty.length === 0) return; |
||||||
|
|
||||||
|
setSaving(true); |
||||||
|
try { |
||||||
|
const data = await apiFetch<Question[]>("/questions", { |
||||||
|
method: "POST", body: JSON.stringify({ questions: nonEmpty }), |
||||||
|
}); |
||||||
|
const newSaved = [...saved, ...data]; |
||||||
|
setSaved(newSaved); |
||||||
|
updateFieldCount(newSaved.length); |
||||||
|
} catch (err: any) { alert(err.message); } |
||||||
|
setSaving(false); |
||||||
|
} |
||||||
|
|
||||||
|
async function getAnswer(question: Question) { |
||||||
|
if (question.answer || answeringId === question.id) return; |
||||||
|
setAnsweringId(question.id); |
||||||
|
try { |
||||||
|
const res = await fetch(`${API_BASE}/questions/${question.id}/answer`, { |
||||||
|
method: "POST", |
||||||
|
credentials: "include", |
||||||
|
}); |
||||||
|
const reader = res.body?.getReader(); |
||||||
|
const decoder = new TextDecoder(); |
||||||
|
let fullAnswer = ""; |
||||||
|
|
||||||
|
while (reader) { |
||||||
|
const { done, value } = await reader.read(); |
||||||
|
if (done) break; |
||||||
|
for (const line of decoder.decode(value).split("\n").filter((l) => l.startsWith("data: "))) { |
||||||
|
const data = line.slice(6); |
||||||
|
if (data === "[DONE]") break; |
||||||
|
try { |
||||||
|
const parsed = JSON.parse(data); |
||||||
|
if (parsed.content) { |
||||||
|
fullAnswer += parsed.content; |
||||||
|
setSaved((prev) => prev.map((q) => q.id === question.id ? { ...q, answer: fullAnswer } : q)); |
||||||
|
} |
||||||
|
} catch { /* skip */ } |
||||||
|
} |
||||||
|
} |
||||||
|
} catch (err: any) { alert(err.message); } |
||||||
|
setAnsweringId(null); |
||||||
|
} |
||||||
|
|
||||||
|
async function deleteQuestion(id: number) { |
||||||
|
if (!canDeleteOwn || !confirm("Удалить этот вопрос безвозвратно?")) return; |
||||||
|
try { |
||||||
|
await apiFetch(`/questions/${id}`, { method: "DELETE" }); |
||||||
|
const next = saved.filter((q) => q.id !== id); |
||||||
|
setSaved(next); |
||||||
|
updateFieldCount(next.length); |
||||||
|
} catch (err: any) { |
||||||
|
alert(err.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const unanswered = saved.filter((q) => |
||||||
|
!q.answer && (!search || q.text.toLowerCase().includes(search.toLowerCase())) |
||||||
|
); |
||||||
|
const answered = saved.filter((q) => |
||||||
|
q.answer && (!search || q.text.toLowerCase().includes(search.toLowerCase()) || q.answer.toLowerCase().includes(search.toLowerCase())) |
||||||
|
); |
||||||
|
const todaySavedCount = saved.length; |
||||||
|
const remaining = Math.max(0, 5 - todaySavedCount); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-6"> |
||||||
|
<div> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Ежедневные вопросы</h1> |
||||||
|
<p className="text-sm text-muted-foreground mt-1"> |
||||||
|
{remaining > 0 |
||||||
|
? `Ещё ${remaining} из 5 обязательных вопросов` |
||||||
|
: `Норма выполнена (${todaySavedCount} вопросов)`} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
{/* New questions form */} |
||||||
|
<Card> |
||||||
|
<CardContent className="pt-5 space-y-3"> |
||||||
|
{fields.map((val, i) => ( |
||||||
|
<div key={i} className="animate-fade-in" style={{ animationDelay: `${i * 50}ms` }}> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<span className="text-xs font-medium text-muted-foreground w-4 text-right shrink-0"> |
||||||
|
{todaySavedCount + i + 1} |
||||||
|
</span> |
||||||
|
<Input |
||||||
|
value={val} |
||||||
|
onChange={(e) => updateField(i, e.target.value)} |
||||||
|
placeholder={`Вопрос...`} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
<div className="flex gap-2 pt-2 ml-6"> |
||||||
|
<Button variant="outline" size="sm" onClick={() => setFields((p) => [...p, ""])}> |
||||||
|
<Plus size={14} /> |
||||||
|
Ещё |
||||||
|
</Button> |
||||||
|
<Button size="sm" onClick={saveQuestions} disabled={saving || fields.every((f) => !f.trim())}> |
||||||
|
{saving ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />} |
||||||
|
Сохранить |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
|
||||||
|
{/* Search */} |
||||||
|
{saved.length > 0 && ( |
||||||
|
<div className="relative"> |
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /> |
||||||
|
<Input |
||||||
|
value={search} |
||||||
|
onChange={(e) => setSearch(e.target.value)} |
||||||
|
placeholder="Поиск по вопросам..." |
||||||
|
className="pl-9" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Unanswered first */} |
||||||
|
{unanswered.length > 0 && ( |
||||||
|
<div className="space-y-3"> |
||||||
|
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> |
||||||
|
Ожидают ответа ({unanswered.length}) |
||||||
|
</h2> |
||||||
|
{unanswered.map((q, i) => ( |
||||||
|
<Card key={q.id} className="animate-fade-in border-orange-200/60" style={{ animationDelay: `${i * 40}ms` }}> |
||||||
|
<CardContent className="pt-5 space-y-3"> |
||||||
|
<p className="text-[14px] font-medium">{q.text}</p> |
||||||
|
<div className="flex flex-wrap gap-2"> |
||||||
|
<Button |
||||||
|
variant="outline" |
||||||
|
size="sm" |
||||||
|
onClick={() => getAnswer(q)} |
||||||
|
disabled={answeringId === q.id} |
||||||
|
> |
||||||
|
{answeringId === q.id ? ( |
||||||
|
<> |
||||||
|
<Sparkles size={13} className="animate-spin-glow" /> |
||||||
|
Генерация... |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<Sparkles size={13} /> |
||||||
|
Получить ответ |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Button> |
||||||
|
{canDeleteOwn && ( |
||||||
|
<Button variant="ghost" size="sm" className="text-destructive" onClick={() => deleteQuestion(q.id)}> |
||||||
|
<Trash2 size={13} /> |
||||||
|
Удалить |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Answered below */} |
||||||
|
{answered.length > 0 && ( |
||||||
|
<div className="space-y-3"> |
||||||
|
<h2 className="text-xs font-medium text-muted-foreground uppercase tracking-wider"> |
||||||
|
С ответами ({answered.length}) |
||||||
|
</h2> |
||||||
|
{answered.map((q, i) => ( |
||||||
|
<Card key={q.id} className="animate-fade-in" style={{ animationDelay: `${i * 40}ms` }}> |
||||||
|
<CardContent className="pt-5 space-y-3"> |
||||||
|
<div className="flex items-start justify-between gap-2"> |
||||||
|
<p className="text-[14px] font-medium flex-1">{q.text}</p> |
||||||
|
{canDeleteOwn && ( |
||||||
|
<Button variant="ghost" size="icon" className="shrink-0 text-destructive h-8 w-8" onClick={() => deleteQuestion(q.id)} title="Удалить вопрос"> |
||||||
|
<Trash2 size={14} /> |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<div className="bg-accent/50 rounded-xl p-4"> |
||||||
|
<Markdown className="text-[13px]" exportable exportTitle={`question-${q.id}`}>{q.answer!}</Markdown> |
||||||
|
</div> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,196 @@ |
|||||||
|
import { useEffect, useMemo, useRef, useState } from "react"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Card, CardContent } from "@/components/ui/card"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { API_BASE, apiFetch, todayDate } from "@/lib/utils"; |
||||||
|
import { useAuth } from "@/context/AuthContext"; |
||||||
|
import { Calendar, ImagePlus, Loader2, Trash2 } from "lucide-react"; |
||||||
|
|
||||||
|
interface HallPhoto { |
||||||
|
id: number; |
||||||
|
date: string; |
||||||
|
createdAt: string; |
||||||
|
imageUrl: string; |
||||||
|
fileName: string; |
||||||
|
displayName: string; |
||||||
|
} |
||||||
|
|
||||||
|
export default function ReportPage() { |
||||||
|
const { user } = useAuth(); |
||||||
|
const canManagePhotos = |
||||||
|
user?.username?.toLowerCase() === "konstantin"; |
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null); |
||||||
|
const [photos, setPhotos] = useState<HallPhoto[]>([]); |
||||||
|
const [selectedDate, setSelectedDate] = useState(todayDate()); |
||||||
|
const [uploading, setUploading] = useState(false); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
loadPhotos(); |
||||||
|
}, []); |
||||||
|
|
||||||
|
async function loadPhotos() { |
||||||
|
try { |
||||||
|
setPhotos(await apiFetch<HallPhoto[]>("/reports")); |
||||||
|
} catch { |
||||||
|
setPhotos([]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function onFileChange(e: React.ChangeEvent<HTMLInputElement>) { |
||||||
|
const file = e.target.files?.[0]; |
||||||
|
if (!file) return; |
||||||
|
const formData = new FormData(); |
||||||
|
formData.append("photo", file); |
||||||
|
formData.append("date", selectedDate); |
||||||
|
|
||||||
|
setUploading(true); |
||||||
|
try { |
||||||
|
const res = await fetch(`${API_BASE}/reports/upload`, { |
||||||
|
method: "POST", |
||||||
|
credentials: "include", |
||||||
|
body: formData, |
||||||
|
}); |
||||||
|
if (!res.ok) { |
||||||
|
const err = await res.json().catch(() => ({ error: "Ошибка загрузки" })); |
||||||
|
throw new Error(err.error || `HTTP ${res.status}`); |
||||||
|
} |
||||||
|
await loadPhotos(); |
||||||
|
} catch (err: any) { |
||||||
|
alert(err.message); |
||||||
|
} finally { |
||||||
|
setUploading(false); |
||||||
|
e.target.value = ""; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function deletePhoto(id: number) { |
||||||
|
if (!confirm("Удалить это фото?")) return; |
||||||
|
try { |
||||||
|
await apiFetch(`/reports/${id}`, { method: "DELETE" }); |
||||||
|
await loadPhotos(); |
||||||
|
} catch (err: unknown) { |
||||||
|
alert(err instanceof Error ? err.message : "Ошибка"); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const grouped = useMemo(() => { |
||||||
|
const g: Record<string, HallPhoto[]> = {}; |
||||||
|
for (const p of photos) { |
||||||
|
if (!g[p.date]) g[p.date] = []; |
||||||
|
g[p.date].push(p); |
||||||
|
} |
||||||
|
return g; |
||||||
|
}, [photos]); |
||||||
|
|
||||||
|
const sortedDates = Object.keys(grouped).sort((a, b) => b.localeCompare(a)); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-6"> |
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap"> |
||||||
|
<div> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Зал</h1> |
||||||
|
<p className="text-sm text-muted-foreground mt-1"> |
||||||
|
{canManagePhotos |
||||||
|
? "Загружайте фото и просматривайте их по датам" |
||||||
|
: "Просмотр фото ученика по датам"} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
|
||||||
|
{canManagePhotos && ( |
||||||
|
<Card> |
||||||
|
<CardContent className="pt-5 flex flex-wrap items-end gap-3"> |
||||||
|
<div className="space-y-1"> |
||||||
|
<label className="text-xs text-muted-foreground">Дата</label> |
||||||
|
<Input type="date" value={selectedDate} onChange={(e) => setSelectedDate(e.target.value)} /> |
||||||
|
</div> |
||||||
|
<div className="flex flex-col justify-end gap-0"> |
||||||
|
<input |
||||||
|
ref={fileInputRef} |
||||||
|
type="file" |
||||||
|
accept="image/*" |
||||||
|
onChange={onFileChange} |
||||||
|
disabled={uploading} |
||||||
|
className="sr-only" |
||||||
|
tabIndex={-1} |
||||||
|
/> |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
disabled={uploading} |
||||||
|
onClick={() => fileInputRef.current?.click()} |
||||||
|
> |
||||||
|
{uploading ? <Loader2 size={15} className="animate-spin" /> : <ImagePlus size={15} />} |
||||||
|
Прикрепить фото |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
)} |
||||||
|
|
||||||
|
{sortedDates.map((date) => ( |
||||||
|
<div key={date} className="space-y-3"> |
||||||
|
<div className="flex items-center gap-2"> |
||||||
|
<Calendar size={14} className="text-muted-foreground" /> |
||||||
|
<h2 className="text-sm font-medium">{date}</h2> |
||||||
|
<span className="text-xs text-muted-foreground">({grouped[date].length})</span> |
||||||
|
</div> |
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3"> |
||||||
|
{grouped[date].map((p) => { |
||||||
|
const label = p.displayName || p.fileName; |
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={p.id} |
||||||
|
className="relative group flex flex-col rounded-xl overflow-hidden border bg-accent/20 hover:shadow-sm transition" |
||||||
|
> |
||||||
|
<div className="relative"> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="block w-full text-left" |
||||||
|
onClick={() => window.open(p.imageUrl, "_blank", "noopener,noreferrer")} |
||||||
|
> |
||||||
|
<img |
||||||
|
src={p.imageUrl} |
||||||
|
alt={label} |
||||||
|
loading="lazy" |
||||||
|
className="w-full h-40 object-cover group-hover:scale-[1.02] transition" |
||||||
|
/> |
||||||
|
</button> |
||||||
|
{canManagePhotos && ( |
||||||
|
<Button |
||||||
|
type="button" |
||||||
|
variant="destructive" |
||||||
|
size="icon" |
||||||
|
className="absolute top-2 right-2 h-8 w-8 opacity-90 shadow-md" |
||||||
|
onClick={(e) => { |
||||||
|
e.stopPropagation(); |
||||||
|
deletePhoto(p.id); |
||||||
|
}} |
||||||
|
title="Удалить" |
||||||
|
> |
||||||
|
<Trash2 size={14} /> |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<p |
||||||
|
className="text-[11px] px-2 py-1.5 text-muted-foreground truncate border-t bg-background/95" |
||||||
|
title={label} |
||||||
|
> |
||||||
|
{label} |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
|
||||||
|
{photos.length === 0 && ( |
||||||
|
<div className="text-center py-12 text-sm text-muted-foreground"> |
||||||
|
{canManagePhotos |
||||||
|
? "Пока нет фото. Добавьте первое изображение." |
||||||
|
: "Пока нет фото."} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,147 @@ |
|||||||
|
import { useState, useEffect } from "react"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { Textarea } from "@/components/ui/textarea"; |
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
||||||
|
import { Label } from "@/components/ui/label"; |
||||||
|
import { Save, Loader2, CheckCircle2, Key, MessageSquare } from "lucide-react"; |
||||||
|
import { apiFetch } from "@/lib/utils"; |
||||||
|
import { useAuth } from "@/context/AuthContext"; |
||||||
|
import { Navigate } from "react-router-dom"; |
||||||
|
|
||||||
|
const PROMPT_FIELDS = [ |
||||||
|
{ |
||||||
|
key: "prompt_answer", |
||||||
|
label: "Промпт для ответов на вопросы", |
||||||
|
description: "Используется при нажатии «Получить ответ» в разделе вопросов", |
||||||
|
placeholder: |
||||||
|
"Ты — помощник-репетитор. Ответь на вопрос ученика максимально подробно и понятно...", |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "prompt_textbook", |
||||||
|
label: "Промпт для генерации учебника", |
||||||
|
description: "Используется при генерации учебника по теме", |
||||||
|
placeholder: |
||||||
|
"Составь учебный материал по указанной теме. Объясняй просто, с примерами из жизни...", |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "prompt_test", |
||||||
|
label: "Промпт для генерации теста", |
||||||
|
description: "Используется при создании теста из 10 вопросов", |
||||||
|
placeholder: |
||||||
|
"Сгенерируй тест из 10 вопросов с 4 вариантами ответа. Верни JSON-массив...", |
||||||
|
}, |
||||||
|
{ |
||||||
|
key: "prompt_report", |
||||||
|
label: "Промпт для ежедневного отчёта", |
||||||
|
description: "Используется при генерации отчёта за день", |
||||||
|
placeholder: |
||||||
|
'Составь отчёт в формате: "Алексей Михайлович, добрый вечер, за сегодня я узнал..."', |
||||||
|
}, |
||||||
|
]; |
||||||
|
|
||||||
|
export default function SettingsPage() { |
||||||
|
const { user } = useAuth(); |
||||||
|
if (user?.role !== "TUTOR") { |
||||||
|
return <Navigate to="/" replace />; |
||||||
|
} |
||||||
|
|
||||||
|
const [apiKey, setApiKey] = useState(""); |
||||||
|
const [prompts, setPrompts] = useState<Record<string, string>>({}); |
||||||
|
const [saving, setSaving] = useState(false); |
||||||
|
const [saved, setSaved] = useState(false); |
||||||
|
|
||||||
|
useEffect(() => { loadSettings(); }, []); |
||||||
|
|
||||||
|
async function loadSettings() { |
||||||
|
try { |
||||||
|
const data = await apiFetch<Record<string, string>>("/settings/raw"); |
||||||
|
setApiKey(data.deepseek_api_key || ""); |
||||||
|
const p: Record<string, string> = {}; |
||||||
|
for (const f of PROMPT_FIELDS) p[f.key] = data[f.key] || ""; |
||||||
|
setPrompts(p); |
||||||
|
} catch { /* first time */ } |
||||||
|
} |
||||||
|
|
||||||
|
async function saveSettings() { |
||||||
|
setSaving(true); |
||||||
|
setSaved(false); |
||||||
|
const settings: Record<string, string> = { deepseek_api_key: apiKey }; |
||||||
|
for (const [k, v] of Object.entries(prompts)) { |
||||||
|
if (v.trim()) settings[k] = v.trim(); |
||||||
|
} |
||||||
|
try { |
||||||
|
await apiFetch("/settings", { method: "PUT", body: JSON.stringify(settings) }); |
||||||
|
setSaved(true); |
||||||
|
setTimeout(() => setSaved(false), 3000); |
||||||
|
} catch (err: any) { alert(err.message); } |
||||||
|
setSaving(false); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-6"> |
||||||
|
<div> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Настройки</h1> |
||||||
|
<p className="text-sm text-muted-foreground mt-1">API-ключ и промпты для ИИ-ассистента</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Card> |
||||||
|
<CardHeader className="pb-2"> |
||||||
|
<CardTitle className="flex items-center gap-2 text-sm"> |
||||||
|
<Key size={15} className="text-primary" /> |
||||||
|
API-ключ DeepSeek |
||||||
|
</CardTitle> |
||||||
|
</CardHeader> |
||||||
|
<CardContent className="space-y-2"> |
||||||
|
<Input |
||||||
|
type="password" |
||||||
|
value={apiKey} |
||||||
|
onChange={(e) => setApiKey(e.target.value)} |
||||||
|
placeholder="sk-..." |
||||||
|
/> |
||||||
|
<p className="text-xs text-muted-foreground"> |
||||||
|
Получите на{" "} |
||||||
|
<a href="https://platform.deepseek.com" target="_blank" className="text-primary hover:underline"> |
||||||
|
platform.deepseek.com |
||||||
|
</a> |
||||||
|
</p> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
|
||||||
|
<Card> |
||||||
|
<CardHeader className="pb-2"> |
||||||
|
<CardTitle className="flex items-center gap-2 text-sm"> |
||||||
|
<MessageSquare size={15} className="text-primary" /> |
||||||
|
Промпты |
||||||
|
</CardTitle> |
||||||
|
</CardHeader> |
||||||
|
<CardContent className="space-y-5"> |
||||||
|
{PROMPT_FIELDS.map((field) => ( |
||||||
|
<div key={field.key} className="space-y-1.5"> |
||||||
|
<Label htmlFor={field.key} className="text-[13px]">{field.label}</Label> |
||||||
|
<p className="text-xs text-muted-foreground">{field.description}</p> |
||||||
|
<Textarea |
||||||
|
id={field.key} |
||||||
|
value={prompts[field.key] || ""} |
||||||
|
onChange={(e) => setPrompts((p) => ({ ...p, [field.key]: e.target.value }))} |
||||||
|
placeholder={field.placeholder} |
||||||
|
className="min-h-[90px] text-[13px]" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
|
||||||
|
<Button onClick={saveSettings} disabled={saving} className="w-full"> |
||||||
|
{saving ? ( |
||||||
|
<Loader2 size={15} className="animate-spin" /> |
||||||
|
) : saved ? ( |
||||||
|
<CheckCircle2 size={15} /> |
||||||
|
) : ( |
||||||
|
<Save size={15} /> |
||||||
|
)} |
||||||
|
{saved ? "Сохранено" : "Сохранить настройки"} |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,333 @@ |
|||||||
|
import { useState, useEffect } from "react"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { Card, CardContent } from "@/components/ui/card"; |
||||||
|
import { Loader2, ClipboardList, CheckCircle2, XCircle, RotateCcw, MessageSquare } from "lucide-react"; |
||||||
|
import { apiFetch } from "@/lib/utils"; |
||||||
|
|
||||||
|
interface TestQuestion { |
||||||
|
question: string; |
||||||
|
options: Record<string, string>; |
||||||
|
correct: string; |
||||||
|
} |
||||||
|
|
||||||
|
interface Test { |
||||||
|
id: number; |
||||||
|
topic: string; |
||||||
|
questions: string; |
||||||
|
createdAt: string; |
||||||
|
results: TestResult[]; |
||||||
|
} |
||||||
|
|
||||||
|
interface TestResult { |
||||||
|
id?: number; |
||||||
|
score: number; |
||||||
|
total: number; |
||||||
|
/** false для наставника — результат не сохраняется в БД */ |
||||||
|
persisted?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
type Phase = "setup" | "generating" | "taking" | "results"; |
||||||
|
|
||||||
|
export default function TestPage() { |
||||||
|
const [phase, setPhase] = useState<Phase>("setup"); |
||||||
|
const [topic, setTopic] = useState(""); |
||||||
|
const [fromQuestions, setFromQuestions] = useState(false); |
||||||
|
const [testId, setTestId] = useState<number | null>(null); |
||||||
|
const [questions, setQuestions] = useState<TestQuestion[]>([]); |
||||||
|
const [answers, setAnswers] = useState<Record<string, string>>({}); |
||||||
|
const [result, setResult] = useState<TestResult | null>(null); |
||||||
|
const [tests, setTests] = useState<Test[]>([]); |
||||||
|
const [currentQ, setCurrentQ] = useState(0); |
||||||
|
|
||||||
|
useEffect(() => { loadTests(); }, []); |
||||||
|
|
||||||
|
async function loadTests() { |
||||||
|
try { setTests(await apiFetch<Test[]>("/tests")); } catch { /* empty */ } |
||||||
|
} |
||||||
|
|
||||||
|
async function generateTest() { |
||||||
|
setPhase("generating"); |
||||||
|
try { |
||||||
|
const body: Record<string, unknown> = fromQuestions |
||||||
|
? { fromQuestions: true } |
||||||
|
: { topic: topic.trim() }; |
||||||
|
|
||||||
|
if (!fromQuestions && !topic.trim()) { |
||||||
|
alert("Введите тему"); |
||||||
|
setPhase("setup"); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const test = await apiFetch<Test>("/tests/generate", { |
||||||
|
method: "POST", body: JSON.stringify(body), |
||||||
|
}); |
||||||
|
|
||||||
|
setTestId(test.id); |
||||||
|
setQuestions(JSON.parse(test.questions)); |
||||||
|
setAnswers({}); |
||||||
|
setCurrentQ(0); |
||||||
|
setPhase("taking"); |
||||||
|
} catch (err: any) { alert(err.message); setPhase("setup"); } |
||||||
|
} |
||||||
|
|
||||||
|
async function submitTest() { |
||||||
|
if (!testId) return; |
||||||
|
try { |
||||||
|
const res = await apiFetch<TestResult>(`/tests/${testId}/submit`, { |
||||||
|
method: "POST", body: JSON.stringify({ answers }), |
||||||
|
}); |
||||||
|
setResult(res); |
||||||
|
setPhase("results"); |
||||||
|
loadTests(); |
||||||
|
} catch (err: any) { alert(err.message); } |
||||||
|
} |
||||||
|
|
||||||
|
function reset() { |
||||||
|
setPhase("setup"); |
||||||
|
setTopic(""); |
||||||
|
setFromQuestions(false); |
||||||
|
setQuestions([]); |
||||||
|
setAnswers({}); |
||||||
|
setResult(null); |
||||||
|
setTestId(null); |
||||||
|
setCurrentQ(0); |
||||||
|
} |
||||||
|
|
||||||
|
function retakeTest(test: Test) { |
||||||
|
setTestId(test.id); |
||||||
|
setQuestions(JSON.parse(test.questions)); |
||||||
|
setAnswers({}); |
||||||
|
setCurrentQ(0); |
||||||
|
setResult(null); |
||||||
|
setPhase("taking"); |
||||||
|
} |
||||||
|
|
||||||
|
if (phase === "generating") { |
||||||
|
return ( |
||||||
|
<div className="flex flex-col items-center justify-center py-24 gap-4 animate-fade-in"> |
||||||
|
<div className="w-12 h-12 rounded-2xl bg-accent flex items-center justify-center animate-pulse-glow"> |
||||||
|
<Loader2 size={22} className="animate-spin text-primary" /> |
||||||
|
</div> |
||||||
|
<p className="text-sm text-muted-foreground">Генерируем тест из 10 вопросов...</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (phase === "taking") { |
||||||
|
const q = questions[currentQ]; |
||||||
|
const progress = Object.keys(answers).length; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-6"> |
||||||
|
<div className="flex items-center justify-between"> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight"> |
||||||
|
Вопрос {currentQ + 1} из {questions.length} |
||||||
|
</h1> |
||||||
|
<span className="text-sm text-muted-foreground"> |
||||||
|
Отвечено: {progress}/{questions.length} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden"> |
||||||
|
<div |
||||||
|
className="bg-primary h-full rounded-full transition-all duration-500 ease-out" |
||||||
|
style={{ width: `${(progress / questions.length) * 100}%` }} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<Card className="animate-fade-in" key={currentQ}> |
||||||
|
<CardContent className="pt-5 space-y-4"> |
||||||
|
<p className="text-[15px] font-medium leading-relaxed">{q.question}</p> |
||||||
|
<div className="space-y-2"> |
||||||
|
{Object.entries(q.options).map(([key, text]) => ( |
||||||
|
<button |
||||||
|
key={key} |
||||||
|
onClick={() => setAnswers((p) => ({ ...p, [q.question]: key }))} |
||||||
|
className={`w-full text-left p-3.5 rounded-xl border transition-all duration-200 cursor-pointer text-sm ${ |
||||||
|
answers[q.question] === key |
||||||
|
? "bg-primary text-primary-foreground border-primary shadow-sm shadow-primary/20" |
||||||
|
: "hover:bg-secondary/80 hover:border-primary/20" |
||||||
|
}`}
|
||||||
|
> |
||||||
|
<span className="font-medium mr-2 opacity-60">{key.toUpperCase()}</span> |
||||||
|
{text} |
||||||
|
</button> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
|
||||||
|
<div className="flex justify-between"> |
||||||
|
<Button |
||||||
|
variant="outline" |
||||||
|
onClick={() => setCurrentQ((c) => Math.max(0, c - 1))} |
||||||
|
disabled={currentQ === 0} |
||||||
|
> |
||||||
|
Назад |
||||||
|
</Button> |
||||||
|
<div className="flex gap-2"> |
||||||
|
{currentQ < questions.length - 1 ? ( |
||||||
|
<Button onClick={() => setCurrentQ((c) => c + 1)}> |
||||||
|
Далее |
||||||
|
</Button> |
||||||
|
) : ( |
||||||
|
<Button |
||||||
|
onClick={submitTest} |
||||||
|
disabled={progress < questions.length} |
||||||
|
> |
||||||
|
Завершить тест |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (phase === "results" && result) { |
||||||
|
const pct = Math.round((result.score / result.total) * 100); |
||||||
|
const isGood = pct >= 70; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-6 animate-fade-in"> |
||||||
|
<Card className={isGood ? "border-green-200" : "border-orange-200"}> |
||||||
|
<CardContent className="pt-6 text-center space-y-4"> |
||||||
|
<div className={`text-5xl font-bold ${isGood ? "text-green-600" : "text-orange-500"}`}> |
||||||
|
{result.score}/{result.total} |
||||||
|
</div> |
||||||
|
<p className="text-muted-foreground"> |
||||||
|
{pct >= 90 ? "Превосходно!" : pct >= 70 ? "Хороший результат" : pct >= 50 ? "Неплохо, но стоит повторить" : "Нужно подтянуть материал"} |
||||||
|
</p> |
||||||
|
<div className="w-full bg-muted rounded-full h-2 overflow-hidden max-w-xs mx-auto"> |
||||||
|
<div |
||||||
|
className={`h-full rounded-full transition-all duration-1000 ease-out ${isGood ? "bg-green-500" : "bg-orange-400"}`} |
||||||
|
style={{ width: `${pct}%` }} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
|
||||||
|
<div className="space-y-2"> |
||||||
|
{questions.map((q, i) => { |
||||||
|
const userAnswer = answers[q.question]; |
||||||
|
const isCorrect = userAnswer === q.correct; |
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={i} |
||||||
|
className={`flex items-start gap-3 p-3.5 rounded-xl border text-sm animate-fade-in ${ |
||||||
|
isCorrect ? "border-green-200 bg-green-50/50" : "border-red-200 bg-red-50/50" |
||||||
|
}`}
|
||||||
|
style={{ animationDelay: `${i * 50}ms` }} |
||||||
|
> |
||||||
|
{isCorrect |
||||||
|
? <CheckCircle2 size={17} className="text-green-600 mt-0.5 shrink-0" /> |
||||||
|
: <XCircle size={17} className="text-red-500 mt-0.5 shrink-0" />} |
||||||
|
<div> |
||||||
|
<p className="font-medium">{q.question}</p> |
||||||
|
{!isCorrect && ( |
||||||
|
<p className="text-xs text-muted-foreground mt-1"> |
||||||
|
Правильно: {q.correct}) {q.options[q.correct]} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
})} |
||||||
|
</div> |
||||||
|
|
||||||
|
<Button onClick={reset} variant="outline" className="w-full"> |
||||||
|
<RotateCcw size={15} /> |
||||||
|
Новый тест |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-6"> |
||||||
|
<div> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Тестирование</h1> |
||||||
|
<p className="text-sm text-muted-foreground mt-1">Проверьте знания — 10 вопросов с вариантами ответов</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3"> |
||||||
|
<button |
||||||
|
onClick={() => setFromQuestions(false)} |
||||||
|
className={`p-5 rounded-2xl border text-center transition-all duration-200 cursor-pointer ${ |
||||||
|
!fromQuestions |
||||||
|
? "bg-accent border-primary/20 shadow-sm shadow-primary/5" |
||||||
|
: "hover:bg-secondary/60" |
||||||
|
}`}
|
||||||
|
> |
||||||
|
<ClipboardList size={22} className={`mx-auto mb-2 ${!fromQuestions ? "text-primary" : "text-muted-foreground"}`} /> |
||||||
|
<span className="text-sm font-medium block">По теме</span> |
||||||
|
<span className="text-xs text-muted-foreground">Укажите тему теста</span> |
||||||
|
</button> |
||||||
|
<button |
||||||
|
onClick={() => setFromQuestions(true)} |
||||||
|
className={`p-5 rounded-2xl border text-center transition-all duration-200 cursor-pointer ${ |
||||||
|
fromQuestions |
||||||
|
? "bg-accent border-primary/20 shadow-sm shadow-primary/5" |
||||||
|
: "hover:bg-secondary/60" |
||||||
|
}`}
|
||||||
|
> |
||||||
|
<MessageSquare size={22} className={`mx-auto mb-2 ${fromQuestions ? "text-primary" : "text-muted-foreground"}`} /> |
||||||
|
<span className="text-sm font-medium block">По моим вопросам</span> |
||||||
|
<span className="text-xs text-muted-foreground">На основе заданных ранее</span> |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{!fromQuestions && ( |
||||||
|
<Input |
||||||
|
value={topic} |
||||||
|
onChange={(e) => setTopic(e.target.value)} |
||||||
|
placeholder="Тема теста..." |
||||||
|
className="animate-fade-in" |
||||||
|
/> |
||||||
|
)} |
||||||
|
|
||||||
|
<Button onClick={generateTest} className="w-full"> |
||||||
|
Сгенерировать тест |
||||||
|
</Button> |
||||||
|
|
||||||
|
{tests.length > 0 && ( |
||||||
|
<div className="space-y-2"> |
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> |
||||||
|
История |
||||||
|
</h2> |
||||||
|
{tests.map((t) => ( |
||||||
|
<div key={t.id} className="flex items-center justify-between p-3.5 rounded-xl border text-sm"> |
||||||
|
<div> |
||||||
|
<span className="font-medium">{t.topic}</span> |
||||||
|
<span className="text-xs text-muted-foreground ml-2"> |
||||||
|
{new Date(t.createdAt).toLocaleDateString("ru")} |
||||||
|
</span> |
||||||
|
{t.results.length > 0 && ( |
||||||
|
<span className={`ml-2 text-sm font-semibold ${ |
||||||
|
t.results[t.results.length - 1].score / t.results[t.results.length - 1].total >= 0.7 ? "text-green-600" : "text-orange-500" |
||||||
|
}`}>
|
||||||
|
{t.results[t.results.length - 1].score}/{t.results[t.results.length - 1].total} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
{t.results.length > 1 && ( |
||||||
|
<span className="text-[10px] text-muted-foreground ml-1"> |
||||||
|
({t.results.length} попыток) |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
<Button |
||||||
|
variant="outline" |
||||||
|
size="sm" |
||||||
|
onClick={() => retakeTest(t)} |
||||||
|
> |
||||||
|
<RotateCcw size={13} /> |
||||||
|
Пройти |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
))} |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,162 @@ |
|||||||
|
import { useState, useEffect } from "react"; |
||||||
|
import { Button } from "@/components/ui/button"; |
||||||
|
import { Input } from "@/components/ui/input"; |
||||||
|
import { Card, CardContent } from "@/components/ui/card"; |
||||||
|
import { BookOpen, ChevronRight, Search, Sparkles } from "lucide-react"; |
||||||
|
import { apiFetch, API_BASE } from "@/lib/utils"; |
||||||
|
import Markdown from "@/components/Markdown"; |
||||||
|
|
||||||
|
interface Textbook { |
||||||
|
id: number; |
||||||
|
topic: string; |
||||||
|
content: string; |
||||||
|
createdAt: string; |
||||||
|
} |
||||||
|
|
||||||
|
export default function TextbookPage() { |
||||||
|
const [topic, setTopic] = useState(""); |
||||||
|
const [generating, setGenerating] = useState(false); |
||||||
|
const [current, setCurrent] = useState(""); |
||||||
|
const [textbooks, setTextbooks] = useState<Textbook[]>([]); |
||||||
|
const [selectedId, setSelectedId] = useState<number | null>(null); |
||||||
|
const [search, setSearch] = useState(""); |
||||||
|
|
||||||
|
useEffect(() => { loadTextbooks(); }, []); |
||||||
|
|
||||||
|
async function loadTextbooks() { |
||||||
|
try { setTextbooks(await apiFetch<Textbook[]>("/textbooks")); } catch {} |
||||||
|
} |
||||||
|
|
||||||
|
async function generate() { |
||||||
|
if (!topic.trim() || generating) return; |
||||||
|
setGenerating(true); |
||||||
|
setCurrent(""); |
||||||
|
setSelectedId(null); |
||||||
|
|
||||||
|
try { |
||||||
|
const res = await fetch(`${API_BASE}/textbooks/generate`, { |
||||||
|
method: "POST", |
||||||
|
credentials: "include", |
||||||
|
headers: { "Content-Type": "application/json" }, |
||||||
|
body: JSON.stringify({ topic: topic.trim() }), |
||||||
|
}); |
||||||
|
|
||||||
|
const reader = res.body?.getReader(); |
||||||
|
const decoder = new TextDecoder(); |
||||||
|
let fullContent = ""; |
||||||
|
|
||||||
|
while (reader) { |
||||||
|
const { done, value } = await reader.read(); |
||||||
|
if (done) break; |
||||||
|
for (const line of decoder.decode(value).split("\n").filter((l) => l.startsWith("data: "))) { |
||||||
|
const data = line.slice(6); |
||||||
|
if (data === "[DONE]") break; |
||||||
|
try { |
||||||
|
const parsed = JSON.parse(data); |
||||||
|
if (parsed.content) { fullContent += parsed.content; setCurrent(fullContent); } |
||||||
|
} catch {} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
setTopic(""); |
||||||
|
loadTextbooks(); |
||||||
|
} catch (err: any) { setCurrent(`Ошибка: ${err.message}`); } |
||||||
|
setGenerating(false); |
||||||
|
} |
||||||
|
|
||||||
|
const displayContent = selectedId |
||||||
|
? textbooks.find((t) => t.id === selectedId)?.content || "" |
||||||
|
: current; |
||||||
|
|
||||||
|
const filtered = textbooks.filter( |
||||||
|
(t) => t.topic.toLowerCase().includes(search.toLowerCase()) |
||||||
|
); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="space-y-6"> |
||||||
|
<div> |
||||||
|
<h1 className="text-xl font-semibold tracking-tight">Генерация учебника</h1> |
||||||
|
<p className="text-sm text-muted-foreground mt-1"> |
||||||
|
ИИ составит учебник с понятными объяснениями по вашей теме |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="flex gap-2"> |
||||||
|
<Input |
||||||
|
value={topic} |
||||||
|
onChange={(e) => setTopic(e.target.value)} |
||||||
|
onKeyDown={(e) => e.key === "Enter" && generate()} |
||||||
|
placeholder="Введите тему..." |
||||||
|
disabled={generating} |
||||||
|
className="flex-1" |
||||||
|
/> |
||||||
|
<Button onClick={generate} disabled={generating || !topic.trim()}> |
||||||
|
{generating ? <Sparkles size={16} className="animate-spin-glow" /> : <BookOpen size={16} />} |
||||||
|
Создать |
||||||
|
</Button> |
||||||
|
</div> |
||||||
|
|
||||||
|
{generating && !displayContent && ( |
||||||
|
<div className="shimmer rounded-2xl h-40" /> |
||||||
|
)} |
||||||
|
|
||||||
|
{displayContent && ( |
||||||
|
<Card className="animate-fade-in"> |
||||||
|
<CardContent className="pt-5"> |
||||||
|
<Markdown exportable exportTitle={selectedId ? textbooks.find((t) => t.id === selectedId)?.topic || "textbook" : topic || "textbook"}> |
||||||
|
{displayContent} |
||||||
|
</Markdown> |
||||||
|
</CardContent> |
||||||
|
</Card> |
||||||
|
)} |
||||||
|
|
||||||
|
{textbooks.length > 0 && ( |
||||||
|
<div className="space-y-3"> |
||||||
|
<h2 className="text-sm font-medium text-muted-foreground uppercase tracking-wider"> |
||||||
|
Созданные учебники |
||||||
|
</h2> |
||||||
|
|
||||||
|
<div className="relative"> |
||||||
|
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" /> |
||||||
|
<Input |
||||||
|
value={search} |
||||||
|
onChange={(e) => setSearch(e.target.value)} |
||||||
|
placeholder="Поиск по учебникам..." |
||||||
|
className="pl-9" |
||||||
|
/> |
||||||
|
</div> |
||||||
|
|
||||||
|
<div className="space-y-1.5"> |
||||||
|
{filtered.map((tb) => ( |
||||||
|
<button |
||||||
|
key={tb.id} |
||||||
|
onClick={() => setSelectedId(tb.id === selectedId ? null : tb.id)} |
||||||
|
className={`w-full text-left flex items-center justify-between p-3.5 rounded-xl border transition-all duration-200 cursor-pointer group ${ |
||||||
|
tb.id === selectedId |
||||||
|
? "bg-accent border-primary/20 shadow-sm" |
||||||
|
: "hover:bg-secondary/60 hover:border-border" |
||||||
|
}`}
|
||||||
|
> |
||||||
|
<div> |
||||||
|
<span className="text-sm font-medium">{tb.topic}</span> |
||||||
|
<span className="text-xs text-muted-foreground ml-2"> |
||||||
|
{new Date(tb.createdAt).toLocaleDateString("ru")} |
||||||
|
</span> |
||||||
|
</div> |
||||||
|
<ChevronRight |
||||||
|
size={15} |
||||||
|
className={`text-muted-foreground transition-transform duration-200 ${ |
||||||
|
tb.id === selectedId ? "rotate-90" : "group-hover:translate-x-0.5" |
||||||
|
}`}
|
||||||
|
/> |
||||||
|
</button> |
||||||
|
))} |
||||||
|
{filtered.length === 0 && search && ( |
||||||
|
<p className="text-center text-sm text-muted-foreground py-6">Ничего не найдено</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", |
||||||
|
"target": "ES2023", |
||||||
|
"useDefineForClassFields": true, |
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"], |
||||||
|
"module": "ESNext", |
||||||
|
"types": ["vite/client"], |
||||||
|
"skipLibCheck": true, |
||||||
|
|
||||||
|
/* Bundler mode */ |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowImportingTsExtensions": true, |
||||||
|
"verbatimModuleSyntax": true, |
||||||
|
"moduleDetection": "force", |
||||||
|
"noEmit": true, |
||||||
|
"jsx": "react-jsx", |
||||||
|
|
||||||
|
/* Linting */ |
||||||
|
"strict": true, |
||||||
|
"noUnusedLocals": true, |
||||||
|
"noUnusedParameters": true, |
||||||
|
"erasableSyntaxOnly": true, |
||||||
|
"noFallthroughCasesInSwitch": true, |
||||||
|
"noUncheckedSideEffectImports": true, |
||||||
|
"baseUrl": ".", |
||||||
|
"paths": { |
||||||
|
"@/*": ["./src/*"] |
||||||
|
} |
||||||
|
}, |
||||||
|
"include": ["src"] |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
{ |
||||||
|
"files": [], |
||||||
|
"references": [ |
||||||
|
{ "path": "./tsconfig.app.json" }, |
||||||
|
{ "path": "./tsconfig.node.json" } |
||||||
|
] |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", |
||||||
|
"target": "ES2023", |
||||||
|
"lib": ["ES2023"], |
||||||
|
"module": "ESNext", |
||||||
|
"types": ["node"], |
||||||
|
"skipLibCheck": true, |
||||||
|
|
||||||
|
/* Bundler mode */ |
||||||
|
"moduleResolution": "bundler", |
||||||
|
"allowImportingTsExtensions": true, |
||||||
|
"verbatimModuleSyntax": true, |
||||||
|
"moduleDetection": "force", |
||||||
|
"noEmit": true, |
||||||
|
|
||||||
|
/* Linting */ |
||||||
|
"strict": true, |
||||||
|
"noUnusedLocals": true, |
||||||
|
"noUnusedParameters": true, |
||||||
|
"erasableSyntaxOnly": true, |
||||||
|
"noFallthroughCasesInSwitch": true, |
||||||
|
"noUncheckedSideEffectImports": true |
||||||
|
}, |
||||||
|
"include": ["vite.config.ts"] |
||||||
|
} |
||||||
@ -0,0 +1,22 @@ |
|||||||
|
import { defineConfig } from "vite"; |
||||||
|
import react from "@vitejs/plugin-react"; |
||||||
|
import tailwindcss from "@tailwindcss/vite"; |
||||||
|
import path from "path"; |
||||||
|
|
||||||
|
export default defineConfig({ |
||||||
|
plugins: [react(), tailwindcss()], |
||||||
|
resolve: { |
||||||
|
alias: { |
||||||
|
"@": path.resolve(__dirname, "./src"), |
||||||
|
}, |
||||||
|
}, |
||||||
|
server: { |
||||||
|
port: 5173, |
||||||
|
proxy: { |
||||||
|
"/api": { |
||||||
|
target: "http://localhost:3001", |
||||||
|
changeOrigin: true, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}); |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
{ |
||||||
|
"name": "edu_helper", |
||||||
|
"version": "1.0.0", |
||||||
|
"description": "EduHelper — ИИ-помощник для обучения", |
||||||
|
"scripts": { |
||||||
|
"dev": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\"", |
||||||
|
"app": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\" \"sleep 3 && open -a 'Google Chrome' --args --app=http://localhost:5173 --new-window\"", |
||||||
|
"app:arc": "concurrently \"cd backend && npm run dev\" \"cd frontend && npm run dev\" \"sleep 3 && open http://localhost:5173\"" |
||||||
|
}, |
||||||
|
"keywords": [], |
||||||
|
"author": "", |
||||||
|
"license": "ISC", |
||||||
|
"type": "commonjs", |
||||||
|
"devDependencies": { |
||||||
|
"concurrently": "^9.2.1" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
нужно сделать веб приложение |
||||||
|
обязательно использование Shadcn UI |
||||||
|
как минимум 5 полей для вопросов - обязательно должны быть заполнены в течение дня |
||||||
|
ии ассистент должен отвечать на вопросы по кнопке |
||||||
|
нужно хранить вопросы и ответы |
||||||
|
возможность составить учебник с максимально понятными объяснениями для тупого (без шуток) через ИИ ассиситента по теме которую пользователь вводит |
||||||
|
составление теста по 10 вопросам по заданной теме либо по вопросам которые задавал пользователь ранее |
||||||
|
дизайн в два цвета где один из них белый |
||||||
|
генерация отчёта ежедневно в формате |
||||||
|
"Алексей Михайлович, добрый вечер, за сегодня я узнал {формулировка от llm по вопросам за день}. Прошёл тест из _ вопросов по теме _ на _ баллов из _, составил учебник по теме _." |
||||||
|
ИИ ассиситент - дипсик, вставить ключ можно в интерфейсе |
||||||
|
Надпись на главной странице "Доброе утро/день/вечер, Константин" и чат с ИИ ассистентом без системного промпта |
||||||
|
раздел настроек обязаетльно, в котором можно редактировать все промпты для каждой потребности |
||||||
|
|
||||||
Loading…
Reference in new issue