Browse Source

Initial commit: Edu Helper (Docker, React, Express, Prisma)

Made-with: Cursor
master
Константин Лебединский 2 weeks ago
commit
3dec3ea720
  1. 9
      .dockerignore
  2. 19
      .env.docker.example
  3. 6
      .gitignore
  4. 29
      Dockerfile
  5. 118
      README.md
  6. 32
      SPRINT.md
  7. 730
      TEXTBOOK.md
  8. 5
      backend/.gitignore
  9. 15
      backend/env.example
  10. 1991
      backend/package-lock.json
  11. 45
      backend/package.json
  12. 128
      backend/prisma/migrations/20250401120000_init/migration.sql
  13. 14
      backend/prisma/migrations/20260401195000_add_hall_photo/migration.sql
  14. 2
      backend/prisma/migrations/20260401210000_hall_photo_original_name/migration.sql
  15. 3
      backend/prisma/migrations/migration_lock.toml
  16. 114
      backend/prisma/schema.prisma
  17. 67
      backend/src/index.ts
  18. 22
      backend/src/lib/authTokens.ts
  19. 31
      backend/src/lib/deepseek.ts
  20. 5
      backend/src/lib/prisma.ts
  21. 12
      backend/src/lib/studentContext.ts
  22. 25
      backend/src/middleware/auth.ts
  23. 6
      backend/src/middleware/errorHandler.ts
  24. 16
      backend/src/middleware/studentContext.ts
  25. 69
      backend/src/routes/auth.ts
  26. 83
      backend/src/routes/chat.ts
  27. 132
      backend/src/routes/questions.ts
  28. 192
      backend/src/routes/reports.ts
  29. 48
      backend/src/routes/settings.ts
  30. 143
      backend/src/routes/tests.ts
  31. 99
      backend/src/routes/textbooks.ts
  32. 65
      backend/src/seed.ts
  33. 10
      backend/src/types/express.d.ts
  34. 19
      backend/tsconfig.json
  35. 39
      docker-compose.yml
  36. 6
      docker-entrypoint.sh
  37. 24
      frontend/.gitignore
  38. 73
      frontend/README.md
  39. 23
      frontend/eslint.config.js
  40. 13
      frontend/index.html
  41. 5089
      frontend/package-lock.json
  42. 41
      frontend/package.json
  43. 1
      frontend/public/favicon.svg
  44. 24
      frontend/public/icons.svg
  45. 49
      frontend/src/App.tsx
  46. BIN
      frontend/src/assets/hero.png
  47. 1
      frontend/src/assets/react.svg
  48. 1
      frontend/src/assets/vite.svg
  49. 108
      frontend/src/components/Layout.tsx
  50. 85
      frontend/src/components/Markdown.tsx
  51. 48
      frontend/src/components/ui/button.tsx
  52. 43
      frontend/src/components/ui/card.tsx
  53. 19
      frontend/src/components/ui/input.tsx
  54. 15
      frontend/src/components/ui/label.tsx
  55. 18
      frontend/src/components/ui/textarea.tsx
  56. 68
      frontend/src/context/AuthContext.tsx
  57. 224
      frontend/src/index.css
  58. 52
      frontend/src/lib/utils.ts
  59. 10
      frontend/src/main.tsx
  60. 342
      frontend/src/pages/ArchivePage.tsx
  61. 201
      frontend/src/pages/HomePage.tsx
  62. 98
      frontend/src/pages/LoginPage.tsx
  63. 240
      frontend/src/pages/QuestionsPage.tsx
  64. 196
      frontend/src/pages/ReportPage.tsx
  65. 147
      frontend/src/pages/SettingsPage.tsx
  66. 333
      frontend/src/pages/TestPage.tsx
  67. 162
      frontend/src/pages/TextbookPage.tsx
  68. 32
      frontend/tsconfig.app.json
  69. 7
      frontend/tsconfig.json
  70. 26
      frontend/tsconfig.node.json
  71. 22
      frontend/vite.config.ts
  72. 1588
      package-lock.json
  73. 17
      package.json
  74. 14
      task.txt
  75. 3
      Запуск EduHelper.command

9
.dockerignore

@ -0,0 +1,9 @@
**/node_modules
**/.git
**/.env
**/.env.*
!**/.env.example
**/dist
**/*.db
**/*.db-journal
.DS_Store

19
.env.docker.example

@ -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

6
.gitignore vendored

@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.db
generated/
.DS_Store

29
Dockerfile

@ -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"]

118
README.md

@ -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`).

32
SPRINT.md

@ -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 с прокси).

730
TEXTBOOK.md

@ -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, ...)` гарантирует, что **хотя бы одно поле** всегда доступно.
---
*Учебник обновлён. Новые разделы: архив с поиском, умное управление полями.*

5
backend/.gitignore vendored

@ -0,0 +1,5 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma

15
backend/env.example

@ -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

1991
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

45
backend/package.json

@ -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"
}
}

128
backend/prisma/migrations/20250401120000_init/migration.sql

@ -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;

14
backend/prisma/migrations/20260401195000_add_hall_photo/migration.sql

@ -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;

2
backend/prisma/migrations/20260401210000_hall_photo_original_name/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "HallPhoto" ADD COLUMN "originalName" TEXT NOT NULL DEFAULT '';

