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