3
backend/prisma/migrations/migration_lock.toml

@ -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"

114
backend/prisma/schema.prisma

@ -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())
}

67
backend/src/index.ts

@ -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}`);
});

22
backend/src/lib/authTokens.ts

@ -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;
}

31
backend/src/lib/deepseek.ts

@ -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;
}

5
backend/src/lib/prisma.ts

@ -0,0 +1,5 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default prisma;

12
backend/src/lib/studentContext.ts

@ -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;
}

25
backend/src/middleware/auth.ts

@ -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" });
}
}

6
backend/src/middleware/errorHandler.ts

@ -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 });
}

16
backend/src/middleware/studentContext.ts

@ -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: "Не назначен ученик для наставника" });
}
}

69
backend/src/routes/auth.ts

@ -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;

83
backend/src/routes/chat.ts

@ -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;

132
backend/src/routes/questions.ts

@ -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;

192
backend/src/routes/reports.ts

@ -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;

48
backend/src/routes/settings.ts

@ -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;

143
backend/src/routes/tests.ts

@ -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;

99
backend/src/routes/textbooks.ts

@ -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;

65
backend/src/seed.ts

@ -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();
});

10
backend/src/types/express.d.ts vendored

@ -0,0 +1,10 @@
declare global {
namespace Express {
interface Request {
user?: { id: number; role: "TUTOR" | "STUDENT"; username: string };
studentId?: number;
}
}
}
export {};

19
backend/tsconfig.json

@ -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"]
}

39
docker-compose.yml

@ -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:

6
docker-entrypoint.sh

@ -0,0 +1,6 @@
#!/bin/sh
set -e
cd /app
npx prisma migrate deploy
node dist/seed.js
exec "$@"

24
frontend/.gitignore vendored

@ -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?

73
frontend/README.md

@ -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...
},
},
])
```

23
frontend/eslint.config.js

@ -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,
},
},
])

13
frontend/index.html

@ -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>

5089
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

41
frontend/package.json

@ -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"
}
}

1
frontend/public/favicon.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

49
frontend/src/App.tsx

@ -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>
);
}

BIN
frontend/src/assets/hero.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
frontend/src/assets/react.svg

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
frontend/src/assets/vite.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

108
frontend/src/components/Layout.tsx

@ -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>
);
}

85
frontend/src/components/Markdown.tsx

@ -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>
);
}

48
frontend/src/components/ui/button.tsx

@ -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 };

43
frontend/src/components/ui/card.tsx

@ -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 };

19
frontend/src/components/ui/input.tsx

@ -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 };

15
frontend/src/components/ui/label.tsx

@ -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 };

18
frontend/src/components/ui/textarea.tsx

@ -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 };

68
frontend/src/context/AuthContext.tsx

@ -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;
}

224
frontend/src/index.css

@ -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;
}

52
frontend/src/lib/utils.ts

@ -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];
}

10
frontend/src/main.tsx

@ -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>,
)

342
frontend/src/pages/ArchivePage.tsx

@ -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>
);
}

201
frontend/src/pages/HomePage.tsx

@ -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>
);
}

98
frontend/src/pages/LoginPage.tsx

@ -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>
);
}

240
frontend/src/pages/QuestionsPage.tsx

@ -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>
);
}

196
frontend/src/pages/ReportPage.tsx

@ -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>
);
}

147
frontend/src/pages/SettingsPage.tsx

@ -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>
);
}

333
frontend/src/pages/TestPage.tsx

@ -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>
);
}

162
frontend/src/pages/TextbookPage.tsx

@ -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>
);
}

32
frontend/tsconfig.app.json

@ -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"]
}

7
frontend/tsconfig.json

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
frontend/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"]
}

22
frontend/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,
},
},
},
});

1588
package-lock.json generated

File diff suppressed because it is too large Load Diff

17
package.json

@ -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"
}
}

14
task.txt

@ -0,0 +1,14 @@
нужно сделать веб приложение
обязательно использование Shadcn UI
как минимум 5 полей для вопросов - обязательно должны быть заполнены в течение дня
ии ассистент должен отвечать на вопросы по кнопке
нужно хранить вопросы и ответы
возможность составить учебник с максимально понятными объяснениями для тупого (без шуток) через ИИ ассиситента по теме которую пользователь вводит
составление теста по 10 вопросам по заданной теме либо по вопросам которые задавал пользователь ранее
дизайн в два цвета где один из них белый
генерация отчёта ежедневно в формате
"Алексей Михайлович, добрый вечер, за сегодня я узнал {формулировка от llm по вопросам за день}. Прошёл тест из _ вопросов по теме _ на _ баллов из _, составил учебник по теме _."
ИИ ассиситент - дипсик, вставить ключ можно в интерфейсе
Надпись на главной странице "Доброе утро/день/вечер, Константин" и чат с ИИ ассистентом без системного промпта
раздел настроек обязаетльно, в котором можно редактировать все промпты для каждой потребности

3
Запуск EduHelper.command

@ -0,0 +1,3 @@
#!/bin/bash
cd "$(dirname "$0")"
exec npm run app
Loading…
Cancel
Save