Compare commits
107 Commits
d356dddc96
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b57b14d9b | |||
| 367764b71e | |||
| acf7ee49aa | |||
| 751c012f3d | |||
| 7084806aac | |||
| b2fa98051f | |||
| 4f5b5c535a | |||
| e5ba94cb33 | |||
| 12e1785ff2 | |||
| bd1e77c2a3 | |||
| d2362a3f1e | |||
| 3a2f64d47d | |||
| d32186c101 | |||
| f7d428180b | |||
| c5d2caa345 | |||
| 89d614fa00 | |||
| a9e6272d2d | |||
| f2946db57a | |||
| 9cb56b9b04 | |||
| 6fa49d4113 | |||
| 90f155d334 | |||
| d47f79be1a | |||
| ec128f670a | |||
| a27089bc0c | |||
| c94a8dafa9 | |||
| 47840901c5 | |||
| e3e6c713d2 | |||
| 77016a03c7 | |||
| c1ae048c14 | |||
| 799117d287 | |||
| c445bfacad | |||
| 41871a1e8e | |||
| 444b9c0faf | |||
| 5547b427bb | |||
| 2dfc42821c | |||
| 33dcf9bb4a | |||
| a5e7b20699 | |||
| 93e74951a7 | |||
| 48721759d3 | |||
| 4f3b389f05 | |||
| 628226151b | |||
| 9a21c705b7 | |||
| 7888a7598b | |||
| c25369b766 | |||
| 6b5bfc853e | |||
| e691124058 | |||
| fdb9f96382 | |||
| c64f393a7b | |||
| ba0a630fd9 | |||
| 2468671d82 | |||
| 7242a989ba | |||
| d2150153df | |||
| 3ed7bc147b | |||
| 39d84a3db2 | |||
| 15df731e37 | |||
| bfa037885f | |||
| 8757537344 | |||
| 65aa669522 | |||
| f4e74b38d4 | |||
| c050c005e4 | |||
| af1fb6f61e | |||
| 09e5653191 | |||
| 29f6533e63 | |||
| 4821764a4f | |||
| 5dfa79d357 | |||
| 9eb21e3ab4 | |||
| af8644ebce | |||
| 0bde11b86e | |||
| d8be6d6d95 | |||
| 9731fcab48 | |||
| 0e4f6c4b01 | |||
| dd198349fb | |||
| 808bcadfca | |||
| ab37af59f2 | |||
| ce305eab58 | |||
| e590f541b3 | |||
| 48a9398905 | |||
| 3855bbd4be | |||
| 768a38b9d3 | |||
| f0024c4243 | |||
| d0ba4bf909 | |||
| dd46a10c20 | |||
| 99c143d670 | |||
| 58a61d6f04 | |||
| e77588deb8 | |||
| 093e403f5f | |||
| 66b311f17e | |||
| 32b0fa9d6f | |||
| c647b29712 | |||
| 6d93a7b406 | |||
| 97f4c1ec24 | |||
| ec51dd34bb | |||
| b40d518b74 | |||
| 6975a9f97e | |||
| 9bc18247df | |||
| 543d5b2d5e | |||
| d0c8c6dd53 | |||
| c88b5d2004 | |||
| 4183a912e4 | |||
| 07b9a6d261 | |||
| 05dd4d1df2 | |||
| 03e3972388 | |||
| 8fdc67b4a5 | |||
| e9eff5bae5 | |||
| 992763aeb9 | |||
| 09325187f9 | |||
| 01a9ef482c |
@@ -5,7 +5,6 @@
|
|||||||
.env.production
|
.env.production
|
||||||
node_modules
|
node_modules
|
||||||
.next
|
.next
|
||||||
src/generated
|
|
||||||
*.md
|
*.md
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
docker-compose.prod.yml
|
docker-compose.prod.yml
|
||||||
|
|||||||
@@ -45,3 +45,6 @@ yarn-error.log*
|
|||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
# Claude Code local plugins (external git repos, не коммитим)
|
||||||
|
.claude/plugins/
|
||||||
|
|||||||
@@ -1,5 +1,308 @@
|
|||||||
<!-- BEGIN:nextjs-agent-rules -->
|
# AGENTS.md — LMS Second Brain
|
||||||
# This is NOT the Next.js you know
|
|
||||||
|
|
||||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
Собственная LMS-платформа для образовательных курсов по PKM и Obsidian.
|
||||||
<!-- END:nextjs-agent-rules -->
|
Заменяет emdesell.ru. Масштаб: ~1000 аккаунтов, ~200 активных, до 10 курсов.
|
||||||
|
Production: **https://school.second-brain.ru**
|
||||||
|
|
||||||
|
> Подробная техническая документация — в `TECHNICAL.md`.
|
||||||
|
> Роадмап и текущий статус — в `ROADMAP.md`.
|
||||||
|
> Полные правила для Claude Code — в `CLAUDE.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Слой | Технология | Версия |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| Фреймворк | Next.js (App Router) | **16.2.2** |
|
||||||
|
| Язык | TypeScript (strict) | 5.x |
|
||||||
|
| UI | React | 19 |
|
||||||
|
| Стили | Tailwind CSS (CSS-based, **без** tailwind.config.ts) | 4.x |
|
||||||
|
| Компоненты | shadcn/ui (Base UI, **не Radix**) | v4 |
|
||||||
|
| ORM | Prisma | 7.x |
|
||||||
|
| Auth | Better Auth (**не NextAuth**) | 1.6.0 |
|
||||||
|
| Редактор | TipTap WYSIWYG | 2.x |
|
||||||
|
| Drag-and-drop | @dnd-kit | latest |
|
||||||
|
| БД | PostgreSQL | 16 |
|
||||||
|
| Email | Resend | latest |
|
||||||
|
| Хранилище | Hetzner Object Storage (S3-совместимый) | — |
|
||||||
|
| Видео | Kinescope (iframe embed) | — |
|
||||||
|
| Валидация | Zod | 3.x |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Критические отличия от стандартных версий
|
||||||
|
|
||||||
|
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
|
||||||
|
|
||||||
|
### Next.js 16.2.2
|
||||||
|
- Используется `proxy.ts` вместо `middleware.ts`
|
||||||
|
- Экспортируемая функция называется `proxy`, не `middleware`
|
||||||
|
- Перед написанием кода смотри `node_modules/next/dist/docs/`
|
||||||
|
|
||||||
|
### Tailwind CSS v4
|
||||||
|
- **Нет файла `tailwind.config.ts`** — вся кастомизация через CSS
|
||||||
|
- Конфиг: `@import "tailwindcss"` и `@theme` в `globals.css`
|
||||||
|
|
||||||
|
### shadcn/ui v4
|
||||||
|
- Базируется на `@base-ui/react`, **не Radix**
|
||||||
|
- Нет пропа `asChild` — триггеры обычные элементы
|
||||||
|
- Установка: `npx shadcn@latest add <component>`
|
||||||
|
|
||||||
|
### Prisma 7.x
|
||||||
|
- Импорт: `from "@/generated/prisma/client"` (не `from "@/generated/prisma"`)
|
||||||
|
- Требует адаптер: `new PrismaPg({ connectionString })`
|
||||||
|
- Не генерирует `index.ts`
|
||||||
|
|
||||||
|
### Better Auth 1.6.0
|
||||||
|
- **Не путать с NextAuth** — другая библиотека, другое API
|
||||||
|
- В этом проекте используется **bcrypt** (не scrypt по умолчанию)
|
||||||
|
- Настройки `password.hash` / `password.verify` в `src/lib/auth.ts`
|
||||||
|
- `auth-client.ts` не использует `baseURL` — берёт `window.location.origin`
|
||||||
|
- Seed-пользователи вставлены через SQL с `emailVerified = true`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Разработка
|
||||||
|
npm run dev # localhost:3000
|
||||||
|
docker compose up -d # Поднять PostgreSQL локально
|
||||||
|
|
||||||
|
# Проверка качества
|
||||||
|
npm run lint # ESLint
|
||||||
|
npm run type-check # tsc --noEmit
|
||||||
|
|
||||||
|
# Сборка
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
|
||||||
|
# База данных
|
||||||
|
npx prisma migrate dev --name <snake_case_name> # Новая миграция
|
||||||
|
npx prisma migrate deploy # Применить в production
|
||||||
|
npx prisma generate # Пересоздать клиент
|
||||||
|
npx prisma db seed # Заполнить тестовыми данными
|
||||||
|
npx prisma studio # GUI для БД
|
||||||
|
|
||||||
|
# Production-деплой (на сервере в /root/digital-household/lms-sb/)
|
||||||
|
git pull
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
При старте production-контейнер автоматически запускает `prisma migrate deploy`, затем `node server.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
lms-system/
|
||||||
|
├── src/
|
||||||
|
│ ├── app/ # Next.js App Router
|
||||||
|
│ │ ├── (auth)/ # login, register, verify-email
|
||||||
|
│ │ ├── (student)/ # dashboard, courses/[slug], lessons/[lessonId]
|
||||||
|
│ │ ├── curator/ # homework review, dashboard
|
||||||
|
│ │ ├── admin/ # courses, users, settings, categories
|
||||||
|
│ │ └── api/ # REST endpoints + Better Auth handler
|
||||||
|
│ ├── components/
|
||||||
|
│ │ ├── ui/ # shadcn/ui (автогенерация, не трогать)
|
||||||
|
│ │ ├── editor/ # TipTap WYSIWYG
|
||||||
|
│ │ ├── player/ # Kinescope Player wrapper
|
||||||
|
│ │ ├── course/ # Компоненты курса
|
||||||
|
│ │ └── layout/ # Header, Sidebar, Footer
|
||||||
|
│ ├── lib/
|
||||||
|
│ │ ├── auth.ts # Better Auth config (сервер)
|
||||||
|
│ │ ├── auth-client.ts # Better Auth client (браузер)
|
||||||
|
│ │ ├── prisma.ts # Prisma singleton
|
||||||
|
│ │ ├── s3.ts # Hetzner S3 клиент
|
||||||
|
│ │ ├── email.ts # Resend email helpers
|
||||||
|
│ │ └── utils.ts # cn() и утилиты
|
||||||
|
│ ├── types/ # TypeScript-типы
|
||||||
|
│ ├── proxy.ts # Auth middleware (защита маршрутов)
|
||||||
|
│ └── middleware.ts # Обёртка над proxy
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # Схема БД (~314 строк)
|
||||||
|
│ ├── seed.ts # Тестовые данные
|
||||||
|
│ └── migrations/ # НЕ РЕДАКТИРОВАТЬ ВРУЧНУЮ
|
||||||
|
├── docker-compose.yml # Локальная разработка
|
||||||
|
├── docker-compose.prod.yml # Production
|
||||||
|
├── Dockerfile # Multi-stage build
|
||||||
|
├── .env.example # Шаблон переменных (без секретов)
|
||||||
|
└── .env.local # Локальные секреты (в .gitignore)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Роли и маршруты
|
||||||
|
|
||||||
|
| Роль | Маршруты | Описание |
|
||||||
|
|------|---------|----------|
|
||||||
|
| `admin` | `/admin/*`, `/curator/*`, всё | Полный доступ |
|
||||||
|
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
|
||||||
|
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
|
||||||
|
|
||||||
|
Защита маршрутов — в `src/proxy.ts` + проверка сессии в layout/page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Модель данных (ключевые сущности)
|
||||||
|
|
||||||
|
```
|
||||||
|
User → Session, Account, Verification # Better Auth
|
||||||
|
Category → Course → Module → Lesson # Структура контента
|
||||||
|
Lesson → LessonFile # Файлы к уроку
|
||||||
|
CourseEnrollment (userId + courseId) # Доступ с expiresAt
|
||||||
|
AccessLog # Аудит доступов
|
||||||
|
LessonProgress (userId + lessonId) # Прогресс ученика
|
||||||
|
Lesson → Homework → HomeworkSubmission → HomeworkFeedback # ДЗ
|
||||||
|
Lesson → LessonComment # Обсуждения (soft-delete)
|
||||||
|
Lesson → Quiz → QuizQuestion → QuizOption # Тесты
|
||||||
|
Quiz → QuizAttempt # Результаты тестов
|
||||||
|
Settings (key-value) # Настройки платформы
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дизайн-система «Second Brain Aubade»
|
||||||
|
|
||||||
|
Типографский, монохромный, газетный стиль.
|
||||||
|
|
||||||
|
| Токен | Значение |
|
||||||
|
|-------|---------|
|
||||||
|
| Шрифт | Fira Mono (400/500/700, Latin + Cyrillic) |
|
||||||
|
| Фон | `#F5F5F0` (тёплый off-white) |
|
||||||
|
| Текст | `#323232` |
|
||||||
|
| Поверхность | `#E8E8E0` |
|
||||||
|
| Акцент | `#E8F0D8` (зелёный) |
|
||||||
|
| Border | `#AAAAAA` |
|
||||||
|
| Сайдбар | `#2A2A28` (тёмный) |
|
||||||
|
|
||||||
|
**Aubade-эффект** (карточки и кнопки):
|
||||||
|
- Border: `2px solid #AAAAAA`
|
||||||
|
- Shadow: `4px 4px 0 0 #AAAAAA`
|
||||||
|
- Hover: `translate(-2px, -2px)` + shadow `6px 6px`
|
||||||
|
- Active: `translate(2px, 2px)`, shadow убирается
|
||||||
|
- CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инфраструктура
|
||||||
|
|
||||||
|
| Компонент | Значение |
|
||||||
|
|-----------|---------|
|
||||||
|
| Сервер | Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe |
|
||||||
|
| Reverse proxy | Caddy (auto HTTPS, Let's Encrypt) |
|
||||||
|
| Порт | 3010 (внутри контейнера 3000) |
|
||||||
|
| БД | PostgreSQL 16 (контейнер `lms-sb-db-1`) |
|
||||||
|
| Object Storage | Hetzner S3, endpoint `nbg1.your-objectstorage.com`, бакет `second-brain-lms` |
|
||||||
|
| Git | Gitea — `https://git.second-brain.ru/admins/lms-sb` |
|
||||||
|
| Email | Resend, домен `mailsend.second-brain.ru` |
|
||||||
|
| Бэкапы | PostgreSQL → Backblaze B2 (ежедневно, 03:00, ротация 7 дней) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
```env
|
||||||
|
DATABASE_URL="postgresql://lms_user:password@localhost:5432/lms_db"
|
||||||
|
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
|
||||||
|
BETTER_AUTH_URL="http://localhost:3000"
|
||||||
|
RESEND_API_KEY=""
|
||||||
|
EMAIL_FROM="noreply@mailsend.second-brain.ru"
|
||||||
|
S3_ENDPOINT="https://nbg1.your-objectstorage.com"
|
||||||
|
S3_BUCKET="second-brain-lms"
|
||||||
|
S3_ACCESS_KEY=""
|
||||||
|
S3_SECRET_KEY=""
|
||||||
|
S3_REGION="eu-central"
|
||||||
|
```
|
||||||
|
|
||||||
|
Секреты — **только в `.env.local`**. При добавлении новых переменных обновлять `.env.example`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила написания кода
|
||||||
|
|
||||||
|
### Языки
|
||||||
|
- **UI-строки** (заголовки, кнопки, сообщения): русский
|
||||||
|
- **Переменные, функции, файлы, комментарии**: английский
|
||||||
|
- **Коммиты**: английский, imperative mood (`Add lesson progress`, `Fix auth redirect`)
|
||||||
|
|
||||||
|
### Стиль
|
||||||
|
- Server Actions для форм и мутаций
|
||||||
|
- Не добавлять абстракции «на будущее» — только текущий этап
|
||||||
|
- Нет `console.log` в production (только `console.error` для реальных ошибок)
|
||||||
|
- Нет захардкоженных секретов, URL, ID
|
||||||
|
|
||||||
|
### Миграции БД
|
||||||
|
- **Никогда** не редактировать `prisma/migrations/` вручную
|
||||||
|
- **Всегда** спрашивать перед миграцией, которая меняет или удаляет существующие поля
|
||||||
|
- Имена миграций: английский, snake_case (`add_lesson_progress`)
|
||||||
|
- Перед `prisma migrate deploy` на production — бэкап БД
|
||||||
|
|
||||||
|
### Файлы и загрузки
|
||||||
|
- Все файлы (ДЗ, PDF, изображения) — через Hetzner Object Storage, **не на диск VPS**
|
||||||
|
- Обложки курсов: 16:9, max 5 MB, JPG/PNG/WebP
|
||||||
|
- Изображения в уроках: max 10 MB
|
||||||
|
- Файлы к уроку: max 100 MB, PDF/ZIP/DOCX
|
||||||
|
|
||||||
|
### Коммиты
|
||||||
|
- Один коммит = одна логически завершённая единица
|
||||||
|
- Перед коммитом: `npm run lint && npm run type-check`
|
||||||
|
- После завершения каждого этапа ROADMAP — `git push` в Gitea
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чек-лист перед коммитом
|
||||||
|
|
||||||
|
- [ ] `npm run lint` — без ошибок
|
||||||
|
- [ ] `npm run type-check` — без ошибок
|
||||||
|
- [ ] Новые `.env` переменные добавлены в `.env.example`
|
||||||
|
- [ ] Миграция БД согласована (если есть)
|
||||||
|
- [ ] Нет `console.log`, нет секретов в коде
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тестовые аккаунты
|
||||||
|
|
||||||
|
| Email | Пароль | Роль |
|
||||||
|
|-------|--------|------|
|
||||||
|
| admin@second-brain.ru | Password123! | admin |
|
||||||
|
| curator@second-brain.ru | Password123! | curator |
|
||||||
|
| student@second-brain.ru | Password123! | student |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Текущий статус проекта
|
||||||
|
|
||||||
|
**Завершено (9 из 13 этапов):**
|
||||||
|
- Этап 0: Каркас, auth, роли, деплой
|
||||||
|
- Этап 1: Курсы → Модули → Уроки (CRUD, drag-and-drop, TipTap, S3)
|
||||||
|
- Этап 1.5: Расширенный доступ (сроки, категории, AccessLog)
|
||||||
|
- Этап 2: Kinescope-интеграция, рендер уроков для ученика
|
||||||
|
- Этап 3: Прогресс (кнопка завершения, прогресс-бар)
|
||||||
|
- Этап 5: Домашние задания + обратная связь куратора
|
||||||
|
- Этап 6: Обсуждения под уроками
|
||||||
|
- Этап 7: Email-уведомления (Resend)
|
||||||
|
- Этап 8: Импорт уроков из Markdown (Obsidian)
|
||||||
|
|
||||||
|
**В работе:**
|
||||||
|
- Этап 9: Настройки платформы (Admin Settings)
|
||||||
|
|
||||||
|
**Впереди:**
|
||||||
|
- Этап 11: Импорт/экспорт учеников (CSV, миграция с emdesell)
|
||||||
|
- Этап 12: Telegram-бот + аналитика (Yandex.Metrika)
|
||||||
|
- Этап 13: Тесты и квизы с автопроверкой
|
||||||
|
|
||||||
|
**Бэклог:** сертификаты, геймификация, платежи, медиатека, цифровой сад, CI/CD
|
||||||
|
|
||||||
|
Полный роадмап с деталями и критериями готовности — в `ROADMAP.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- Seed-пользователи вставлены через SQL с `emailVerified = true` (обход Better Auth)
|
||||||
|
- Загрузка файлов: нет лимита на уровне Next.js (только S3)
|
||||||
|
- Drag-and-drop: возможны race conditions при быстрых перетаскиваниях (некритично)
|
||||||
|
- `expiresAt` проверяется в UI, но не блокирует доступ на уровне middleware
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
| TypeScript | 5.x | Язык |
|
| TypeScript | 5.x | Язык |
|
||||||
| React | 19 | UI |
|
| React | 19 | UI |
|
||||||
| PostgreSQL | 16 | База данных |
|
| PostgreSQL | 16 | База данных |
|
||||||
| Prisma | 6.x | ORM + миграции |
|
| Prisma | 7.x | ORM + миграции |
|
||||||
| Better Auth | latest | Аутентификация и сессии |
|
| Better Auth | latest | Аутентификация и сессии |
|
||||||
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
|
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
|
||||||
| shadcn/ui | latest | UI-компоненты |
|
| shadcn/ui | latest | UI-компоненты |
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Дизайн-система — LMS
|
||||||
|
|
||||||
|
Этот проект использует дизайн-систему **ДС-2 «Second Brain LMS & Press»** — терминальный язык «Aubade».
|
||||||
|
|
||||||
|
## Канон
|
||||||
|
|
||||||
|
Полная спецификация (9 секций, формат `DESIGN.md`):
|
||||||
|
|
||||||
|
- **Source of truth:** `SecondBrainTech/02-Стандарты/Дизайн-LMS/DESIGN.md`
|
||||||
|
- Копия для Open Design: `~/Documents/Claude/open-design/design-systems/second-brain-lms/`
|
||||||
|
- Превью со всеми примерами: `preview.html` в тех же каталогах
|
||||||
|
|
||||||
|
> Канон правится только там. Этот файл — практический указатель для разработки внутри репозитория.
|
||||||
|
|
||||||
|
## Язык в двух словах
|
||||||
|
|
||||||
|
Терминальный, моноширинный, «реестровый». Серо-зелёная палитра, острые углы 2px, выраженные рамки 2px, жёсткие тени-подложки с физикой hover/active. Тёмный админ-сайдбар. Без кремовых тонов и серифа — это язык ДС-1 (сайт и PDF), отдельной парной системы.
|
||||||
|
|
||||||
|
## Где токены в этом репозитории
|
||||||
|
|
||||||
|
Реализация — `src/app/globals.css`:
|
||||||
|
|
||||||
|
- **Палитра** — CSS-переменные в `:root`: `--background #F5F5F0`, `--foreground #323232`, `--accent #E8F0D8`, `--border #AAAAAA`, тёмный сайдбар `--sidebar-*`.
|
||||||
|
- **Типографическая шкала** — переопределённые токены Tailwind `--text-*` в блоке `@theme` (канон ДС-2, +2px к дефолту Tailwind).
|
||||||
|
- **Шрифт** — Fira Mono, подключение в `src/app/layout.tsx` через `next/font/google`.
|
||||||
|
- **Компонентные классы** — `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`, `.admin-sidebar*`.
|
||||||
|
|
||||||
|
## Письма Press
|
||||||
|
|
||||||
|
Рассылка Second Brain Press — поверхность Email той же ДС-2. Шаблон — Listmonk template id=1 (табличная вёрстка, Arial, карта 620px с рамкой 2px `#AAAAAA`). Подробности — секция 5 канонического `DESIGN.md`.
|
||||||
|
|
||||||
|
## История
|
||||||
|
|
||||||
|
Предыдущая версия этого файла ссылалась на легаси-дизайн-систему v1 (кремовая палитра + лаванда, `~/Documents/Claude/design-system/`). Она заменена: v1 — легаси, актуальна ДС-2.
|
||||||
+289
-142
@@ -6,13 +6,12 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Этап 0 — Каркас, auth, роли ✅ ЗАВЕРШЁН (07.04.2026)
|
## Этап 0 — Каркас, auth, роли ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
**Задеплоено на:** https://school.second-brain.ru
|
|
||||||
|
|
||||||
- [x] Next.js 16.2.2 (App Router, TypeScript, Tailwind v4)
|
- [x] Next.js 16.2.2 (App Router, TypeScript, Tailwind v4)
|
||||||
- [x] Docker Compose: PostgreSQL 16 (прод на Hetzner)
|
- [x] Docker Compose: PostgreSQL 16 (прод на Hetzner)
|
||||||
- [x] Prisma 7: схема User, Session, Account + полная LMS-модель
|
- [x] Prisma 7: схема User, Session, Account + полная LMS-модель
|
||||||
- [x] Better Auth: вход по email/password, роли student/curator/admin
|
- [x] Better Auth: вход по email/password, роли student/curator/admin
|
||||||
- [x] proxy.ts: защита маршрутов по сессии
|
- [x] Middleware: защита маршрутов по сессии
|
||||||
- [x] Дашборды для трёх ролей
|
- [x] Дашборды для трёх ролей
|
||||||
- [x] Страница входа, регистрации, подтверждения email
|
- [x] Страница входа, регистрации, подтверждения email
|
||||||
- [x] Seed: admin/curator/student (пароль: Password123!)
|
- [x] Seed: admin/curator/student (пароль: Password123!)
|
||||||
@@ -21,62 +20,249 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке)
|
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке) ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
**Цель:** могу создать полную структуру курса из браузера.
|
|
||||||
|
|
||||||
- [ ] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
|
- [x] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
|
||||||
- [ ] Admin: список курсов, создать / редактировать / удалить курс
|
- [x] Admin: список курсов, создать / редактировать / удалить курс
|
||||||
- [ ] Admin: список модулей внутри курса, drag-and-drop сортировка
|
- [x] Admin: список модулей внутри курса, drag-and-drop сортировка
|
||||||
- [ ] Admin: список уроков внутри модуля, drag-and-drop сортировка
|
- [x] Admin: список уроков внутри модуля, drag-and-drop сортировка
|
||||||
- [ ] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
|
- [x] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
|
||||||
- [ ] Загрузка картинок в уроке → Hetzner Object Storage
|
- [x] Загрузка картинок в уроке → Hetzner Object Storage
|
||||||
- [ ] Поле для Kinescope ID в уроке (просто текстовое, без интеграции — это Этап 2)
|
- [x] Поле для Kinescope ID в уроке
|
||||||
- [ ] Публикация/скрытие курса и урока (черновик / опубликован)
|
- [x] Публикация/скрытие курса и урока (черновик / опубликован)
|
||||||
- [ ] Управление доступом: выдать / забрать доступ к курсу для пользователя
|
- [x] Управление доступом: выдать / забрать доступ к курсу для пользователя
|
||||||
|
- [x] Дизайн: Fira Mono, #F5F5F0, Aubade-карточки
|
||||||
**Критерий готовности:** создаю курс «Obsidian PKM», добавляю 2 модуля, в каждом по 3 урока с текстом и картинкой, публикую.
|
- [x] Admin: таблица пользователей (/admin/users)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 2 — Интеграция Kinescope, рендер уроков для ученика
|
**Доработки таблицы пользователей (добавить в рамках Этапа 9):**
|
||||||
**Цель:** ученик видит урок с видео Kinescope и текстом.
|
- [ ] Фильтры: по роли (Ученик / Куратор / Администратор), по статусу email (подтверждён/нет)
|
||||||
|
- [ ] Поиск по имени / email
|
||||||
- [ ] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
|
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
|
||||||
- [ ] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
|
- [ ] Ховер-попап на строке: список курсов пользователя со сроками доступа + email + телефон
|
||||||
- [ ] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
|
- [ ] Быстрые действия в строке: кнопка «Выдать доступ» без перехода на страницу пользователя
|
||||||
- [ ] Страница курса для ученика: список модулей и уроков, статус прохождения
|
|
||||||
- [ ] Навигация по урокам: предыдущий / следующий
|
|
||||||
- [ ] Блокировка доступа к курсу без enrollment (middleware или server component)
|
|
||||||
- [ ] Страница «Мои курсы» в личном кабинете ученика
|
|
||||||
|
|
||||||
**Кинескоп-нюанс:** пока без DRM (бесплатный план), просто передаём `videoId` в компонент. Когда появится платный план — добавим signed URLs в отдельной задаче.
|
|
||||||
|
|
||||||
**Критерий готовности:** ученик открывает урок, смотрит видео из Kinescope, читает текст, скачивает PDF.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 3 — Прогресс и линейное открытие уроков
|
## Этап 1.5 — Расширенное управление доступом ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
**Цель:** ученик проходит курс последовательно, прогресс сохраняется.
|
|
||||||
|
|
||||||
- [ ] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
|
- [x] Срок доступа: `expiresAt` в `CourseEnrollment`, просроченный подсвечивается красным
|
||||||
- [ ] Кнопка «Урок завершён» — создаёт запись в LessonProgress
|
- [x] Категории курсов: таблица `Category`, `/admin/categories`, привязка к курсу
|
||||||
- [ ] Логика блокировки: следующий урок открыт только если предыдущий завершён
|
- [x] Расширенный энролл: `/admin/users/[userId]` — выбор нескольких курсов + срок одной операцией
|
||||||
- [ ] Прогресс-бар по курсу (% завершённых уроков)
|
- [x] История доступа: `AccessLog` — каждая операция логируется (кто, когда, метод, примечание)
|
||||||
- [ ] Прогресс-бар по модулю
|
|
||||||
- [ ] API: `POST /api/progress/complete` — принимает lessonId, создаёт прогресс
|
|
||||||
- [ ] Иконки статуса уроков в боковой панели: ✓ пройден / 🔒 заблокирован / → текущий
|
|
||||||
|
|
||||||
**Примечание:** если в уроке есть тест (Этап 4) или ДЗ (Этап 5) — кнопка «Завершить» появляется только после их прохождения/отправки. Эту логику дорабатываем на соответствующих этапах.
|
|
||||||
|
|
||||||
**Критерий готовности:** прохожу урок 1, нажимаю «Завершён» — открывается урок 2. Урок 3 заблокирован. Прогресс-бар показывает 33%.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 4 — Тесты и квизы
|
## Этап 2 — Интеграция Kinescope, рендер уроков для ученика ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
|
||||||
|
- [x] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
|
||||||
|
- [x] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
|
||||||
|
- [x] Страница курса для ученика: список модулей и уроков, статус прохождения
|
||||||
|
- [x] Навигация по урокам: предыдущий / следующий
|
||||||
|
- [x] Блокировка доступа к курсу без enrollment (layout server component)
|
||||||
|
- [x] Страница «Мои курсы» в личном кабинете ученика (dashboard)
|
||||||
|
- [x] Кнопки Сохранить / Просмотр в редакторе урока
|
||||||
|
- [x] Иконка-статус уроков в боковой панели курса (✓ пройден)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 3 — Прогресс ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
|
||||||
|
- [x] Кнопка «Урок завершён» — создаёт/удаляет запись в LessonProgress
|
||||||
|
- [x] Прогресс-бар по курсу в боковой панели (% завершённых уроков)
|
||||||
|
- [x] Прогресс-бар по курсу на дашборде студента
|
||||||
|
- [x] Admin bypass: администратор видит все уроки без отметок о прогрессе
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 5 — Домашние задания и обратная связь куратора ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
|
||||||
|
- [x] Admin: добавить / редактировать / удалить блок ДЗ к уроку (HomeworkEditor)
|
||||||
|
- [x] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
|
||||||
|
- [x] Ученик: видит статус ДЗ и фидбек куратора в карточке урока
|
||||||
|
- [x] Куратор / Admin: список ДЗ на проверку (`/curator/homework`)
|
||||||
|
- [x] Куратор / Admin: просмотр работы, текстовый комментарий с обратной связью
|
||||||
|
- [x] Admin: AdminShell на `/curator/*` маршрутах (сайдбар не пропадает)
|
||||||
|
- [x] Admin: реальная статистика на дашборде (студенты, курсы, ДЗ, прогресс)
|
||||||
|
|
||||||
|
**Доработки (добавить в рамках Этапа 9):**
|
||||||
|
- [ ] Фильтры в списке ДЗ: по имени/email ученика, по уроку, по курсу, по статусу
|
||||||
|
- [ ] Поиск по имени/email ученика
|
||||||
|
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
|
||||||
|
- [ ] Расширенные статусы: «Новое» / «Просмотрено» / «Прокомментировано»
|
||||||
|
- [ ] Шаблоны ответов куратора — готовые тексты фидбека, выбираемые из списка
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 6 — Обсуждения под уроками ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Prisma: LessonComment (soft-delete через поле `deleted`)
|
||||||
|
- [x] Рендер списка комментариев под уроком (server-fetched, client-rendered)
|
||||||
|
- [x] Форма отправки комментария (только для enrolled учеников и admin)
|
||||||
|
- [x] Модерация: автор, куратор или admin может удалить комментарий
|
||||||
|
- [x] Счётчик активных комментариев в заголовке секции
|
||||||
|
|
||||||
|
**Не реализовано (добавить в Этап 9 или отдельно):**
|
||||||
|
- [ ] `/admin/comments` — сводная таблица всех комментариев по всем урокам
|
||||||
|
- Колонки: №, Имя ученика, Урок (ссылка), Время, кол-во ответов, превью текста
|
||||||
|
- Удалить комментарий прямо из списка
|
||||||
|
- Пагинация
|
||||||
|
- Ссылка в сайдбаре AdminNav
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Базовый HTML email-шаблон (фирменный стиль Second Brain)
|
||||||
|
- [x] Приветственное письмо при регистрации (`databaseHooks.user.create.after`)
|
||||||
|
- [x] Письмо ученику об открытии доступа к курсу
|
||||||
|
- [x] Куратор / Admin: уведомление о новом ДЗ на проверку
|
||||||
|
- [x] Ученик: уведомление о полученном фидбеке
|
||||||
|
- [x] Resend domain: mailsend.second-brain.ru (verified)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 8 — Импорт уроков из Markdown (Obsidian) ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] API: `POST /api/admin/import-md` — принимает .md-файл
|
||||||
|
- [x] Парсинг frontmatter (title, kinescopeId, order, published) через `gray-matter`
|
||||||
|
- [x] Конвертация Markdown → TipTap JSON через `unified` + `remark-parse`
|
||||||
|
- [x] Поддержка: заголовки, параграфы, жирный/курсив/зачёркнутый, инлайн-код, блоки кода, цитаты, списки, ссылки, изображения (HTTP), горизонтальные разделители
|
||||||
|
- [x] Очистка Obsidian-синтаксиса: `![[image]]` удаляется, `[[link|alias]]` → текст
|
||||||
|
- [x] UI: кнопка «Импорт .md» в редакторе урока — заполняет форму без автосохранения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 9 — Настройки платформы (Admin Settings) ✅ ЗАВЕРШЁН (08.04.2026)
|
||||||
|
**Цель:** администратор управляет ключевыми параметрами платформы без правки кода.
|
||||||
|
|
||||||
|
### Основное
|
||||||
|
- [ ] Название школы (используется в заголовке сайта, подписи писем)
|
||||||
|
- [ ] Описание школы (мета-тег description)
|
||||||
|
- [ ] Ключевые слова (мета-тег keywords)
|
||||||
|
- [ ] Режим тех. работ: вкл/выкл (показывает заглушку всем кроме admin)
|
||||||
|
- [ ] Регистрация учеников: вкл/выкл
|
||||||
|
|
||||||
|
### Оформление
|
||||||
|
- [ ] Логотип школы (загрузка → Object Storage, отображается в шапке)
|
||||||
|
- [ ] Фавикон (загрузка → Object Storage)
|
||||||
|
- [ ] Показывать логотип: да/нет
|
||||||
|
|
||||||
|
### Уведомления
|
||||||
|
- [ ] Email(ы) для системных уведомлений (кому слать письма о ДЗ, вопросах, регистрациях)
|
||||||
|
- [ ] Уведомление куратору/админу о новом ДЗ: вкл/выкл
|
||||||
|
- [ ] Уведомление куратору/админу о новом вопросе ученика: вкл/выкл
|
||||||
|
- [ ] Уведомление админу о новой регистрации: вкл/выкл
|
||||||
|
- [ ] Уведомление ученику при ответе на ДЗ/вопрос: вкл/выкл
|
||||||
|
|
||||||
|
### Данные ученика
|
||||||
|
- [ ] Требовать подтверждение email перед доступом к курсам: да/нет
|
||||||
|
- [ ] Фамилия при регистрации: обязательная / необязательная / выключена
|
||||||
|
- [ ] Телефон при регистрации: обязательный / необязательный / выключен
|
||||||
|
|
||||||
|
### Защита
|
||||||
|
- [ ] Одна активная сессия на аккаунт: вкл/выкл
|
||||||
|
- [ ] CAPTCHA на форме регистрации: вкл/выкл (reCAPTCHA v3)
|
||||||
|
|
||||||
|
### Права куратора
|
||||||
|
- [ ] Куратор видит ДЗ: по всем курсам / только по назначенным курсам
|
||||||
|
- [ ] Куратор может отвечать на вопросы учеников: да/нет
|
||||||
|
- [ ] Куратор видит список всех студентов: да/нет
|
||||||
|
|
||||||
|
### Вставка кода
|
||||||
|
- [ ] Произвольный код в `<head>` (Yandex.Metrika, Google Analytics, пиксели)
|
||||||
|
- [ ] Произвольный код в `<body>` (виджеты, чаты поддержки)
|
||||||
|
|
||||||
|
### Юридические документы
|
||||||
|
- [ ] URL Политики конфиденциальности (ссылка на внешний документ)
|
||||||
|
- [ ] URL Согласия на обработку персональных данных
|
||||||
|
- [ ] URL Договора-оферты
|
||||||
|
- [ ] Показывать чекбокс «Я принимаю условия» при регистрации: да/нет
|
||||||
|
- [ ] Реквизиты организации (текстовое поле, отображается в подвале)
|
||||||
|
|
||||||
|
### Соц. сети
|
||||||
|
- [ ] YouTube: одна ссылка
|
||||||
|
- [ ] VK: несколько ссылок (название + URL), например «Основная группа» и «Канал»
|
||||||
|
- [ ] Telegram: несколько ссылок (название + URL), например «Основной канал» и «Канал курса»
|
||||||
|
(отображаются в подвале личного кабинета ученика; хранятся как JSON-массив в Settings)
|
||||||
|
|
||||||
|
### Вопросы учеников
|
||||||
|
- [ ] Система вопросов глобально: вкл/выкл
|
||||||
|
- [ ] Куратор/админ может написать ученику первым: да/нет
|
||||||
|
- [ ] Вопросы только по курсам ученика: да/нет
|
||||||
|
- [ ] Включать вопросы для новых курсов автоматически: да/нет
|
||||||
|
|
||||||
|
**Хранение:** таблица `Settings` (key-value), доступна через `getSettings()` в server components.
|
||||||
|
**Критерий готовности:** меняю название школы → оно появляется в заголовке. Включаю тех. работы → ученики видят заглушку. Куратор привязан к курсу — видит только его ДЗ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 11 — Импорт/Экспорт учеников и миграция с emdesell
|
||||||
|
**Цель:** все пользователи и контент перенесены в новую LMS. Раздел `/admin/import-export`.
|
||||||
|
|
||||||
|
### Импорт учеников (CSV)
|
||||||
|
- [ ] Скачать файл-шаблон CSV (Email, Имя, Фамилия, Телефон)
|
||||||
|
- [ ] Загрузка CSV, поддержка кодировок Windows-1251 и UTF-8
|
||||||
|
- [ ] Опция: подтверждать email автоматически (да/нет)
|
||||||
|
- [ ] Опция: обновлять уже существующие аккаунты (да/нет)
|
||||||
|
- [ ] Присвоение доступов к курсам при импорте (выбор курса + срок в днях, 0 = бессрочно)
|
||||||
|
- [ ] Опция: отправить письмо-уведомление ученику (со ссылкой для установки пароля)
|
||||||
|
- [ ] Предпросмотр перед применением (таблица: кто создаётся, кто обновляется, кому даётся доступ)
|
||||||
|
- [ ] Применить импорт — создать пользователей, выдать доступы, отправить письма
|
||||||
|
|
||||||
|
### Экспорт учеников (CSV)
|
||||||
|
- [ ] Все ученики или фильтр по конкретному курсу/доступу
|
||||||
|
- [ ] Фильтр по просмотрам уроков (экспортировать только тех кто смотрел)
|
||||||
|
- [ ] Выбор кодировки: Windows-1251 (для Excel) / UTF-8
|
||||||
|
- [ ] Поля: Email, Имя, Фамилия, Телефон, Дата регистрации, Курсы, Прогресс
|
||||||
|
|
||||||
|
### Миграция контента
|
||||||
|
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
|
||||||
|
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
|
||||||
|
- [ ] QA: проверить 10 случайных аккаунтов после импорта
|
||||||
|
|
||||||
|
**Критерий готовности:** загружаю CSV из emdesell → предпросмотр показывает корректные данные → применяю → ученики получают письма → могут войти и продолжить обучение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 12 — Telegram-бот и аналитика
|
||||||
|
**Цель:** уведомления в Telegram для всех участников, базовая аналитика.
|
||||||
|
|
||||||
|
**Настройки (в разделе Настройки → Telegram):**
|
||||||
|
- Токен бота (вводится в админке, хранится в Settings)
|
||||||
|
- Интеграция вкл/выкл глобально
|
||||||
|
- Показывать кнопку «Подключить Telegram» в кабинете ученика: да/нет
|
||||||
|
|
||||||
|
**Уведомления куратору/админу:**
|
||||||
|
- [ ] Новое ДЗ на проверку
|
||||||
|
- [ ] Новый вопрос от ученика
|
||||||
|
- [ ] Новая регистрация студента
|
||||||
|
- [ ] Ошибки платформы (500-е, failed email и т.д.)
|
||||||
|
|
||||||
|
**Уведомления ученику:**
|
||||||
|
- [ ] Получен фидбек по ДЗ
|
||||||
|
- [ ] Ответ куратора на вопрос
|
||||||
|
- [ ] Открыт доступ к новому курсу
|
||||||
|
|
||||||
|
**Реализация:**
|
||||||
|
- [ ] Ученик привязывает Telegram через `/start` в боте (сохраняется `telegramChatId` в User)
|
||||||
|
- [ ] Кнопка «Подключить Telegram» в личном кабинете ученика
|
||||||
|
- [ ] Админ/куратор вводит свой `chatId` в профиле или через `/start`
|
||||||
|
- [ ] Настройки бота в разделе Настройки → Telegram
|
||||||
|
- [ ] Yandex.Metrika: базовое подключение (pageviews)
|
||||||
|
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 13 — Тесты и квизы
|
||||||
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
|
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
|
||||||
|
|
||||||
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt
|
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть)
|
||||||
- [ ] Admin: создание теста к уроку (добавить вопрос → варианты ответов → отметить правильный)
|
- [ ] Admin: создание теста к уроку (вопрос → варианты → отметить правильный)
|
||||||
- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст
|
- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст
|
||||||
- [ ] Рендер теста в уроке для ученика
|
- [ ] Рендер теста в уроке для ученика
|
||||||
- [ ] Авто-проверка (single/multiple choice), результат сразу
|
- [ ] Авто-проверка (single/multiple choice), результат сразу
|
||||||
@@ -87,105 +273,34 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 5 — Домашние задания и обратная связь куратора
|
|
||||||
**Цель:** ученик сдаёт ДЗ, куратор оставляет комментарий.
|
|
||||||
|
|
||||||
- [ ] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
|
|
||||||
- [ ] Admin: добавить блок ДЗ к уроку (текст задания)
|
|
||||||
- [ ] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
|
|
||||||
- [ ] Ученик: ДЗ засчитывается **автоматически при отправке** (урок открывается сразу)
|
|
||||||
- [ ] Куратор: список ДЗ на проверку (все или по курсу), статус «новое / просмотрено»
|
|
||||||
- [ ] Куратор: просмотр ДЗ, оставить текстовый комментарий
|
|
||||||
- [ ] История обмена по ДЗ (ученик может ответить на комментарий куратора)
|
|
||||||
- [ ] Уведомление ученику когда куратор оставил комментарий (заглушка — реальные email на Этапе 7)
|
|
||||||
|
|
||||||
**Критерий готовности:** отправляю ДЗ — урок открывается. Куратор заходит в панель, видит ДЗ, оставляет комментарий. Ученик видит комментарий в карточке урока.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 6 — Обсуждения под уроками
|
|
||||||
**Цель:** ученики могут общаться под каждым уроком.
|
|
||||||
|
|
||||||
- [ ] Prisma: LessonComment (с поддержкой вложенных ответов — опционально)
|
|
||||||
- [ ] Рендер треда комментариев под уроком
|
|
||||||
- [ ] Форма отправки комментария (только для enrolled учеников)
|
|
||||||
- [ ] Модерация: куратор/админ может удалить комментарий
|
|
||||||
- [ ] Пагинация или infinite scroll для длинных тредов
|
|
||||||
|
|
||||||
**Критерий готовности:** ученик оставляет комментарий, другой ученик его видит, куратор может удалить.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 7 — Email-уведомления
|
|
||||||
**Цель:** все участники получают нужные письма через Resend.
|
|
||||||
|
|
||||||
- [ ] Базовый email-шаблон (HTML, фирменный стиль)
|
|
||||||
- [ ] Ученик: подтверждение регистрации (уже частично с Этапа 0, финализировать)
|
|
||||||
- [ ] Ученик: письмо когда куратор оставил комментарий к ДЗ
|
|
||||||
- [ ] Ученик: письмо когда ответили на его комментарий в уроке
|
|
||||||
- [ ] Куратор / Админ: новое ДЗ на проверку
|
|
||||||
- [ ] Куратор / Админ: новый комментарий в обсуждении
|
|
||||||
- [ ] Админ: зарегистрирован новый ученик
|
|
||||||
- [ ] Очередь отправки (edge case: Resend временно недоступен → retry)
|
|
||||||
|
|
||||||
**Критерий готовности:** отправляю ДЗ — куратор получает email. Куратор отвечает — ученик получает email.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 8 — Импорт уроков из Markdown
|
|
||||||
**Цель:** могу импортировать урок из .md-файла Obsidian одним действием.
|
|
||||||
|
|
||||||
- [ ] API: `POST /api/admin/lessons/import-md` — принимает .md-файл
|
|
||||||
- [ ] Парсинг frontmatter (title, order, kinescopeId и кастомные поля) → метаданные урока
|
|
||||||
- [ ] Конвертация Markdown-тела в TipTap JSON (через remark / rehype)
|
|
||||||
- [ ] UI в админке: кнопка «Импортировать из .md» на странице урока
|
|
||||||
- [ ] Обработка картинок в Markdown (локальные пути → Object Storage)
|
|
||||||
|
|
||||||
**Критерий готовности:** беру .md-файл из Obsidian с frontmatter и текстом → импортирую → урок создан с правильными метаданными и контентом.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 9 — Миграция с emdesell
|
|
||||||
**Цель:** все пользователи и контент перенесены в новую LMS.
|
|
||||||
|
|
||||||
- [ ] Скрипт импорта пользователей из CSV-экспорта emdesell (email, имя, курсы)
|
|
||||||
- [ ] Создание пользователей без пароля + письмо «установите пароль»
|
|
||||||
- [ ] Назначение доступов к курсам по данным из CSV
|
|
||||||
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
|
|
||||||
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
|
|
||||||
- [ ] QA: проверить 10 случайных аккаунтов после импорта
|
|
||||||
|
|
||||||
**Критерий готовности:** все ученики из emdesell могут войти в новую LMS и продолжить обучение.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 10 — Telegram-бот и аналитика
|
|
||||||
**Цель:** получаю уведомления в Telegram, вижу базовую аналитику.
|
|
||||||
|
|
||||||
- [ ] Telegram-бот: уведомление куратору/админу о новом ДЗ
|
|
||||||
- [ ] Telegram-бот: уведомление об ошибках (500-е, failed email и т.д.)
|
|
||||||
- [ ] Yandex.Metrika: базовое подключение (pageviews)
|
|
||||||
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Этап 11 — Деплой на Hetzner
|
|
||||||
**Цель:** LMS работает на production-сервере по своему домену с SSL.
|
|
||||||
|
|
||||||
- [ ] `docker-compose.prod.yml`: app + PostgreSQL + Redis + Nginx
|
|
||||||
- [ ] Nginx: SSL через Let's Encrypt (certbot), reverse proxy на Next.js
|
|
||||||
- [ ] GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
|
|
||||||
- [ ] Резервное копирование PostgreSQL (cron → Object Storage)
|
|
||||||
- [ ] Мониторинг uptime (UptimeRobot или аналог)
|
|
||||||
- [ ] `.env` на сервере через Hetzner Secrets Manager или vault-файл вне репозитория
|
|
||||||
- [ ] Smoke-тест: регистрация → урок → ДЗ → куратор → email
|
|
||||||
|
|
||||||
**Критерий MVP готов:** создаю курс из админки, добавляю уроки с Kinescope, импортирую ученика из emdesell, даю доступ — ученик регистрируется, проходит урок, сдаёт тест, отправляет ДЗ, получает автодоступ к следующему уроку, позже — комментарий куратора на email.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Бэклог (после MVP)
|
## Бэклог (после MVP)
|
||||||
|
|
||||||
|
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
|
||||||
|
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
|
||||||
|
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
|
||||||
|
- Embeddable open-source editor (в одну строку) — отложить, пока не требуется
|
||||||
|
- Сейчас Этап 7 (Email-уведомления) завершён на базовой связке, задача — рефакторинг на React Email
|
||||||
|
|
||||||
|
- **Самостоятельная регистрация + автоматический онбординг** — два сценария входа и воронка после регистрации:
|
||||||
|
|
||||||
|
**Сценарии регистрации:**
|
||||||
|
- С лендинга через покупку — пользователь оплачивает курс, аккаунт создаётся автоматически, письмо с доступом приходит сразу
|
||||||
|
- Прямой вход на платформу — пользователь приходит по реферальной ссылке, из соцсетей, от партнёров — регистрируется сам без покупки
|
||||||
|
|
||||||
|
**Автоматический онбординг после регистрации:**
|
||||||
|
- Автоназначение вводных / вотер-модулей курсов (бесплатные превью, чтобы зацепить)
|
||||||
|
- Доступ к базовой библиотеке материалов по умолчанию (статьи, шаблоны, гайды — определяется в настройках)
|
||||||
|
- Приветственная воронка: серия писем / уведомлений, которая ведёт к первой покупке
|
||||||
|
- Уведомление администратора о новой регистрации (email + Telegram)
|
||||||
|
|
||||||
|
**Что нужно проработать:**
|
||||||
|
- Публичная страница регистрации (+ CAPTCHA, опционально)
|
||||||
|
- Настройка в Этапе 9: «Регистрация открыта: да/нет» + выбор вводных курсов/модулей, которые назначаются автоматически
|
||||||
|
- Интеграция с платёжной системой: оплата на лендинге → автосоздание аккаунта → автовыдача доступа к купленному курсу
|
||||||
|
- Разграничение: что видит гость / зарегистрированный без покупки / купивший курс
|
||||||
|
|
||||||
|
- Резервное копирование PostgreSQL (cron → Object Storage)
|
||||||
|
- GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
|
||||||
- Сертификаты по окончании курса
|
- Сертификаты по окончании курса
|
||||||
- Геймификация (баллы, бейджи, рейтинги)
|
- Геймификация (баллы, бейджи, рейтинги)
|
||||||
- Промокоды и интеграция с платёжными системами
|
- Промокоды и интеграция с платёжными системами
|
||||||
@@ -193,3 +308,35 @@
|
|||||||
- Kinescope DRM (signed URLs) — при переходе на платный план
|
- Kinescope DRM (signed URLs) — при переходе на платный план
|
||||||
- Водяные знаки на PDF и картинках
|
- Водяные знаки на PDF и картинках
|
||||||
- Мобильное приложение
|
- Мобильное приложение
|
||||||
|
- **Вопросы учеников** — система тикетов `/admin/questions` и `/questions` для ученика:
|
||||||
|
- Таблица в админке: №, Имя, Курс, Тема, Статус (Ожидает / Отвечено), Дата
|
||||||
|
- Статусы отсортированы: сначала «Ожидает ответа»
|
||||||
|
- Куратор/Admin может создать обращение первым (написать ученику)
|
||||||
|
- Внутри тикета: история переписки, смена статуса
|
||||||
|
- **База знаний** — FAQ, который ученик видит до отправки вопроса
|
||||||
|
- **Шаблоны ответов** — куратор выбирает готовый ответ из списка
|
||||||
|
- Email + Telegram уведомления обеим сторонам
|
||||||
|
|
||||||
|
- **Главная страница ученика** — кастомизируемый экран после входа:
|
||||||
|
- Приветственный баннер с описанием школы (редактируется в настройках)
|
||||||
|
- Список курсов ученика с прогрессом
|
||||||
|
- Блок бесплатных/открытых материалов (статьи, PDF, видео)
|
||||||
|
- Анонсы ближайших событий и новых курсов
|
||||||
|
|
||||||
|
- **Медиатека (Файлы)** — централизованное файловое хранилище `/admin/files`:
|
||||||
|
- Prisma: `MediaFolder` (id, name, courseId?, createdAt) + `MediaFile` (id, folderId?, name, url, size, mimeType, uploadedById, createdAt)
|
||||||
|
- Папки автоматически создаются по курсам + «Common» для общих файлов
|
||||||
|
- Вид: грид (карточки с иконкой типа) или список — переключатель
|
||||||
|
- Breadcrumb-навигация: Все файлы / Название папки
|
||||||
|
- Загрузка файлов (PDF, изображения, любые) → Object Storage
|
||||||
|
- Создание папки вручную
|
||||||
|
- Клик на файл → диалог: имя (редактируемое), дата загрузки, размер, автор
|
||||||
|
- Действия в диалоге: скопировать ссылку, скачать, удалить
|
||||||
|
- Вставка файлов из медиатеки в урок (вместо повторной загрузки)
|
||||||
|
|
||||||
|
- **Цифровой сад** — публичный раздел платформы для сообщества:
|
||||||
|
- Методические материалы и статьи (PKM, Obsidian, Second Brain)
|
||||||
|
- Рекомендованная литература с аннотациями
|
||||||
|
- Записи открытых встреч и вебинаров
|
||||||
|
- Календарь: предстоящие открытые уроки, запуски курсов, события
|
||||||
|
- Возможно: публичный Obsidian-like граф знаний
|
||||||
|
|||||||
+267
@@ -0,0 +1,267 @@
|
|||||||
|
# TECHNICAL — LMS Second Brain
|
||||||
|
|
||||||
|
Живая документация проекта. Обновляется по мере разработки.
|
||||||
|
Роадмап и планирование — в `ROADMAP.md`. Здесь — факты о том, как всё устроено.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инфраструктура
|
||||||
|
|
||||||
|
| Компонент | Значение |
|
||||||
|
|---|---|
|
||||||
|
| **Сервер** | Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe |
|
||||||
|
| **IP** | 178.104.27.196 |
|
||||||
|
| **Домен LMS** | https://school.second-brain.ru |
|
||||||
|
| **Reverse proxy** | Caddy (auto HTTPS через Let's Encrypt) |
|
||||||
|
| **Порт приложения** | 3010 (внутри контейнера — 3000) |
|
||||||
|
| **БД** | PostgreSQL 16 (контейнер `lms-sb-db-1`) |
|
||||||
|
| **Object Storage** | Hetzner Object Storage, регион Nuremberg |
|
||||||
|
| **Бакет** | `second-brain-lms` (публичный, read-only) |
|
||||||
|
| **Endpoint S3** | https://nbg1.your-objectstorage.com |
|
||||||
|
| **Git-репозиторий** | https://git.second-brain.ru/admins/lms-sb |
|
||||||
|
| **Email-сервис** | Resend, домен `mailsend.second-brain.ru` (verified) |
|
||||||
|
| **From-адрес** | noreply@mailsend.second-brain.ru |
|
||||||
|
|
||||||
|
### Деплой
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# На сервере: /root/digital-household/lms-sb/
|
||||||
|
git pull ...
|
||||||
|
docker compose -f docker-compose.prod.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
При старте контейнер автоматически запускает `prisma migrate deploy`, затем `node server.js`.
|
||||||
|
|
||||||
|
### .env на сервере
|
||||||
|
|
||||||
|
Файл `/root/digital-household/lms-sb/.env`:
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_PASSWORD=lms_cd5041e961a3050db359aa15
|
||||||
|
BETTER_AUTH_SECRET=<secret>
|
||||||
|
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL
|
||||||
|
EMAIL_FROM=noreply@mailsend.second-brain.ru
|
||||||
|
S3_ENDPOINT=https://nbg1.your-objectstorage.com
|
||||||
|
S3_BUCKET=second-brain-lms
|
||||||
|
S3_ACCESS_KEY=<ключ>
|
||||||
|
S3_SECRET_KEY=<секрет>
|
||||||
|
S3_REGION=eu-central
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
| Слой | Технология | Версия |
|
||||||
|
|---|---|---|
|
||||||
|
| Фреймворк | Next.js (App Router) | 16.2.2 |
|
||||||
|
| Язык | TypeScript | 5.x |
|
||||||
|
| UI | React | 19 |
|
||||||
|
| Стили | Tailwind CSS (CSS-based config) | 4.x |
|
||||||
|
| UI-компоненты | shadcn/ui (базируется на Base UI, **не Radix**) | latest |
|
||||||
|
| ORM | Prisma | 7.x |
|
||||||
|
| Auth | Better Auth | 1.6.0 |
|
||||||
|
| WYSIWYG | TipTap | 2.x |
|
||||||
|
| Drag-and-drop | @dnd-kit | latest |
|
||||||
|
| Шрифт | Fira Mono (400/500/700, Latin + Cyrillic) | Google Fonts |
|
||||||
|
| Email | Resend | latest |
|
||||||
|
| S3 | @aws-sdk/client-s3 | 3.x |
|
||||||
|
| БД | PostgreSQL | 16 |
|
||||||
|
|
||||||
|
### Важные нюансы стека
|
||||||
|
|
||||||
|
- **shadcn/ui v4** использует `@base-ui/react`, а не Radix. Нет `asChild`. Триггеры — обычные элементы.
|
||||||
|
- **Prisma 7** не генерирует `index.ts`. Импорт: `from "@/generated/prisma/client"`, не `from "@/generated/prisma"`.
|
||||||
|
- **Prisma 7** требует адаптер: `new PrismaPg({ connectionString })` — иначе `PrismaClient()` бросает ошибку.
|
||||||
|
- **Better Auth** использует `scrypt` по умолчанию. В этом проекте **переключён на bcrypt** (в `auth.ts` настроены `password.hash` / `password.verify`).
|
||||||
|
- **`NEXT_PUBLIC_*`** переменные запекаются при сборке. `auth-client.ts` не использует `baseURL` — клиент сам берёт `window.location.origin`.
|
||||||
|
- **Next.js 16** использует `proxy.ts` вместо `middleware.ts` (и экспортируемая функция называется `proxy`, не `middleware`).
|
||||||
|
- **Tailwind v4**: конфиг только в CSS через `@import "tailwindcss"` и `@theme`. Нет `tailwind.config.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Дизайн-система
|
||||||
|
|
||||||
|
Стиль: **Second Brain Aubade** — типографский, монохромный, с газетным характером.
|
||||||
|
|
||||||
|
| Токен | Значение |
|
||||||
|
|---|---|
|
||||||
|
| Шрифт | Fira Mono (весь UI) |
|
||||||
|
| Фон страницы | `#F5F5F0` (тёплый off-white) |
|
||||||
|
| Текст основной | `#323232` (тёмный уголь) |
|
||||||
|
| Текст вторичный | `#666666` |
|
||||||
|
| Поверхность / surface | `#E8E8E0` |
|
||||||
|
| Акцент / highlight | `#E8F0D8` (зелёный) |
|
||||||
|
| Divider / border | `#AAAAAA` |
|
||||||
|
| Hover | `#D8D8D0` |
|
||||||
|
| Фон сайдбара (тёмный) | `#2A2A28` |
|
||||||
|
| Активный пункт сайдбара | `#E8F0D8` (зелёный) |
|
||||||
|
|
||||||
|
**Aubade-эффект** — фирменный стиль карточек и кнопок:
|
||||||
|
- Border: `2px solid #AAAAAA`
|
||||||
|
- Box-shadow: `4px 4px 0 0 #AAAAAA` (смещение без размытия)
|
||||||
|
- Hover: `transform: translate(-2px, -2px)` + shadow `6px 6px`
|
||||||
|
- Active (кнопка): `transform: translate(2px, 2px)` + shadow убирается
|
||||||
|
|
||||||
|
CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Требования к медиафайлам
|
||||||
|
|
||||||
|
### Обложка курса (`Course.coverImage`)
|
||||||
|
|
||||||
|
| Параметр | Требование |
|
||||||
|
|---|---|
|
||||||
|
| **Соотношение сторон** | **16 : 9** (горизонтальный прямоугольник) |
|
||||||
|
| **Рекомендуемое разрешение** | 1280 × 720 px (HD) или 1920 × 1080 px (Full HD) |
|
||||||
|
| **Минимальное разрешение** | 800 × 450 px |
|
||||||
|
| **Максимальный размер файла** | 5 MB |
|
||||||
|
| **Форматы** | JPG, PNG, WebP |
|
||||||
|
| **Цветовое пространство** | sRGB |
|
||||||
|
| **Где хранится** | Hetzner Object Storage, бакет `second-brain-lms`, путь `uploads/<uuid>.ext` |
|
||||||
|
| **Доступ** | Публичный URL (прямая ссылка на файл) |
|
||||||
|
|
||||||
|
> Пример URL: `https://nbg1.your-objectstorage.com/second-brain-lms/uploads/abc123.jpg`
|
||||||
|
|
||||||
|
### Изображения в уроках (TipTap)
|
||||||
|
|
||||||
|
| Параметр | Требование |
|
||||||
|
|---|---|
|
||||||
|
| **Соотношение сторон** | Любое — TipTap встраивает как `<img>` с `max-width: 100%` |
|
||||||
|
| **Рекомендуемая ширина** | 1200 px (контент-зона урока) |
|
||||||
|
| **Максимальный размер файла** | 10 MB |
|
||||||
|
| **Форматы** | JPG, PNG, GIF, WebP |
|
||||||
|
| **Где хранится** | Hetzner Object Storage, путь `uploads/<uuid>.ext` |
|
||||||
|
|
||||||
|
### PDF и файлы к уроку (Этап 2+)
|
||||||
|
|
||||||
|
| Параметр | Требование |
|
||||||
|
|---|---|
|
||||||
|
| **Форматы** | PDF, ZIP, DOCX, XLSX, PPTX |
|
||||||
|
| **Максимальный размер** | 100 MB |
|
||||||
|
| **Где хранится** | Hetzner Object Storage, путь `lessons/<lessonId>/files/<uuid>.ext` |
|
||||||
|
|
||||||
|
### Аватары пользователей (если добавим)
|
||||||
|
|
||||||
|
| Параметр | Требование |
|
||||||
|
|---|---|
|
||||||
|
| **Соотношение сторон** | 1 : 1 (квадрат) |
|
||||||
|
| **Рекомендуемый размер** | 256 × 256 px |
|
||||||
|
| **Максимальный размер файла** | 2 MB |
|
||||||
|
| **Форматы** | JPG, PNG, WebP |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Роли и доступ
|
||||||
|
|
||||||
|
| Роль | Маршруты | Описание |
|
||||||
|
|---|---|---|
|
||||||
|
| `admin` | `/admin/*`, `/curator/*`, `/dashboard` | Полный доступ |
|
||||||
|
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
|
||||||
|
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
|
||||||
|
|
||||||
|
Защита маршрутов — в `src/proxy.ts` + проверка сессии в каждом layout/page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-маршруты
|
||||||
|
|
||||||
|
| Метод | Путь | Описание | Кто может |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
|
||||||
|
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура БД (ключевые таблицы)
|
||||||
|
|
||||||
|
```
|
||||||
|
User — id, email, name, role, emailVerified
|
||||||
|
Session — Better Auth sessions
|
||||||
|
Account — Better Auth credentials (bcrypt password)
|
||||||
|
Verification — Better Auth email verification tokens
|
||||||
|
|
||||||
|
Category — id, title, slug, order
|
||||||
|
Course — id, slug, title, description, coverImage, published, order, categoryId
|
||||||
|
Module — id, courseId, title, order
|
||||||
|
Lesson — id, moduleId, title, content (JSON), kinescopeId, published, order
|
||||||
|
LessonFile — id, lessonId, name, url, size
|
||||||
|
|
||||||
|
CourseEnrollment — userId + courseId (PK), enrolledAt, expiresAt
|
||||||
|
AccessLog — id, courseId, userId, action, method, grantedById, note, createdAt
|
||||||
|
LessonProgress — userId + lessonId (PK), completedAt
|
||||||
|
|
||||||
|
Quiz — id, lessonId, showAnswers
|
||||||
|
QuizQuestion — id, quizId, text, type (SINGLE/MULTIPLE/TEXT), order
|
||||||
|
QuizOption — id, questionId, text, isCorrect, order
|
||||||
|
QuizAttempt — id, userId, quizId, score, answers (JSON), completedAt
|
||||||
|
|
||||||
|
Homework — id, lessonId, description
|
||||||
|
HomeworkSubmission — id, homeworkId, userId, text, files (JSON), submittedAt
|
||||||
|
HomeworkFeedback — id, submissionId, curatorId, text, createdAt
|
||||||
|
|
||||||
|
LessonComment — id, lessonId, userId, text, deleted, createdAt
|
||||||
|
```
|
||||||
|
|
||||||
|
Миграции: `prisma/migrations/` — **никогда не редактировать вручную**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Тестовые аккаунты (seed)
|
||||||
|
|
||||||
|
| Email | Пароль | Роль |
|
||||||
|
|---|---|---|
|
||||||
|
| admin@second-brain.ru | Password123! | admin |
|
||||||
|
| curator@second-brain.ru | Password123! | curator |
|
||||||
|
| student@second-brain.ru | Password123! | student |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Что сделано (по этапам)
|
||||||
|
|
||||||
|
### Этап 0 — Каркас + Auth ✅
|
||||||
|
- Next.js 16.2.2 + TypeScript + Tailwind v4
|
||||||
|
- PostgreSQL 16 + Prisma 7 + полная LMS-схема
|
||||||
|
- Better Auth: email/password, роли, сессии
|
||||||
|
- proxy.ts: защита маршрутов
|
||||||
|
- Дашборды для 3 ролей (admin / curator / student)
|
||||||
|
- Dockerfile multi-stage + docker-compose.prod.yml
|
||||||
|
- Caddy: school.second-brain.ru → порт 3010
|
||||||
|
|
||||||
|
### Этап 1 — CRUD курсов в админке ✅
|
||||||
|
- Список курсов: `/admin/courses`
|
||||||
|
- Создание курса (диалог), редактирование, удаление
|
||||||
|
- Обложка курса: загрузка в S3, требования — см. раздел «Медиафайлы»
|
||||||
|
- Модули: drag-and-drop сортировка, CRUD
|
||||||
|
- Уроки: drag-and-drop сортировка, CRUD
|
||||||
|
- Редактор урока: TipTap (Bold, Italic, H2/H3, списки, цитата, код, ссылки, изображения)
|
||||||
|
- Загрузка изображений в урок → S3
|
||||||
|
- Поле Kinescope ID (текстовое)
|
||||||
|
- Публикация / скрытие курса и урока
|
||||||
|
- Управление доступом к курсу (выдать / отозвать)
|
||||||
|
- Страница пользователей: `/admin/users`
|
||||||
|
- Дизайн Second Brain Aubade (Fira Mono, #F5F5F0, карточки с тенью)
|
||||||
|
|
||||||
|
### Этап 1.5 — Расширенное управление доступом ✅
|
||||||
|
- Категории курсов: `/admin/categories`, CRUD, привязка к курсу
|
||||||
|
- Срок доступа: поле `expiresAt` при энролле, просроченный подсвечивается красным
|
||||||
|
- Страница ученика `/admin/users/[userId]`: мультиэнролл (несколько курсов + срок)
|
||||||
|
- История доступа: таблица `AccessLog`, отображается на странице курса и ученика
|
||||||
|
- Hetzner Object Storage подключён: бакет `second-brain-lms`, Nuremberg
|
||||||
|
|
||||||
|
### Этап 3 — Прогресс, ДЗ, Email ✅
|
||||||
|
- Прогресс студента: кнопка «Отметить как пройденный», галочки и прогресс-бар в сайдбаре, прогресс на дашборде
|
||||||
|
- Домашние задания: редактор ДЗ в уроке (admin), сдача текстом + файлами (student), проверка и фидбек (curator/admin)
|
||||||
|
- Куратор-панель: `/curator/dashboard` со статистикой, `/curator/homework` список, страница проверки
|
||||||
|
- Админ имеет доступ к куратор-маршрутам через пункт «ДЗ на проверку» в сайдбаре
|
||||||
|
- Email уведомления через Resend: доступ к курсу, новая работа, фидбек получен, приветствие
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ограничения / технический долг
|
||||||
|
|
||||||
|
- `requireEmailVerification: true` в Better Auth — seed-пользователи вставлены напрямую через SQL с `emailVerified = true`
|
||||||
|
- Загрузка файлов через `/api/admin/upload` — нет ограничения по размеру на уровне Next.js (только S3). При необходимости добавить middleware с проверкой `Content-Length`
|
||||||
|
- Drag-and-drop обновляет порядок через Server Actions — при быстрых последовательных перетаскиваниях возможны race conditions (некритично для MVP)
|
||||||
|
- `expiresAt` проверяется только в UI (красная подсветка). Блокировка доступа по сроку на уровне middleware — в рамках Этапа 2
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Tech Debt Audit — lms-sb
|
||||||
|
Generated: 2026-05-09
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
- **1 Critical**: middleware не работает — файл называется `proxy.ts` вместо `middleware.ts`, защита маршрутов на уровне Next.js отсутствует
|
||||||
|
- **3 High**: двойная загрузка полной структуры курса на каждый урок; `getSettings()` вызывается дважды в root layout; нет ограничения размера загружаемых файлов
|
||||||
|
- **0 тестов** — ни одного test-файла во всём проекте
|
||||||
|
- **4 отладочных `console.log`** в production-коде Server Action
|
||||||
|
- Zod установлен как зависимость, но нигде не используется — Server Actions принимают `FormData` без валидации схемы
|
||||||
|
- 3 уязвимости npm **high** severity (next, fast-uri, fast-xml-builder)
|
||||||
|
- `settings-form.tsx` — 506 строк, единственный god-файл, но внутренняя структура оправданна
|
||||||
|
- Самые горячие файлы совпадают с самыми крупными: student lesson page (12 правок, 270 строк) и lesson-editor (8 правок, 408 строк) — концентрация долга
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Mental Model
|
||||||
|
|
||||||
|
LMS построена на Next.js 16 App Router с тремя зонами доступа: `(auth)`, `(student)`, `admin`/`curator`. Мутации идут через Server Actions, данные читаются в RSC. Better Auth отвечает за сессии и роли. Prisma 7 с PostgreSQL через собственный PrismaPg адаптер (обходит ограничения Turbopack).
|
||||||
|
|
||||||
|
Главная аномалия: middleware объявлен в `src/proxy.ts` с функцией `proxy()`, а не в `src/middleware.ts` с функцией `middleware()`. Next.js его не подхватывает. Защита работает только за счёт явных проверок сессии в каждой странице и action — что само по себе достаточно, но заявленный в CLAUDE.md "Auth middleware (защита маршрутов)" фактически не существует.
|
||||||
|
|
||||||
|
Второй структурный факт: при открытии страницы урока студентом происходит двойная загрузка полной структуры курса — один раз в `layout.tsx` (для sidebar), второй в `page.tsx` (для prev/next навигации). Это N+1 на уровне layout/page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
| ID | Category | File:Line | Severity | Effort | Description | Recommendation |
|
||||||
|
|----|----------|-----------|----------|--------|-------------|----------------|
|
||||||
|
| F001 | Security | src/proxy.ts:1 | Critical | S | Файл `proxy.ts` не является Next.js middleware. Next.js ищет `src/middleware.ts` с экспортом `middleware`. Маршруты не защищены на уровне edge. | Переименовать файл в `src/middleware.ts`, переименовать экспорт `proxy` → `middleware` |
|
||||||
|
| F002 | Performance | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:37 | High | M | `page.tsx` загружает `lesson.module.course.modules` с вложенными уроками для prev/next nav — та же структура уже загружена в `layout.tsx:20`. Двойной DB-запрос на каждый pageview урока. | Вынести prev/next навигацию в layout или передавать через `searchParams`/context вместо повторной загрузки |
|
||||||
|
| F003 | Performance | src/app/layout.tsx:14,27 | High | S | `getSettings()` вызывается дважды в одном компоненте — в `generateMetadata()` и в `RootLayout()`. Два одинаковых DB-запроса на каждый запрос. | Объединить в один вызов или обернуть `getSettings` в `React.cache()` |
|
||||||
|
| F004 | Security | src/app/api/admin/upload/route.ts:1, src/app/api/student/homework-upload/route.ts:1, src/app/api/curator/audio-upload/route.ts:1 | High | S | Ни один upload endpoint не проверяет размер файла перед `file.arrayBuffer()`. Загрузка 1 ГБ файла ляжет в память Node.js. | Добавить проверку `file.size` до 50 МБ (или другого лимита) сразу после `form.get("file")` |
|
||||||
|
| F005 | Observability | src/lib/actions/lesson-actions.ts:24,27,42,48 | Medium | S | 4 `console.log` в production Server Action. Логируют `lessonId` и статус каждого сохранения в prod-консоль. | Убрать все 4. Оставить только `console.error` в catch-блоках. |
|
||||||
|
| F006 | Type & Contract | src/app/admin/courses/actions.ts:28-47 | Medium | M | Server Actions принимают `FormData` и читают поля через `as string` без валидации. Zod установлен, но не используется нигде в проекте. | Добавить Zod-схему на входе `createCourse` и `updateCourse`; повторить паттерн в остальных actions |
|
||||||
|
| F007 | Dependencies | package.json | Medium | S | `npm audit` показывает 3 high severity уязвимости: `next` (сам фреймворк), `fast-uri`, `fast-xml-builder`. | `npm update next` до последнего патча; проверить влияние на остальные зависимости |
|
||||||
|
| F008 | Dependencies | package.json | Low | S | `depcheck` находит 4 неиспользуемые зависимости: `@tailwindcss/typography`, `shadcn`, `tw-animate-css`, `zod`. Если Zod будет использован (F006), убрать оставшиеся три. | `npm remove @tailwindcss/typography shadcn tw-animate-css` |
|
||||||
|
| F009 | Architecture | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:102-106 | Low | S | Функция `formatSize` определена внутри Server Component — при каждом рендере пересоздаётся. Не ошибка, но засоряет файл. | Вынести в `src/lib/utils.ts` |
|
||||||
|
| F010 | Consistency | src/app/admin/courses/[courseId]/actions.ts:12, src/app/admin/categories/actions.ts:11, src/app/admin/settings/actions.ts:11 | Low | S | Разные строки ошибки авторизации: `"Forbidden"` (EN), `"Нет доступа"` (RU), `"Unauthorized"` (EN). Нет единого паттерна. | Выбрать один формат и применить везде; ошибки авторизации не должны уходить клиенту как читаемый текст |
|
||||||
|
| F011 | Security | src/app/layout.tsx:32,37 | Low | — | `dangerouslySetInnerHTML` с `headCode`/`bodyCode` из БД — admin может вставить произвольный JS. | Намеренная функция (code injection для аналитики). Задокументировать явно, что это admin-only привилегия. Добавить проверку роли на странице настроек — уже есть. |
|
||||||
|
| F012 | Testing | — | High | L | Ни одного теста во всём проекте. Нет `*.test.*`, `*.spec.*`, нет `__tests__/`. Горячие файлы (lesson page, lesson-editor) не прикрыты ничем. | Начать с unit-тестов `src/lib/md-to-tiptap.ts` (чистая функция, высокий риск регрессии) и `src/lib/settings.ts`. Для UI — Playwright E2E на login + lesson complete flow. |
|
||||||
|
| F013 | Consistency | src/app/(student)/courses/[slug]/layout.tsx:54, src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:53 | Low | S | Оба файла самостоятельно проверяют `isAdmin` для conditional DB queries с одинаковой логикой. Паттерн не вынесен. | Не критично при текущем размере, но при росте числа маршрутов станет проблемой |
|
||||||
|
| F014 | Documentation | src/proxy.ts:1, CLAUDE.md | Medium | S | CLAUDE.md: `src/middleware.ts — Auth middleware (защита маршрутов)`. Файла не существует, существует `src/proxy.ts`. Документация врёт. | После исправления F001 — обновить CLAUDE.md |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top 5 "fix these first"
|
||||||
|
|
||||||
|
### 1. F001 — Переименовать proxy.ts в middleware.ts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git mv src/proxy.ts src/middleware.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
В `src/middleware.ts`:
|
||||||
|
```ts
|
||||||
|
// было:
|
||||||
|
export function proxy(request: NextRequest) { ... }
|
||||||
|
// стало:
|
||||||
|
export function middleware(request: NextRequest) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
`config` экспорт уже правильный — оставить как есть. Это однострочный фикс с нулевым риском регрессии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. F003 — Двойной `getSettings()` в root layout
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/app/layout.tsx — было: два вызова
|
||||||
|
const settings = await getSettings(); // в generateMetadata
|
||||||
|
const settings = await getSettings(); // в RootLayout
|
||||||
|
|
||||||
|
// стало: обернуть в React.cache
|
||||||
|
// src/lib/settings.ts
|
||||||
|
import { cache } from "react";
|
||||||
|
export const getSettings = cache(async (): Promise<Settings> => { ... });
|
||||||
|
```
|
||||||
|
|
||||||
|
Один `cache()` снимает оба дублированных запроса в рамках одного render pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. F004 — Лимит размера файлов в upload endpoints
|
||||||
|
|
||||||
|
В каждом из 5 upload routes добавить сразу после получения файла:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024; // 50 МБ
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. F005 — Убрать console.log из lesson-actions.ts
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// src/lib/actions/lesson-actions.ts — удалить строки 24, 27, 42, 48
|
||||||
|
console.log("[saveLesson] start", lessonId); // удалить
|
||||||
|
console.log("[saveLesson] auth ok"); // удалить
|
||||||
|
console.log("[saveLesson] db update ok"); // удалить
|
||||||
|
console.log("[saveLesson] done"); // удалить
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. F002 — Устранить двойную загрузку структуры курса
|
||||||
|
|
||||||
|
`layout.tsx` уже загружает все модули/уроки курса для sidebar. `page.tsx` загружает ту же структуру ещё раз для prev/next навигации. Самое чистое решение — передавать `allLessons` через `searchParams` или вычислять в layout и передавать через `slot`:
|
||||||
|
|
||||||
|
Альтернатива проще: убрать из `page.tsx` `include: { modules: { include: { lessons } } }` и принять `prevLessonId`/`nextLessonId` как query params, которые layout прописывает в ссылки sidebar-а.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Wins
|
||||||
|
|
||||||
|
- [ ] **F001** — `git mv src/proxy.ts src/middleware.ts` + переименовать экспорт (5 минут)
|
||||||
|
- [ ] **F003** — `import { cache } from "react"` в settings.ts, обернуть `getSettings` (10 минут)
|
||||||
|
- [ ] **F005** — Удалить 4 строки `console.log` в lesson-actions.ts (2 минуты)
|
||||||
|
- [ ] **F007** — `npm update next` — закрыть CVE в самом фреймворке
|
||||||
|
- [ ] **F008** — `npm remove @tailwindcss/typography shadcn tw-animate-css` — убрать мёртвый вес
|
||||||
|
- [ ] **F004** — Добавить `file.size` проверку в 5 upload routes (15 минут)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Things that look bad but are actually fine
|
||||||
|
|
||||||
|
**`src/generated/prisma/`** — 20+ сгенерированных файлов в `src/`. Выглядит как мусор, но это намеренно: Prisma 7 с Turbopack требует TypeScript-клиента в src для корректной работы RSC. Объяснено в коммите `af8644e` и в memory-файле `project_lms_prisma_config.md`. Не трогать.
|
||||||
|
|
||||||
|
**`src/app/(student)/courses/[slug]/layout.tsx:54`** — `prisma.lessonProgress.findMany({ where: { lessonId: { in: allLessonIds } } })` загружает прогресс по всем урокам курса. Выглядит избыточно, но это единственный способ отрисовать sidebar с чекбоксами без N+1 запроса на каждый урок. Правильный паттерн.
|
||||||
|
|
||||||
|
**`src/lib/auth.ts:37`** — Хардкод `"https://school.second-brain.ru"` в `trustedOrigins`. Выглядит как нарушение правила "нет захардкоженных URL". На самом деле это security-критичный список и он должен быть явным, не конфигурируемым через переменные (иначе можно было бы переопределить в .env). Оставить.
|
||||||
|
|
||||||
|
**`catch (() => {})` в трёх местах** (email в import, S3 delete) — выглядит как проглатывание ошибок. В контексте это правильно: ошибка отправки welcome-email или удаления старого файла из S3 не должна ронять основную операцию (импорт/апгрейд файла).
|
||||||
|
|
||||||
|
**506 строк в `settings-form.tsx`** — формально god-файл, но это один большой конфиг-экран с однородной структурой (Section + Field + Toggle). Файл читается линейно, нет запутанной логики. Декомпозиция не добавит ясности.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **`forgot-password` и `reset-password` маршруты** не входят в `PUBLIC_ROUTES` в `proxy.ts:4`. Это намеренно — эти страницы требуют cookie для валидации токена? Или просто забыто?
|
||||||
|
|
||||||
|
2. **`src/app/api/admin/import-md/route.ts`** — существует, но нет UI для вызова. Мёртвый endpoint или WIP?
|
||||||
|
|
||||||
|
3. **QuizOption** — схема Prisma содержит `QuizOption` с `isCorrect`, но в `page.tsx` урока quiz загружается без `options` (`include: { questions: { orderBy: { order: "asc" } } }`). Тест работает только с open-ended вопросами или options загружаются где-то ещё?
|
||||||
|
|
||||||
|
4. **`load-test.js`** в корне репо — `k6` отмечен как missing dependency в depcheck. Это намеренно отдельный инструмент или планируется CI-интеграция?
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Student Questions — Design Spec
|
||||||
|
_Created: 20260519_
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A support-chat feature inside the LMS. Students ask questions, school staff (admin/curator) answers. Each question is a threaded conversation with open/closed status. Includes file attachments and email notifications for all parties.
|
||||||
|
|
||||||
|
Also in scope: email notifications for homework submissions (new + student updates).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### StudentQuestion
|
||||||
|
```
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String -- student who created it
|
||||||
|
courseId String? -- optional course context
|
||||||
|
title String
|
||||||
|
status QuestionStatus @default(OPEN) -- OPEN | CLOSED
|
||||||
|
closedAt DateTime?
|
||||||
|
closedById String? -- admin/curator who closed
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User
|
||||||
|
course Course?
|
||||||
|
messages StudentQuestionMessage[]
|
||||||
|
```
|
||||||
|
|
||||||
|
### StudentQuestionMessage
|
||||||
|
```
|
||||||
|
id String @id @default(cuid())
|
||||||
|
questionId String
|
||||||
|
authorId String
|
||||||
|
text String
|
||||||
|
files String[] -- S3 paths: questions/{questionId}/{messageId}/{filename}
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
question StudentQuestion
|
||||||
|
author User
|
||||||
|
```
|
||||||
|
|
||||||
|
### QuestionStatus (enum)
|
||||||
|
```
|
||||||
|
OPEN
|
||||||
|
CLOSED
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
### Student
|
||||||
|
| Route | Description |
|
||||||
|
|---|---|
|
||||||
|
| `(student)/questions` | List of own questions with unread indicators |
|
||||||
|
| `(student)/questions/new` | Form to create a new question |
|
||||||
|
| `(student)/questions/[id]` | Thread view — read messages + reply |
|
||||||
|
|
||||||
|
### Admin / Curator
|
||||||
|
| Route | Description |
|
||||||
|
|---|---|
|
||||||
|
| `admin/questions` | Split-view: question list left, thread right |
|
||||||
|
| `curator/questions` | Same split-view (curators have same access as admins here) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Behaviour
|
||||||
|
|
||||||
|
### Student — Questions List (`/questions`)
|
||||||
|
- Header: "Мои вопросы" + "+ Задать вопрос" button (→ `/questions/new`)
|
||||||
|
- Each row: title, message count, last activity, status badge (ОТКРЫТ / ЗАКРЫТ)
|
||||||
|
- Unread indicator: black dot + "Новый ответ от школы" when school replied since last student visit
|
||||||
|
- Closed questions: dimmed (opacity 0.7), grey badge
|
||||||
|
- Active (unread) questions: bold border, bold title
|
||||||
|
|
||||||
|
### Student — Thread (`/questions/[id]`)
|
||||||
|
- Header: question title, created date, status, "← Все вопросы" link
|
||||||
|
- Message bubbles: student messages left (#E8E8E0), school messages right (#F5F5F0 + green border #E8F0D8)
|
||||||
|
- New school message: bold label "🔵 новое" in timestamp
|
||||||
|
- File attachments shown inline under message text (📎 filename · size)
|
||||||
|
- Reply form at bottom: textarea + attach button + send button
|
||||||
|
- Attachment types: jpg, png, pdf, md · max 10 MB
|
||||||
|
- Student CAN reply to closed questions (creates new message, does NOT reopen question)
|
||||||
|
|
||||||
|
### Admin/Curator — Split View (`/admin/questions`, `/curator/questions`)
|
||||||
|
- Left panel (45%): tab filter "Открытые / Закрытые", question list sorted by last activity
|
||||||
|
- Unread: red dot, green-tinted background, bold student name
|
||||||
|
- Right panel (55%): selected thread with full message history + reply form + "✓ Закрыть вопрос" button
|
||||||
|
- Only admin/curator can close a question
|
||||||
|
- Closing a question does NOT prevent further messages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
### → School (admin + all curators)
|
||||||
|
| Trigger | Channel |
|
||||||
|
|---|---|
|
||||||
|
| New question created | Email + admin sidebar badge (count of unread questions) |
|
||||||
|
| Student adds message to existing question | Email |
|
||||||
|
|
||||||
|
### → Student
|
||||||
|
| Trigger | Channel |
|
||||||
|
|---|---|
|
||||||
|
| Admin/curator replies to question | Email |
|
||||||
|
|
||||||
|
### Admin Badge
|
||||||
|
- Sidebar badge shows count of questions with unread messages (school hasn't seen yet)
|
||||||
|
- Separate from homework badge
|
||||||
|
|
||||||
|
### Email for Homework (added to scope)
|
||||||
|
| Trigger | Recipient |
|
||||||
|
|---|---|
|
||||||
|
| New HomeworkSubmission created | Admin + all curators |
|
||||||
|
| Student updates existing submission (adds text/file) | Admin + all curators |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Storage
|
||||||
|
|
||||||
|
- Path pattern: `questions/{questionId}/{messageId}/{filename}`
|
||||||
|
- Reuse existing S3 upload infrastructure (`src/lib/s3.ts`)
|
||||||
|
- Allowed: jpg, png, pdf, md
|
||||||
|
- Max size: 10 MB per file
|
||||||
|
- No limit on number of files per message (reasonable: 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read Tracking
|
||||||
|
|
||||||
|
- `isRead` flag per message, set to `true` when the OTHER party opens the thread
|
||||||
|
- Student opens `/questions/[id]` → all school messages in that thread marked `isRead = true`
|
||||||
|
- Admin/curator opens a question in split-view → all student messages marked `isRead = true`
|
||||||
|
- Admin badge recalculates on each page load (count questions where latest student message is unread)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/questions -- create question
|
||||||
|
GET /api/questions -- list own questions (student) or all (admin/curator)
|
||||||
|
GET /api/questions/[id] -- get question + messages
|
||||||
|
POST /api/questions/[id]/messages -- add message + files
|
||||||
|
PATCH /api/questions/[id]/close -- close question (admin/curator only)
|
||||||
|
POST /api/upload/question-file -- upload attachment, returns S3 path
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (this iteration)
|
||||||
|
- Question categories / tags
|
||||||
|
- Assigning question to a specific curator
|
||||||
|
- Email threading (reply-to email to answer)
|
||||||
|
- Push/browser notifications
|
||||||
|
- Question templates
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
|||||||
|
import http from "k6/http";
|
||||||
|
import { check, sleep } from "k6";
|
||||||
|
|
||||||
|
const BASE = "https://school.second-brain.ru";
|
||||||
|
|
||||||
|
// Реальные lesson IDs курса obsidian (опубликованные уроки)
|
||||||
|
const LESSONS = [
|
||||||
|
"c729fjgtrl0tfowh49jh55uak",
|
||||||
|
"ctxca16mjamn5bh2exa3dxltg",
|
||||||
|
"c1f130hwjgks3zm4ohrcneueh",
|
||||||
|
"cn3bahic20cdxj9ih4cxr8tjl",
|
||||||
|
"c2usfe6rwoqcombd9veaalvgj",
|
||||||
|
"clil8czg79uqmqtexw8e5cede",
|
||||||
|
"c0ej1a3wrueg60d1oew2j8ky6",
|
||||||
|
"cypv15bq07deuyi2tjb556n52",
|
||||||
|
"c7v4qdnowy7i6y7pp361dwne3",
|
||||||
|
"c3l9ox9xvd5qyv5mt2pd7if2x",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEST_USER = {
|
||||||
|
email: "loadtest@second-brain.ru",
|
||||||
|
password: "LoadTest2025!",
|
||||||
|
};
|
||||||
|
|
||||||
|
const BROWSER_HEADERS = {
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||||
|
"Accept-Language": "ru-RU,ru;q=0.9",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const options = {
|
||||||
|
stages: [
|
||||||
|
{ duration: "30s", target: 10 }, // разгон до 10 пользователей
|
||||||
|
{ duration: "1m", target: 50 }, // разгон до 50
|
||||||
|
{ duration: "1m", target: 100 }, // разгон до 100
|
||||||
|
{ duration: "3m", target: 100 }, // держим 100 три минуты
|
||||||
|
{ duration: "30s", target: 0 }, // спад
|
||||||
|
],
|
||||||
|
thresholds: {
|
||||||
|
http_req_duration: ["p(95)<3000"], // 95% запросов быстрее 3 секунд
|
||||||
|
http_req_failed: ["rate<0.05"], // ошибок меньше 5%
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Переменная уровня VU — логин один раз на весь жизненный цикл VU.
|
||||||
|
let isLoggedIn = false;
|
||||||
|
|
||||||
|
export default function () {
|
||||||
|
// http.cookieJar() без аргументов — jar уровня VU, сохраняется между итерациями.
|
||||||
|
const jar = http.cookieJar();
|
||||||
|
|
||||||
|
// 1. Логин — только при первой итерации VU
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
// Случайная задержка 0-10s: распределяем 100 логинов во времени,
|
||||||
|
// иначе все VU стартуют одновременно и бьют rate-limit Better Auth.
|
||||||
|
sleep(Math.random() * 10);
|
||||||
|
|
||||||
|
const loginRes = http.post(
|
||||||
|
`${BASE}/api/auth/sign-in/email`,
|
||||||
|
JSON.stringify({ email: TEST_USER.email, password: TEST_USER.password }),
|
||||||
|
{ headers: { "Content-Type": "application/json" }, jar }
|
||||||
|
);
|
||||||
|
check(loginRes, { "login 200": (r) => r.status === 200 });
|
||||||
|
|
||||||
|
if (loginRes.status !== 200) {
|
||||||
|
sleep(5); // пауза при неудаче, не штурмуем auth endpoint
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isLoggedIn = true;
|
||||||
|
sleep(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Дашборд студента
|
||||||
|
const dashRes = http.get(`${BASE}/dashboard`, { jar, headers: BROWSER_HEADERS });
|
||||||
|
check(dashRes, { "dashboard 200": (r) => r.status === 200 });
|
||||||
|
sleep(1);
|
||||||
|
|
||||||
|
// 3. Страница курса
|
||||||
|
const courseRes = http.get(`${BASE}/courses/obsidian`, { jar, headers: BROWSER_HEADERS });
|
||||||
|
check(courseRes, { "course page 200": (r) => r.status === 200 });
|
||||||
|
sleep(2);
|
||||||
|
|
||||||
|
// 4. Открыть 3 случайных урока (имитация чтения)
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const lessonId = LESSONS[Math.floor(Math.random() * LESSONS.length)];
|
||||||
|
const lessonRes = http.get(
|
||||||
|
`${BASE}/courses/obsidian/lessons/${lessonId}`,
|
||||||
|
{ jar, headers: BROWSER_HEADERS }
|
||||||
|
);
|
||||||
|
check(lessonRes, { "lesson page 200": (r) => r.status === 200 });
|
||||||
|
sleep(Math.random() * 3 + 2); // студент "читает" 2-5 секунд
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import type { NextConfig } from "next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
|
transpilePackages: ["unified", "remark-parse"],
|
||||||
|
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+821
-2998
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -19,12 +19,13 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
|
||||||
"@tiptap/extension-image": "^3.22.2",
|
"@tiptap/extension-image": "^3.22.2",
|
||||||
"@tiptap/extension-link": "^3.22.2",
|
"@tiptap/extension-link": "^3.22.2",
|
||||||
"@tiptap/extension-placeholder": "^3.22.2",
|
"@tiptap/extension-placeholder": "^3.22.2",
|
||||||
|
"@tiptap/extension-underline": "^3.22.2",
|
||||||
"@tiptap/pm": "^3.22.2",
|
"@tiptap/pm": "^3.22.2",
|
||||||
"@tiptap/react": "^3.22.2",
|
"@tiptap/react": "^3.22.2",
|
||||||
"@tiptap/starter-kit": "^3.22.2",
|
"@tiptap/starter-kit": "^3.22.2",
|
||||||
@@ -32,17 +33,19 @@
|
|||||||
"better-auth": "^1.6.0",
|
"better-auth": "^1.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.10.0",
|
||||||
"shadcn": "^4.1.2",
|
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"unified": "^11.0.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- CreateTable: Category
|
||||||
|
CREATE TABLE "Category" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"order" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||||
|
|
||||||
|
-- Add categoryId to Course
|
||||||
|
ALTER TABLE "Course" ADD COLUMN "categoryId" TEXT;
|
||||||
|
ALTER TABLE "Course" ADD CONSTRAINT "Course_categoryId_fkey"
|
||||||
|
FOREIGN KEY ("categoryId") REFERENCES "Category"("id")
|
||||||
|
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Add expiresAt to CourseEnrollment
|
||||||
|
ALTER TABLE "CourseEnrollment" ADD COLUMN "expiresAt" TIMESTAMP(3);
|
||||||
|
|
||||||
|
-- CreateTable: AccessLog
|
||||||
|
CREATE TABLE "AccessLog" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"courseId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL,
|
||||||
|
"method" TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
"grantedById" TEXT,
|
||||||
|
"note" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_courseId_fkey"
|
||||||
|
FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_userId_fkey"
|
||||||
|
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_grantedById_fkey"
|
||||||
|
FOREIGN KEY ("grantedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Settings" (
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Settings_pkey" PRIMARY KEY ("key")
|
||||||
|
);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "Module" ADD COLUMN "description" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "HomeworkSubmission" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING';
|
||||||
|
ALTER TABLE "HomeworkSubmission" ADD COLUMN "statusAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "HomeworkFeedback" ADD COLUMN "files" JSONB;
|
||||||
|
ALTER TABLE "HomeworkFeedback" ADD COLUMN "audioUrl" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "Course" ADD COLUMN "allowAudio" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE "HomeworkSubmission" ADD COLUMN "audioUrl" TEXT;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "birthday" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "LessonComment" ADD COLUMN "parentId" TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE "LessonComment" ADD CONSTRAINT "LessonComment_parentId_fkey"
|
||||||
|
FOREIGN KEY ("parentId") REFERENCES "LessonComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "comment" TEXT;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "BalanceTransaction" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"amount" DECIMAL(10,2) NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "BalanceTransaction_pkey" PRIMARY KEY ("id"),
|
||||||
|
CONSTRAINT "BalanceTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "BalanceTransaction_userId_idx" ON "BalanceTransaction"("userId");
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "QuestionStatus" AS ENUM ('OPEN', 'CLOSED');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StudentQuestion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"courseId" TEXT,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"status" "QuestionStatus" NOT NULL DEFAULT 'OPEN',
|
||||||
|
"closedAt" TIMESTAMP(3),
|
||||||
|
"closedById" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "StudentQuestion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StudentQuestionMessage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"questionId" TEXT NOT NULL,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"text" TEXT NOT NULL,
|
||||||
|
"files" JSONB,
|
||||||
|
"isRead" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "StudentQuestionMessage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "StudentQuestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudentQuestion_userId_idx" ON "StudentQuestion"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudentQuestion_status_idx" ON "StudentQuestion"("status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudentQuestionMessage_questionId_idx" ON "StudentQuestionMessage"("questionId");
|
||||||
+149
-30
@@ -18,30 +18,40 @@ model User {
|
|||||||
emailVerified Boolean @default(false)
|
emailVerified Boolean @default(false)
|
||||||
image String?
|
image String?
|
||||||
role String @default("student") // student | curator | admin
|
role String @default("student") // student | curator | admin
|
||||||
|
phone String?
|
||||||
|
birthday DateTime?
|
||||||
banned Boolean? @default(false)
|
banned Boolean? @default(false)
|
||||||
banReason String?
|
banReason String?
|
||||||
banExpires DateTime?
|
banExpires DateTime?
|
||||||
|
comment String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
enrollments CourseEnrollment[]
|
enrollments CourseEnrollment[]
|
||||||
progress LessonProgress[]
|
progress LessonProgress[]
|
||||||
submissions HomeworkSubmission[]
|
submissions HomeworkSubmission[]
|
||||||
comments LessonComment[]
|
comments LessonComment[]
|
||||||
feedbacks HomeworkFeedback[]
|
feedbacks HomeworkFeedback[]
|
||||||
|
accessLogs AccessLog[] @relation("AccessLogUser")
|
||||||
|
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
||||||
|
balanceTransactions BalanceTransaction[]
|
||||||
|
questions StudentQuestion[]
|
||||||
|
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
|
||||||
|
questionMessages StudentQuestionMessage[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
token String @unique
|
token String @unique
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
ipAddress String?
|
ipAddress String?
|
||||||
userAgent String?
|
userAgent String?
|
||||||
createdAt DateTime @default(now())
|
impersonatedBy String?
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
}
|
}
|
||||||
@@ -77,6 +87,16 @@ model Verification {
|
|||||||
// LMS core tables
|
// LMS core tables
|
||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
title String
|
||||||
|
slug String @unique
|
||||||
|
order Int @default(0)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
courses Course[]
|
||||||
|
}
|
||||||
|
|
||||||
model Course {
|
model Course {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
slug String @unique
|
slug String @unique
|
||||||
@@ -84,21 +104,27 @@ model Course {
|
|||||||
description String?
|
description String?
|
||||||
coverImage String?
|
coverImage String?
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
|
allowAudio Boolean @default(false)
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
|
categoryId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||||
modules Module[]
|
modules Module[]
|
||||||
enrollments CourseEnrollment[]
|
enrollments CourseEnrollment[]
|
||||||
|
accessLogs AccessLog[]
|
||||||
|
questions StudentQuestion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Module {
|
model Module {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
courseId String
|
courseId String
|
||||||
title String
|
title String
|
||||||
order Int @default(0)
|
description String?
|
||||||
createdAt DateTime @default(now())
|
order Int @default(0)
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
lessons Lesson[]
|
lessons Lesson[]
|
||||||
@@ -110,6 +136,7 @@ model Lesson {
|
|||||||
title String
|
title String
|
||||||
content Json?
|
content Json?
|
||||||
kinescopeId String?
|
kinescopeId String?
|
||||||
|
coverImage String?
|
||||||
order Int @default(0)
|
order Int @default(0)
|
||||||
published Boolean @default(false)
|
published Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -137,7 +164,8 @@ model LessonFile {
|
|||||||
model CourseEnrollment {
|
model CourseEnrollment {
|
||||||
userId String
|
userId String
|
||||||
courseId String
|
courseId String
|
||||||
enrolledAt DateTime @default(now())
|
enrolledAt DateTime @default(now())
|
||||||
|
expiresAt DateTime?
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
@@ -145,6 +173,21 @@ model CourseEnrollment {
|
|||||||
@@id([userId, courseId])
|
@@id([userId, courseId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AccessLog {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
courseId String
|
||||||
|
userId String
|
||||||
|
action String // "granted" | "revoked"
|
||||||
|
method String @default("manual")
|
||||||
|
grantedById String?
|
||||||
|
note String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation("AccessLogUser", fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
grantedBy User? @relation("AccessLogAdmin", fields: [grantedById], references: [id], onDelete: SetNull)
|
||||||
|
}
|
||||||
|
|
||||||
model LessonProgress {
|
model LessonProgress {
|
||||||
userId String
|
userId String
|
||||||
lessonId String
|
lessonId String
|
||||||
@@ -226,12 +269,15 @@ model Homework {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model HomeworkSubmission {
|
model HomeworkSubmission {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
homeworkId String
|
homeworkId String
|
||||||
userId String
|
userId String
|
||||||
text String?
|
text String?
|
||||||
files Json?
|
files Json?
|
||||||
submittedAt DateTime @default(now())
|
status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED
|
||||||
|
statusAt DateTime?
|
||||||
|
audioUrl String?
|
||||||
|
submittedAt DateTime @default(now())
|
||||||
|
|
||||||
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
@@ -243,6 +289,8 @@ model HomeworkFeedback {
|
|||||||
submissionId String
|
submissionId String
|
||||||
curatorId String
|
curatorId String
|
||||||
text String
|
text String
|
||||||
|
files Json? // [{name, url, size}]
|
||||||
|
audioUrl String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
||||||
@@ -260,7 +308,78 @@ model LessonComment {
|
|||||||
text String
|
text String
|
||||||
deleted Boolean @default(false)
|
deleted Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
parentId String?
|
||||||
|
|
||||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
parent LessonComment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
|
||||||
|
replies LessonComment[] @relation("CommentReplies")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Student Questions
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
enum QuestionStatus {
|
||||||
|
OPEN
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
model StudentQuestion {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
courseId String?
|
||||||
|
title String
|
||||||
|
status QuestionStatus @default(OPEN)
|
||||||
|
closedAt DateTime?
|
||||||
|
closedById String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
|
||||||
|
closedBy User? @relation("QuestionClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
|
||||||
|
messages StudentQuestionMessage[]
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
model StudentQuestionMessage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
questionId String
|
||||||
|
authorId String
|
||||||
|
text String
|
||||||
|
files Json? // [{name, url, size}]
|
||||||
|
isRead Boolean @default(false)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
question StudentQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
|
||||||
|
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([questionId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Balance
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
model BalanceTransaction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
amount Decimal @db.Decimal(10, 2)
|
||||||
|
description String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Platform Settings (key-value store)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Settings {
|
||||||
|
key String @id
|
||||||
|
value String @db.Text
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
# Настройка бекапов на сервере
|
||||||
|
|
||||||
|
## Что бекапится
|
||||||
|
- **PostgreSQL** → дамп каждую ночь → Backblaze B2
|
||||||
|
- **S3-файлы** (Hetzner Object Storage) → синхронизация → Backblaze B2
|
||||||
|
- Хранение: последние 7 дневных дампов БД + все файлы (sync зеркало)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 1 — Backblaze B2: создать bucket и ключи
|
||||||
|
|
||||||
|
1. Зарегистрироваться на https://www.backblaze.com/b2/
|
||||||
|
2. **Buckets → Create a Bucket**:
|
||||||
|
- Name: `lms-backups-second-brain`
|
||||||
|
- Files in Bucket are: `Private`
|
||||||
|
3. **App Keys → Add a New Application Key**:
|
||||||
|
- Name: `lms-server`
|
||||||
|
- Access: `Read and Write`
|
||||||
|
- Bucket: `lms-backups-second-brain`
|
||||||
|
- Сохранить `keyID` и `applicationKey` — показываются один раз
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 2 — Установить rclone на сервере
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://rclone.org/install.sh | sudo bash
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 3 — Настроить rclone: Backblaze B2
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rclone config
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответы:
|
||||||
|
```
|
||||||
|
n (новый remote)
|
||||||
|
name: b2lms
|
||||||
|
type: b2
|
||||||
|
account: <keyID из шага 1>
|
||||||
|
key: <applicationKey из шага 1>
|
||||||
|
<Enter для остальных — defaults>
|
||||||
|
q (quit)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 4 — Настроить rclone: Hetzner S3
|
||||||
|
|
||||||
|
Значения берём из `.env` на сервере.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rclone config
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответы:
|
||||||
|
```
|
||||||
|
n
|
||||||
|
name: hetzner
|
||||||
|
type: s3
|
||||||
|
provider: Other
|
||||||
|
env_auth: false
|
||||||
|
access_key_id: <S3_ACCESS_KEY>
|
||||||
|
secret_access_key: <S3_SECRET_KEY>
|
||||||
|
region: <пусто — Enter>
|
||||||
|
endpoint: <S3_ENDPOINT, например: fsn1.your-objectstorage.com>
|
||||||
|
<Enter для остальных>
|
||||||
|
q
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверить:
|
||||||
|
```bash
|
||||||
|
rclone ls hetzner:lms-uploads
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 5 — Установить скрипт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/lms-backup
|
||||||
|
sudo cp scripts/backup.sh /opt/lms-backup/backup.sh
|
||||||
|
sudo chmod +x /opt/lms-backup/backup.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 6 — Настроить cron
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo crontab -e
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавить строку (запуск каждую ночь в 3:00):
|
||||||
|
```
|
||||||
|
0 3 * * * /opt/lms-backup/backup.sh >> /var/log/lms-backup.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Шаг 7 — Проверить вручную
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo /opt/lms-backup/backup.sh
|
||||||
|
tail -50 /var/log/lms-backup.log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Восстановление из бекапа
|
||||||
|
|
||||||
|
### База данных
|
||||||
|
```bash
|
||||||
|
# Скачать нужный дамп с B2
|
||||||
|
rclone copy b2lms:lms-backups-second-brain/db/db_20260408_0300.sql.gz /tmp/
|
||||||
|
|
||||||
|
# Восстановить в контейнер
|
||||||
|
gunzip -c /tmp/db_20260408_0300.sql.gz \
|
||||||
|
| docker exec -i lms-system-db-1 psql -U lms_user lms_db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Файлы
|
||||||
|
```bash
|
||||||
|
# Синхронизировать файлы обратно на Hetzner S3
|
||||||
|
rclone sync b2lms:lms-backups-second-brain/files hetzner:lms-uploads
|
||||||
|
```
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# LMS Second Brain — Backup Script
|
||||||
|
# Backs up PostgreSQL (from Docker) + S3 files to Backblaze B2
|
||||||
|
# Place at: /opt/lms-backup/backup.sh on the server
|
||||||
|
# Cron: 0 3 * * * /opt/lms-backup/backup.sh >> /var/log/lms-backup.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ───────────────────────────────────────────────────────────────────
|
||||||
|
DB_CONTAINER="lms-system-db-1"
|
||||||
|
DB_USER="lms_user"
|
||||||
|
DB_NAME="lms_db"
|
||||||
|
|
||||||
|
BACKUP_DIR="/tmp/lms-backups"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M)
|
||||||
|
DUMP_FILE="${BACKUP_DIR}/db_${DATE}.sql.gz"
|
||||||
|
|
||||||
|
# B2 rclone remote name (configured via: rclone config)
|
||||||
|
B2_REMOTE="b2lms"
|
||||||
|
B2_BUCKET="lms-backups-second-brain"
|
||||||
|
B2_DB_PATH="${B2_REMOTE}:${B2_BUCKET}/db"
|
||||||
|
B2_FILES_PATH="${B2_REMOTE}:${B2_BUCKET}/files"
|
||||||
|
|
||||||
|
# Hetzner S3 rclone remote name
|
||||||
|
S3_REMOTE="hetzner"
|
||||||
|
S3_BUCKET="lms-uploads"
|
||||||
|
|
||||||
|
# Retention: keep last N daily backups
|
||||||
|
KEEP_DAYS=7
|
||||||
|
|
||||||
|
# ── Functions ─────────────────────────────────────────────────────────────────
|
||||||
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
log "=== LMS Backup started ==="
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# 1. PostgreSQL dump
|
||||||
|
log "Dumping PostgreSQL from container ${DB_CONTAINER}..."
|
||||||
|
docker exec "$DB_CONTAINER" \
|
||||||
|
pg_dump -U "$DB_USER" "$DB_NAME" \
|
||||||
|
| gzip > "$DUMP_FILE"
|
||||||
|
log "Dump created: ${DUMP_FILE} ($(du -sh "$DUMP_FILE" | cut -f1))"
|
||||||
|
|
||||||
|
# 2. Upload DB dump to B2
|
||||||
|
log "Uploading DB dump to Backblaze B2..."
|
||||||
|
rclone copy "$DUMP_FILE" "$B2_DB_PATH"
|
||||||
|
log "DB dump uploaded: ${B2_DB_PATH}/$(basename "$DUMP_FILE")"
|
||||||
|
|
||||||
|
# 3. Sync S3 files to B2
|
||||||
|
log "Syncing S3 files to Backblaze B2..."
|
||||||
|
rclone sync \
|
||||||
|
"${S3_REMOTE}:${S3_BUCKET}" \
|
||||||
|
"$B2_FILES_PATH" \
|
||||||
|
--progress \
|
||||||
|
--transfers=8
|
||||||
|
log "S3 files synced to ${B2_FILES_PATH}"
|
||||||
|
|
||||||
|
# 4. Cleanup local temp files
|
||||||
|
rm -f "$DUMP_FILE"
|
||||||
|
log "Local temp files cleaned"
|
||||||
|
|
||||||
|
# 5. Prune old DB backups on B2 (keep last KEEP_DAYS)
|
||||||
|
log "Pruning DB backups older than ${KEEP_DAYS} days..."
|
||||||
|
rclone delete "$B2_DB_PATH" \
|
||||||
|
--min-age "${KEEP_DAYS}d" \
|
||||||
|
--include "db_*.sql.gz"
|
||||||
|
log "Pruning done"
|
||||||
|
|
||||||
|
log "=== LMS Backup finished successfully ==="
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ForgotPasswordForm() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [sent, setSent] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
await authClient.requestPasswordReset({
|
||||||
|
email,
|
||||||
|
redirectTo: "/reset-password",
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
setSent(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 text-center">
|
||||||
|
<p className="text-sm" style={{ color: "var(--foreground)" }}>
|
||||||
|
Письмо со ссылкой для сброса пароля отправлено на{" "}
|
||||||
|
<strong>{email}</strong>.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Проверьте папку «Спам», если письмо не пришло в течение пары минут.
|
||||||
|
</p>
|
||||||
|
<Link href="/login" className="block text-xs underline mt-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Вернуться к входу
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Введите email — мы пришлём ссылку для задания нового пароля.
|
||||||
|
</p>
|
||||||
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-aubade w-full justify-center"
|
||||||
|
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
|
||||||
|
>
|
||||||
|
{loading ? "Отправка..." : "Сбросить пароль"}
|
||||||
|
</button>
|
||||||
|
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>
|
||||||
|
Вернуться к входу
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { getSetting } from "@/lib/settings";
|
||||||
|
import { ForgotPasswordForm } from "./forgot-password-form";
|
||||||
|
|
||||||
|
export default async function ForgotPasswordPage() {
|
||||||
|
const schoolName = await getSetting("schoolName");
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
|
||||||
|
{schoolName}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
|
||||||
|
Образовательная платформа
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="card-aubade p-8">
|
||||||
|
<ForgotPasswordForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,9 +39,9 @@ export function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -49,12 +49,20 @@ export function LoginForm() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-1.5">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Пароль
|
Пароль
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -62,24 +70,39 @@ export function LoginForm() {
|
|||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
outline: "none",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
{error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--destructive)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
|
className="btn-aubade w-full justify-center"
|
||||||
|
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
|
||||||
>
|
>
|
||||||
{loading ? "Вход..." : "Войти"}
|
{loading ? "Вход..." : "Войти"}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-center text-sm text-gray-500">
|
<div className="flex items-center justify-between text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Нет аккаунта?{" "}
|
<Link href="/forgot-password" className="underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/register" className="text-amber-600 hover:underline">
|
Забыли пароль?
|
||||||
|
</Link>
|
||||||
|
<Link href="/register" className="underline" style={{ color: "var(--foreground)" }}>
|
||||||
Зарегистрироваться
|
Зарегистрироваться
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
|
import { getSetting } from "@/lib/settings";
|
||||||
import { LoginForm } from "./login-form";
|
import { LoginForm } from "./login-form";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default async function LoginPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ notice?: string }>;
|
||||||
|
}) {
|
||||||
|
const [schoolName, { notice }] = await Promise.all([
|
||||||
|
getSetting("schoolName"),
|
||||||
|
searchParams,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-amber-50">
|
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-sm">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
|
||||||
<p className="text-amber-700 mt-2">Войдите в свой аккаунт</p>
|
{schoolName}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
|
||||||
|
Образовательная платформа
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
|
{notice === "password_reset" && (
|
||||||
|
<div
|
||||||
|
className="mb-4 px-4 py-3 text-sm text-center"
|
||||||
|
style={{ border: "2px solid var(--border)", color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
Пароль успешно задан. Войдите с новым паролем.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{notice === "registration_closed" && (
|
||||||
|
<div
|
||||||
|
className="mb-4 px-4 py-3 text-sm text-center"
|
||||||
|
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Регистрация временно закрыта. Обратитесь к администратору.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="card-aubade p-8">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
import { RegisterForm } from "./register-form";
|
import { RegisterForm } from "./register-form";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default async function RegisterPage() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
if (settings.registrationEnabled !== "true") {
|
||||||
|
redirect("/login?notice=registration_closed");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-amber-50">
|
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-sm">
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
|
||||||
<p className="text-amber-700 mt-2">Создайте аккаунт</p>
|
{settings.schoolName}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
|
||||||
|
Образовательная платформа
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
|
<div className="card-aubade p-8">
|
||||||
<RegisterForm />
|
<RegisterForm
|
||||||
|
showTermsCheckbox={settings.showTermsCheckbox === "true"}
|
||||||
|
privacyPolicyUrl={settings.privacyPolicyUrl}
|
||||||
|
termsUrl={settings.termsUrl}
|
||||||
|
offerUrl={settings.offerUrl}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,47 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { signUp } from "@/lib/auth-client";
|
import { signUp } from "@/lib/auth-client";
|
||||||
|
|
||||||
export function RegisterForm() {
|
interface Props {
|
||||||
const router = useRouter();
|
showTermsCheckbox: boolean;
|
||||||
|
privacyPolicyUrl: string;
|
||||||
|
termsUrl: string;
|
||||||
|
offerUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
outline: "none",
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
} as React.CSSProperties;
|
||||||
|
|
||||||
|
const legalLinks = [
|
||||||
|
{ url: privacyPolicyUrl, label: "Политику конфиденциальности" },
|
||||||
|
{ url: termsUrl, label: "Согласие на обработку данных" },
|
||||||
|
{ url: offerUrl, label: "Договор-оферту" },
|
||||||
|
].filter((l) => l.url);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (showTermsCheckbox && !termsAccepted) {
|
||||||
|
setError("Необходимо принять условия для продолжения");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setError("");
|
setError("");
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
@@ -35,14 +61,11 @@ export function RegisterForm() {
|
|||||||
return (
|
return (
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<div className="text-4xl">✉️</div>
|
<div className="text-4xl">✉️</div>
|
||||||
<h2 className="text-xl font-semibold text-gray-800">
|
<p className="font-bold">Проверьте почту</p>
|
||||||
Проверьте почту
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
</h2>
|
Мы отправили письмо на <strong>{email}</strong> для подтверждения аккаунта.
|
||||||
<p className="text-gray-500">
|
|
||||||
Мы отправили письмо на <strong>{email}</strong> для подтверждения
|
|
||||||
аккаунта.
|
|
||||||
</p>
|
</p>
|
||||||
<Link href="/login" className="text-amber-600 hover:underline text-sm">
|
<Link href="/login" className="text-sm underline" style={{ color: "var(--foreground)" }}>
|
||||||
Вернуться к входу
|
Вернуться к входу
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,8 +74,8 @@ export function RegisterForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Имя
|
Имя
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -60,12 +83,14 @@ export function RegisterForm() {
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
placeholder="Иван Иванов"
|
placeholder="Иван Иванов"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Email
|
Email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -73,12 +98,14 @@ export function RegisterForm() {
|
|||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
placeholder="you@example.com"
|
placeholder="you@example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-1">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Пароль
|
Пароль
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -87,21 +114,60 @@ export function RegisterForm() {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
minLength={8}
|
minLength={8}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
placeholder="Минимум 8 символов"
|
placeholder="Минимум 8 символов"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
|
||||||
|
{showTermsCheckbox && (
|
||||||
|
<label className="flex items-start gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={termsAccepted}
|
||||||
|
onChange={(e) => setTermsAccepted(e.target.checked)}
|
||||||
|
className="mt-0.5 flex-shrink-0"
|
||||||
|
style={{ width: "16px", height: "16px", accentColor: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Я принимаю{" "}
|
||||||
|
{legalLinks.length > 0
|
||||||
|
? legalLinks.map((l, i) => (
|
||||||
|
<span key={l.url}>
|
||||||
|
<a
|
||||||
|
href={l.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
{i < legalLinks.length - 1 ? ", " : ""}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
: "условия использования платформы"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
|
className="btn-aubade btn-aubade-accent w-full py-2 text-sm"
|
||||||
|
style={{ opacity: loading ? 0.6 : 1 }}
|
||||||
>
|
>
|
||||||
{loading ? "Регистрация..." : "Зарегистрироваться"}
|
{loading ? "Регистрация..." : "Зарегистрироваться"}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-center text-sm text-gray-500">
|
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Уже есть аккаунт?{" "}
|
Уже есть аккаунт?{" "}
|
||||||
<Link href="/login" className="text-amber-600 hover:underline">
|
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>
|
||||||
Войти
|
Войти
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { getSetting } from "@/lib/settings";
|
||||||
|
import { ResetPasswordForm } from "./reset-password-form";
|
||||||
|
|
||||||
|
export default async function ResetPasswordPage() {
|
||||||
|
const schoolName = await getSetting("schoolName");
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
|
||||||
|
{schoolName}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
|
||||||
|
Образовательная платформа
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="card-aubade p-8">
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<ResetPasswordForm />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { authClient } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResetPasswordForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const token = searchParams.get("token") ?? "";
|
||||||
|
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirm, setConfirm] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Ссылка недействительна или устарела.
|
||||||
|
</p>
|
||||||
|
<Link href="/forgot-password" className="text-xs underline" style={{ color: "var(--foreground)" }}>
|
||||||
|
Запросить новую ссылку
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
if (password !== confirm) {
|
||||||
|
setError("Пароли не совпадают");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError("Пароль должен быть не короче 8 символов");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
const result = await authClient.resetPassword({ newPassword: password, token });
|
||||||
|
setLoading(false);
|
||||||
|
if (result.error) {
|
||||||
|
setError("Ссылка устарела или уже использована. Запросите новую.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
router.push("/login?notice=password_reset");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Задайте новый пароль для вашего аккаунта.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Новый пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Повторите пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={confirm}
|
||||||
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-aubade w-full justify-center"
|
||||||
|
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
|
||||||
|
>
|
||||||
|
{loading ? "Сохранение..." : "Сохранить пароль"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { CourseSidebar } from "@/components/student/course-sidebar";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CourseLayout({ children, params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const isAdmin = session.user.role === "admin";
|
||||||
|
|
||||||
|
const course = await prisma.course.findUnique({
|
||||||
|
where: { slug, ...(isAdmin ? {} : { published: true }) },
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
lessons: {
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) notFound();
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
const enrollment = await prisma.courseEnrollment.findUnique({
|
||||||
|
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enrollment) redirect("/dashboard");
|
||||||
|
|
||||||
|
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) {
|
||||||
|
redirect("/dashboard?expired=1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch completed lesson IDs for this user
|
||||||
|
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id));
|
||||||
|
const progressRecords = isAdmin
|
||||||
|
? []
|
||||||
|
: await prisma.lessonProgress.findMany({
|
||||||
|
where: { userId: session.user.id, lessonId: { in: allLessonIds } },
|
||||||
|
select: { lessonId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedLessonIds = new Set(progressRecords.map((p) => p.lessonId));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1">
|
||||||
|
<CourseSidebar course={course} completedLessonIds={completedLessonIds} />
|
||||||
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
<div className="h-12 lg:hidden" />
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function toggleLessonProgress(lessonId: string, slug: string) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const existing = await prisma.lessonProgress.findUnique({
|
||||||
|
where: { userId_lessonId: { userId: session.user.id, lessonId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.lessonProgress.delete({
|
||||||
|
where: { userId_lessonId: { userId: session.user.id, lessonId } },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.lessonProgress.create({
|
||||||
|
data: { userId: session.user.id, lessonId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
|
revalidatePath(`/courses/${slug}`);
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function addComment(lessonId: string, slug: string, text: string, parentId?: string) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
|
||||||
|
|
||||||
|
// Verify user has access to this lesson's course
|
||||||
|
const lesson = await prisma.lesson.findUnique({
|
||||||
|
where: { id: lessonId },
|
||||||
|
select: { module: { select: { course: { select: { id: true } } } } },
|
||||||
|
});
|
||||||
|
if (!lesson) throw new Error("Lesson not found");
|
||||||
|
|
||||||
|
const isAdmin = session.user.role === "admin";
|
||||||
|
const isCurator = session.user.role === "curator";
|
||||||
|
if (!isAdmin && !isCurator) {
|
||||||
|
const enrollment = await prisma.courseEnrollment.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_courseId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
courseId: lesson.module.course.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!enrollment) throw new Error("Not enrolled");
|
||||||
|
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId) {
|
||||||
|
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
||||||
|
const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } });
|
||||||
|
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.lessonComment.create({
|
||||||
|
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteComment(commentId: string, lessonId: string, slug: string) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
|
||||||
|
if (!comment) throw new Error("Not found");
|
||||||
|
|
||||||
|
const canDelete =
|
||||||
|
comment.userId === session.user.id ||
|
||||||
|
session.user.role === "curator" ||
|
||||||
|
session.user.role === "admin";
|
||||||
|
if (!canDelete) throw new Error("Forbidden");
|
||||||
|
|
||||||
|
await prisma.lessonComment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: { deleted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { sendHomeworkSubmittedEmail, sendHomeworkUpdatedEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
interface HomeworkFile {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitHomework(
|
||||||
|
homeworkId: string,
|
||||||
|
slug: string,
|
||||||
|
lessonId: string,
|
||||||
|
text: string,
|
||||||
|
files: HomeworkFile[],
|
||||||
|
audioUrl?: string | null
|
||||||
|
) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
|
||||||
|
const existing = await prisma.homeworkSubmission.findFirst({
|
||||||
|
where: { homeworkId, userId: session.user.id },
|
||||||
|
include: { feedbacks: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't allow resubmission if feedback already given
|
||||||
|
if (existing?.feedbacks && existing.feedbacks.length > 0) {
|
||||||
|
throw new Error("Работа уже проверена");
|
||||||
|
}
|
||||||
|
|
||||||
|
let submissionId: string;
|
||||||
|
if (existing) {
|
||||||
|
const updated = await prisma.homeworkSubmission.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
|
||||||
|
});
|
||||||
|
submissionId = updated.id;
|
||||||
|
|
||||||
|
// Notify admins/curators when student edits an existing submission
|
||||||
|
const [lessonRecord, staff] = await Promise.all([
|
||||||
|
prisma.homework.findUnique({
|
||||||
|
where: { id: homeworkId },
|
||||||
|
include: { lesson: { select: { title: true } } },
|
||||||
|
}),
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (lessonRecord) {
|
||||||
|
await Promise.all(
|
||||||
|
staff.map((s) =>
|
||||||
|
sendHomeworkUpdatedEmail(s.email, s.name, session.user.name, lessonRecord.lesson.title, submissionId).catch(
|
||||||
|
(e) => console.error("[email] homework-updated:", e)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const created = await prisma.homeworkSubmission.create({
|
||||||
|
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
|
||||||
|
});
|
||||||
|
submissionId = created.id;
|
||||||
|
|
||||||
|
// Notify admins/curators on first submission only
|
||||||
|
const [lesson, admins] = await Promise.all([
|
||||||
|
prisma.homework.findUnique({
|
||||||
|
where: { id: homeworkId },
|
||||||
|
include: { lesson: { select: { title: true } } },
|
||||||
|
}),
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (lesson) {
|
||||||
|
await Promise.all(
|
||||||
|
admins.map((a) =>
|
||||||
|
sendHomeworkSubmittedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { KinescopePlayer } from "@/components/player/kinescope-player";
|
||||||
|
import { LessonContent } from "@/components/student/lesson-content";
|
||||||
|
import { LessonCompleteButton } from "@/components/student/lesson-complete-button";
|
||||||
|
import { HomeworkSection } from "@/components/student/homework-section";
|
||||||
|
import { QuizSection } from "@/components/student/quiz-section";
|
||||||
|
import { LessonComments } from "@/components/student/lesson-comments";
|
||||||
|
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string; lessonId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LessonPage({ params }: Props) {
|
||||||
|
const { slug, lessonId } = await params;
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const isAdmin = session?.user.role === "admin";
|
||||||
|
|
||||||
|
const [lesson, progress, comments] = await Promise.all([
|
||||||
|
prisma.lesson.findUnique({
|
||||||
|
where: { id: lessonId, ...(isAdmin ? {} : { published: true }) },
|
||||||
|
include: {
|
||||||
|
files: { orderBy: { createdAt: "asc" } },
|
||||||
|
homework: true,
|
||||||
|
quiz: {
|
||||||
|
include: { questions: { orderBy: { order: "asc" } } },
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
include: {
|
||||||
|
course: {
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
lessons: {
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
session && !isAdmin
|
||||||
|
? prisma.lessonProgress.findUnique({
|
||||||
|
where: { userId_lessonId: { userId: session.user.id, lessonId } },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
prisma.lessonComment.findMany({
|
||||||
|
where: { lessonId, parentId: null },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
replies: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Fetch homework submission for this student
|
||||||
|
const homeworkSubmission = lesson?.homework && session && !isAdmin
|
||||||
|
? await prisma.homeworkSubmission.findFirst({
|
||||||
|
where: { homeworkId: lesson.homework.id, userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
feedbacks: {
|
||||||
|
include: { curator: { select: { name: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const quizAttempt = lesson?.quiz && session && !isAdmin
|
||||||
|
? await prisma.quizAttempt.findFirst({
|
||||||
|
where: { quizId: lesson.quiz.id, userId: session.user.id },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!lesson || lesson.module.course.slug !== slug) notFound();
|
||||||
|
|
||||||
|
const isCompleted = !!progress;
|
||||||
|
|
||||||
|
// Build ordered flat list of all lessons for prev/next
|
||||||
|
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
|
||||||
|
const idx = allLessons.findIndex((l) => l.id === lessonId);
|
||||||
|
const prevLesson = idx > 0 ? allLessons[idx - 1] : null;
|
||||||
|
const nextLesson = idx < allLessons.length - 1 ? allLessons[idx + 1] : null;
|
||||||
|
|
||||||
|
const hasContent = lesson.content && Object.keys(lesson.content as object).length > 0;
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="max-w-3xl mx-auto px-6 py-8">
|
||||||
|
{/* Title */}
|
||||||
|
<h1 className="text-2xl font-bold mb-6 leading-snug">{lesson.title}</h1>
|
||||||
|
|
||||||
|
{/* Video */}
|
||||||
|
{lesson.kinescopeId && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<KinescopePlayer videoId={lesson.kinescopeId} poster={lesson.coverImage ?? undefined} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text content */}
|
||||||
|
{hasContent && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<LessonContent content={lesson.content as object} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
{lesson.files.length > 0 && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Материалы урока
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{lesson.files.map((file) => (
|
||||||
|
<a
|
||||||
|
key={file.id}
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<FileFormatBadge url={file.url} />
|
||||||
|
<span className="flex-1 font-medium">{file.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{formatSize(file.size)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Скачать
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Homework */}
|
||||||
|
{lesson.homework && !isAdmin && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Домашнее задание
|
||||||
|
</p>
|
||||||
|
<HomeworkSection
|
||||||
|
homework={lesson.homework}
|
||||||
|
submission={homeworkSubmission ? {
|
||||||
|
...homeworkSubmission,
|
||||||
|
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
|
||||||
|
audioUrl: homeworkSubmission.audioUrl ?? null,
|
||||||
|
feedbacks: homeworkSubmission.feedbacks.map((fb) => ({
|
||||||
|
...fb,
|
||||||
|
files: (fb.files as { name: string; url: string; size: number }[]) ?? [],
|
||||||
|
audioUrl: fb.audioUrl ?? null,
|
||||||
|
})),
|
||||||
|
} : null}
|
||||||
|
slug={slug}
|
||||||
|
lessonId={lessonId}
|
||||||
|
allowAudio={lesson.module.course.allowAudio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quiz */}
|
||||||
|
{lesson.quiz && (
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Тест{isAdmin && <span className="ml-2 opacity-50">(предпросмотр)</span>}
|
||||||
|
</p>
|
||||||
|
{isAdmin ? (
|
||||||
|
<div className="space-y-4 opacity-70">
|
||||||
|
{lesson.quiz.questions.map((q, idx) => (
|
||||||
|
<div key={q.id} className="space-y-1">
|
||||||
|
<p className="text-sm font-medium">{idx + 1}. {q.text}</p>
|
||||||
|
<div className="px-4 py-3 text-sm" style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}>
|
||||||
|
Поле для ответа студента
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<QuizSection
|
||||||
|
quiz={lesson.quiz}
|
||||||
|
attempt={quizAttempt ? { answers: quizAttempt.answers as Record<string, string> } : null}
|
||||||
|
slug={slug}
|
||||||
|
lessonId={lessonId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Complete button + Prev/Next navigation */}
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between pt-6 mt-6"
|
||||||
|
style={{ borderTop: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
{prevLesson ? (
|
||||||
|
<Link
|
||||||
|
href={`/courses/${slug}/lessons/${prevLesson.id}`}
|
||||||
|
className="btn-aubade text-sm max-w-[40%]"
|
||||||
|
>
|
||||||
|
← {prevLesson.title}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isAdmin && !lesson.homework && !lesson.quiz && (
|
||||||
|
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} />
|
||||||
|
)}
|
||||||
|
{!isAdmin && (lesson.homework || lesson.quiz) && isCompleted && (
|
||||||
|
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={true} readOnly={true} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{nextLesson ? (
|
||||||
|
<Link
|
||||||
|
href={`/courses/${slug}/lessons/${nextLesson.id}`}
|
||||||
|
className="btn-aubade btn-aubade-accent text-sm max-w-[40%] text-right"
|
||||||
|
>
|
||||||
|
{nextLesson.title} →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{isAdmin ? "Последний урок курса" : ""}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
{session && (
|
||||||
|
<div
|
||||||
|
className="mt-10 pt-8"
|
||||||
|
style={{ borderTop: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-6" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Обсуждение ({
|
||||||
|
comments.filter(c => !c.deleted).length +
|
||||||
|
comments.flatMap(c => c.replies).filter(r => !r.deleted).length
|
||||||
|
})
|
||||||
|
</p>
|
||||||
|
<LessonComments
|
||||||
|
lessonId={lessonId}
|
||||||
|
slug={slug}
|
||||||
|
comments={comments}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
currentUserRole={session.user.role ?? "student"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CoursePage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const course = await prisma.course.findUnique({
|
||||||
|
where: { slug, published: true },
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
lessons: {
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) notFound();
|
||||||
|
|
||||||
|
// Redirect to the first published lesson
|
||||||
|
for (const mod of course.modules) {
|
||||||
|
if (mod.lessons.length > 0) {
|
||||||
|
redirect(`/courses/${slug}/lessons/${mod.lessons[0].id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No lessons yet — show placeholder
|
||||||
|
return (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<p className="text-4xl mb-4">📭</p>
|
||||||
|
<p className="font-bold text-lg">Уроков пока нет</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Курс в разработке. Загляните позже.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,153 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function StudentDashboard() {
|
export default async function StudentDashboard() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const enrollments = await prisma.courseEnrollment.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
course: {
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
include: {
|
||||||
|
lessons: {
|
||||||
|
where: { published: true },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { modules: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { enrolledAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const active = enrollments.filter((e) => !e.expiresAt || e.expiresAt > now);
|
||||||
|
const expired = enrollments.filter((e) => e.expiresAt && e.expiresAt <= now);
|
||||||
|
|
||||||
|
// Fetch progress for all lessons in active courses
|
||||||
|
const allLessonIds = active.flatMap((e) =>
|
||||||
|
e.course.modules.flatMap((m) => m.lessons.map((l) => l.id))
|
||||||
|
);
|
||||||
|
const progressRecords = allLessonIds.length > 0
|
||||||
|
? await prisma.lessonProgress.findMany({
|
||||||
|
where: { userId: session.user.id, lessonId: { in: allLessonIds } },
|
||||||
|
select: { lessonId: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const completedSet = new Set(progressRecords.map((p) => p.lessonId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-amber-50">
|
<main className="max-w-4xl mx-auto px-6 py-10 w-full">
|
||||||
<header className="bg-white border-b border-amber-100 px-6 py-4 flex items-center justify-between">
|
<h1 className="text-2xl font-bold mb-1">Мои курсы</h1>
|
||||||
<h1 className="text-xl font-bold text-amber-900">Second Brain</h1>
|
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<div className="flex items-center gap-4">
|
{active.length} активных курсов
|
||||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
</p>
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
{active.length === 0 ? (
|
||||||
</header>
|
<div className="card-aubade p-12 text-center">
|
||||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
|
||||||
Добро пожаловать, {session.user.name}!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 mb-8">Ваши курсы появятся здесь.</p>
|
|
||||||
<div className="bg-white rounded-2xl border border-amber-100 p-8 text-center text-gray-400">
|
|
||||||
<p className="text-4xl mb-3">📚</p>
|
<p className="text-4xl mb-3">📚</p>
|
||||||
<p>Доступных курсов пока нет.</p>
|
<p className="font-medium">Доступных курсов пока нет</p>
|
||||||
<p className="text-sm mt-1">Обратитесь к администратору за доступом.</p>
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Обратитесь к администратору за доступом
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
) : (
|
||||||
</div>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{active.map(({ course, expiresAt }) => {
|
||||||
|
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||||
|
const completedLessons = course.modules
|
||||||
|
.flatMap((m) => m.lessons)
|
||||||
|
.filter((l) => completedSet.has(l.id)).length;
|
||||||
|
const progressPct = totalLessons > 0
|
||||||
|
? Math.round((completedLessons / totalLessons) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={course.id}
|
||||||
|
href={`/courses/${course.slug}`}
|
||||||
|
className="card-aubade p-0 overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
{course.coverImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={course.coverImage} alt={course.title} className="w-full aspect-video object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-video flex items-center justify-center text-4xl" style={{ backgroundColor: "var(--color-surface)" }}>
|
||||||
|
📚
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4 flex-1 flex flex-col gap-2">
|
||||||
|
<h2 className="font-bold text-base leading-tight">{course.title}</h2>
|
||||||
|
{course.description && (
|
||||||
|
<p className="text-xs line-clamp-2" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{totalLessons > 0 && (
|
||||||
|
<div className="mt-auto">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{completedLessons} из {totalLessons} уроков
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs font-bold"
|
||||||
|
style={{ color: progressPct === 100 ? "var(--foreground)" : "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{progressPct === 100 ? "✓ Завершён" : `${progressPct}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full" style={{ background: "var(--border)" }}>
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${progressPct}%`,
|
||||||
|
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
|
||||||
|
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expiresAt && (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Доступ до {new Date(expiresAt).toLocaleDateString("ru-RU")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired.length > 0 && (
|
||||||
|
<div className="mt-10">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Доступ истёк
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{expired.map(({ course, expiresAt }) => (
|
||||||
|
<div key={course.id} className="flex items-center justify-between px-4 py-3 opacity-50" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<span className="text-sm font-medium">{course.title}</span>
|
||||||
|
<span className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
Истёк {new Date(expiresAt!).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LogoutButton } from "@/components/layout/logout-button";
|
||||||
|
import { getSetting } from "@/lib/settings";
|
||||||
|
import { StopImpersonateBanner } from "@/components/admin/stop-impersonate-banner";
|
||||||
|
|
||||||
|
export default async function StudentLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
// Maintenance mode: non-admin users see the maintenance page
|
||||||
|
if (session.user.role !== "admin") {
|
||||||
|
const maintenance = await getSetting("maintenanceMode");
|
||||||
|
if (maintenance === "true") redirect("/maintenance");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [schoolName, logoUrl, showLogo, socialYoutube, socialVk, socialTelegram, orgRequisites] =
|
||||||
|
await Promise.all([
|
||||||
|
getSetting("schoolName"),
|
||||||
|
getSetting("logoUrl"),
|
||||||
|
getSetting("showLogo"),
|
||||||
|
getSetting("socialYoutube"),
|
||||||
|
getSetting("socialVk"),
|
||||||
|
getSetting("socialTelegram"),
|
||||||
|
getSetting("orgRequisites"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isImpersonating = !!(session.session as { impersonatedBy?: string }).impersonatedBy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
|
||||||
|
{isImpersonating && <StopImpersonateBanner userName={session.user.name} />}
|
||||||
|
<header
|
||||||
|
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3"
|
||||||
|
style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }}
|
||||||
|
>
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2 font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
|
||||||
|
{logoUrl && showLogo === "true" && (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={logoUrl} alt={schoolName} className="h-6 w-auto object-contain" />
|
||||||
|
)}
|
||||||
|
{schoolName}
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Вопросы
|
||||||
|
</Link>
|
||||||
|
<Link href="/profile" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{session.user.name}
|
||||||
|
</Link>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 flex flex-col">{children}</div>
|
||||||
|
{(socialYoutube || socialVk || socialTelegram || orgRequisites) && (
|
||||||
|
<footer
|
||||||
|
className="px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs"
|
||||||
|
style={{ borderTop: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{orgRequisites && (
|
||||||
|
<p className="whitespace-pre-line text-center sm:text-left">{orgRequisites}</p>
|
||||||
|
)}
|
||||||
|
{(socialYoutube || socialVk || socialTelegram) && (
|
||||||
|
<div className="flex items-center gap-4 flex-shrink-0">
|
||||||
|
{socialYoutube && (
|
||||||
|
<a href={socialYoutube} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||||
|
YouTube
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{socialVk && (
|
||||||
|
<a href={socialVk} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||||
|
VK
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{socialTelegram && (
|
||||||
|
<a href={socialTelegram} target="_blank" rel="noopener noreferrer" className="hover:underline">
|
||||||
|
Telegram
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
export async function changePasswordAction(_prevState: unknown, formData: FormData) {
|
||||||
|
const current = (formData.get("currentPassword") as string) ?? "";
|
||||||
|
const next = (formData.get("newPassword") as string) ?? "";
|
||||||
|
const confirm = (formData.get("confirmPassword") as string) ?? "";
|
||||||
|
|
||||||
|
if (!current || !next || !confirm) return { error: "Заполните все поля" };
|
||||||
|
if (next !== confirm) return { error: "Пароли не совпадают" };
|
||||||
|
if (next.length < 8) return { error: "Новый пароль должен быть не короче 8 символов" };
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return { error: "Сессия истекла, войдите заново" };
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where: { userId: session.user.id, providerId: "credential" },
|
||||||
|
});
|
||||||
|
if (!account?.password) return { error: "Аккаунт не найден" };
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(current, account.password);
|
||||||
|
if (!valid) return { error: "Неверный текущий пароль" };
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(next, 10);
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { password: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { changePasswordAction } from "./actions";
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ChangePasswordForm() {
|
||||||
|
const [state, formAction, isPending] = useActionState(changePasswordAction, null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Текущий пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="currentPassword"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Новый пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="newPassword"
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
|
placeholder="Минимум 8 символов"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Повторите новый пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirmPassword"
|
||||||
|
required
|
||||||
|
className="w-full px-3 py-2 text-sm bg-transparent"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{state?.error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--destructive)" }}>{state.error}</p>
|
||||||
|
)}
|
||||||
|
{state?.success && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--foreground)" }}>Пароль успешно изменён.</p>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
className="btn-aubade justify-center"
|
||||||
|
style={isPending ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
|
||||||
|
>
|
||||||
|
{isPending ? "Сохранение..." : "Сменить пароль"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { ChangePasswordForm } from "./change-password-form";
|
||||||
|
|
||||||
|
export const metadata = { title: "Профиль" };
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-lg mx-auto px-4 py-10 w-full">
|
||||||
|
<h1 className="text-xl font-bold tracking-wide mb-8" style={{ color: "var(--foreground)" }}>
|
||||||
|
Профиль
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="card-aubade p-6 mb-6">
|
||||||
|
<h2 className="text-xs uppercase tracking-widest font-bold mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Аккаунт
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3 text-sm" style={{ color: "var(--foreground)" }}>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>Имя</span>
|
||||||
|
<span>{session.user.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>Email</span>
|
||||||
|
<span>{session.user.email}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<h2 className="text-xs uppercase tracking-widest font-bold mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Смена пароля
|
||||||
|
</h2>
|
||||||
|
<ChangePasswordForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect, notFound } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { QuestionThread } from "@/components/questions/QuestionThread";
|
||||||
|
|
||||||
|
export default async function QuestionThreadPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const question = await prisma.studentQuestion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
messages: {
|
||||||
|
include: { author: { select: { id: true, name: true, role: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!question || question.userId !== session.user.id) notFound();
|
||||||
|
|
||||||
|
// Mark staff messages as read
|
||||||
|
await prisma.studentQuestionMessage.updateMany({
|
||||||
|
where: {
|
||||||
|
questionId: id,
|
||||||
|
isRead: false,
|
||||||
|
NOT: { authorId: session.user.id },
|
||||||
|
},
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-start justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold mb-1" style={{ color: "var(--foreground)" }}>
|
||||||
|
{question.title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Создан{" "}
|
||||||
|
{new Date(question.createdAt).toLocaleDateString("ru", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
})}{" "}
|
||||||
|
·{" "}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: question.status === "OPEN" ? "var(--foreground)" : "var(--muted-foreground)",
|
||||||
|
fontWeight: 700,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{question.status === "OPEN" ? "● Открыт" : "✓ Закрыт"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/questions"
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
← Все вопросы
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<QuestionThread
|
||||||
|
questionId={id}
|
||||||
|
questionStatus={question.status as "OPEN" | "CLOSED"}
|
||||||
|
currentUserId={session.user.id}
|
||||||
|
initialMessages={question.messages.map((m) => ({
|
||||||
|
...m,
|
||||||
|
files: m.files as Array<{ name: string; url: string; size: number }> | null,
|
||||||
|
createdAt: m.createdAt.toISOString(),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface FileAttachment {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NewQuestionPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [files, setFiles] = useState<FileAttachment[]>([]);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const selected = Array.from(e.target.files ?? []);
|
||||||
|
if (!selected.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const uploaded: FileAttachment[] = [];
|
||||||
|
for (const f of selected) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", f);
|
||||||
|
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
throw new Error(d.error ?? "Ошибка загрузки файла");
|
||||||
|
}
|
||||||
|
uploaded.push(await res.json());
|
||||||
|
}
|
||||||
|
setFiles((prev) => [...prev, ...uploaded]);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка загрузки");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim() || !text.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/questions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ title, text, files }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
throw new Error(data.error ?? "Ошибка при создании вопроса");
|
||||||
|
}
|
||||||
|
const q = await res.json();
|
||||||
|
router.push(`/questions/${q.id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Ошибка");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-6 py-10">
|
||||||
|
<div className="mb-8">
|
||||||
|
<Link
|
||||||
|
href="/questions"
|
||||||
|
className="text-sm"
|
||||||
|
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
|
||||||
|
>
|
||||||
|
← Все вопросы
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--foreground)" }}>
|
||||||
|
Новый вопрос
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Опишите свой вопрос подробно — куратор ответит в ближайшее время.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-bold mb-2"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
Тема вопроса
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Кратко опишите суть вопроса"
|
||||||
|
required
|
||||||
|
className="w-full text-sm px-4 py-3 outline-none"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="text-xs mt-1.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Например: «Не получается настроить плагин» или «Вопрос по уроку 3»
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="block text-sm font-bold mb-2"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
Описание
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Подробно опишите вопрос или проблему. Что именно не получается? Что уже пробовали? Прикрепите скриншот или ссылку, если нужно."
|
||||||
|
required
|
||||||
|
rows={10}
|
||||||
|
className="w-full text-sm px-4 py-3 outline-none resize-y"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
minHeight: "200px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File attachments */}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
/>
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={f.url}
|
||||||
|
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5"
|
||||||
|
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
📎 <span>{f.name}</span>
|
||||||
|
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
className="ml-1"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={uploading}
|
||||||
|
className="text-xs px-3 py-2"
|
||||||
|
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
{uploading ? "Загрузка..." : "📎 Прикрепить файл"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: "#999" }}>
|
||||||
|
jpg, png, pdf, md · до 10 МБ
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--destructive)" }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || uploading || !title.trim() || !text.trim()}
|
||||||
|
className="text-sm font-bold px-8 py-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--foreground)",
|
||||||
|
color: "var(--background)",
|
||||||
|
border: "none",
|
||||||
|
opacity: loading || uploading || !title.trim() || !text.trim() ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "Отправка..." : "Отправить →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default async function QuestionsPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const questions = await prisma.studentQuestion.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
messages: {
|
||||||
|
where: { isRead: false, NOT: { authorId: session.user.id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
include: { author: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-xl font-bold" style={{ color: "var(--foreground)" }}>
|
||||||
|
Мои вопросы
|
||||||
|
</h1>
|
||||||
|
<Link
|
||||||
|
href="/questions/new"
|
||||||
|
className="text-sm font-bold px-4 py-2"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-surface)",
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+ Задать вопрос
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{questions.length === 0 && (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
У вас ещё нет вопросов.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{questions.map((q) => {
|
||||||
|
const unread = q._count.messages > 0;
|
||||||
|
const lastMsg = q.messages[0];
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={q.id}
|
||||||
|
href={`/questions/${q.id}`}
|
||||||
|
className="block p-3 rounded-sm transition-colors"
|
||||||
|
style={{
|
||||||
|
border: unread ? "2px solid var(--foreground)" : "1px solid var(--border)",
|
||||||
|
background: q.status === "CLOSED" ? "var(--background)" : "var(--color-surface)",
|
||||||
|
opacity: q.status === "CLOSED" ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className="text-sm"
|
||||||
|
style={{
|
||||||
|
fontWeight: unread ? 700 : 400,
|
||||||
|
color: q.status === "CLOSED" ? "var(--muted-foreground)" : "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{q.title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs shrink-0 px-1.5 py-0.5 rounded-sm"
|
||||||
|
style={
|
||||||
|
q.status === "OPEN"
|
||||||
|
? { background: "#E8F0D8", border: "1px solid var(--border)", color: "var(--foreground)" }
|
||||||
|
: { background: "var(--background)", color: "var(--muted-foreground)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{q.status === "OPEN" ? "● ОТКРЫТ" : "✓ ЗАКРЫТ"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{unread && (
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<span
|
||||||
|
className="inline-block w-2 h-2 rounded-full"
|
||||||
|
style={{ background: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
|
||||||
|
Новый ответ от школы
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lastMsg && !unread && (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
последнее от {lastMsg.author.name}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(str: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"y",
|
||||||
|
к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f",
|
||||||
|
х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"shch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya",
|
||||||
|
};
|
||||||
|
return str.toLowerCase()
|
||||||
|
.replace(/[а-яё]/g, (c) => map[c] ?? c)
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const slug = (formData.get("slug") as string).trim() || slugify(title);
|
||||||
|
const count = await prisma.category.count();
|
||||||
|
await prisma.category.create({ data: { title, slug, order: count } });
|
||||||
|
revalidatePath("/admin/categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(id: string, formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const slug = formData.get("slug") as string;
|
||||||
|
await prisma.category.update({ where: { id }, data: { title, slug } });
|
||||||
|
revalidatePath("/admin/categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.category.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/categories");
|
||||||
|
revalidatePath("/admin/courses");
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { CategoryRow } from "@/components/admin/category-row";
|
||||||
|
import { createCategory } from "./actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export default async function CategoriesPage() {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { _count: { select: { courses: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>Категории</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{categories.length} категорий
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-8">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p className="text-sm py-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Категорий пока нет. Создайте первую.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
categories.map((cat) => (
|
||||||
|
<CategoryRow key={cat.id} category={cat} courseCount={cat._count.courses} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Новая категория
|
||||||
|
</p>
|
||||||
|
<form action={createCategory} className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<Input name="title" placeholder="Obsidian PKM" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Slug
|
||||||
|
</label>
|
||||||
|
<Input name="slug" placeholder="obsidian-pkm (авто)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit">+ Создать</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function adminDeleteComment(commentId: string) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
|
||||||
|
|
||||||
|
await prisma.lessonComment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: { deleted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/comments");
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function deleteComment(commentId: string): Promise<{ ok: boolean }> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
|
||||||
|
|
||||||
|
await prisma.lessonComment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: { deleted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/comments");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { CommentsTable } from "@/components/admin/comments-table";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export const metadata = { title: "Комментарии" };
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchParams: Promise<{ page?: string; search?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminCommentsPage({ searchParams }: Props) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") redirect("/dashboard");
|
||||||
|
|
||||||
|
const { page = "1", search = "" } = await searchParams;
|
||||||
|
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||||
|
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
deleted: false,
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ user: { name: { contains: search, mode: "insensitive" as const } } },
|
||||||
|
{ user: { email: { contains: search, mode: "insensitive" as const } } },
|
||||||
|
{ text: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [comments, total] = await Promise.all([
|
||||||
|
prisma.lessonComment.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: PAGE_SIZE,
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
module: {
|
||||||
|
select: {
|
||||||
|
course: { select: { slug: true, title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.lessonComment.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
function pageUrl(p: number) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
params.set("page", String(p));
|
||||||
|
return `/admin/comments?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Комментарии
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{total} активных комментариев
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Suspense>
|
||||||
|
<CommentsTable comments={comments} search={search} />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1 mt-4">
|
||||||
|
{currentPage > 1 && (
|
||||||
|
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs">←</Link>
|
||||||
|
)}
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||||
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((p, i) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}>…</span>
|
||||||
|
) : (
|
||||||
|
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
|
||||||
|
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{currentPage < totalPages && (
|
||||||
|
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { sendCourseAccessEmail } from "@/lib/email";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Modules ──────────────────────────────────────────────────────────────────
|
// ── Modules ──────────────────────────────────────────────────────────────────
|
||||||
@@ -16,14 +19,16 @@ export async function createModule(courseId: string, formData: FormData) {
|
|||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const title = formData.get("title") as string;
|
const title = formData.get("title") as string;
|
||||||
const count = await prisma.module.count({ where: { courseId } });
|
const count = await prisma.module.count({ where: { courseId } });
|
||||||
await prisma.module.create({ data: { courseId, title, order: count } });
|
const mod = await prisma.module.create({ data: { courseId, title, order: count } });
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
|
redirect(`/admin/courses/${courseId}/modules/${mod.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
|
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const title = formData.get("title") as string;
|
const title = formData.get("title") as string;
|
||||||
await prisma.module.update({ where: { id: moduleId }, data: { title } });
|
const description = (formData.get("description") as string | null)?.trim() || null;
|
||||||
|
await prisma.module.update({ where: { id: moduleId }, data: { title, description } });
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +50,55 @@ export async function reorderModules(courseId: string, orderedIds: string[]) {
|
|||||||
|
|
||||||
// ── Enrollment ───────────────────────────────────────────────────────────────
|
// ── Enrollment ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function grantAccess(courseId: string, userId: string) {
|
export async function grantAccess(
|
||||||
await requireAdmin();
|
courseId: string,
|
||||||
|
userId: string,
|
||||||
|
expiresAt?: string | null,
|
||||||
|
note?: string
|
||||||
|
) {
|
||||||
|
const session = await requireAdmin();
|
||||||
await prisma.courseEnrollment.upsert({
|
await prisma.courseEnrollment.upsert({
|
||||||
where: { userId_courseId: { userId, courseId } },
|
where: { userId_courseId: { userId, courseId } },
|
||||||
update: {},
|
update: { expiresAt: expiresAt ? new Date(expiresAt) : null },
|
||||||
create: { userId, courseId },
|
create: { userId, courseId, expiresAt: expiresAt ? new Date(expiresAt) : null },
|
||||||
});
|
});
|
||||||
|
await prisma.accessLog.create({
|
||||||
|
data: {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
action: "granted",
|
||||||
|
method: "manual",
|
||||||
|
grantedById: session.user.id,
|
||||||
|
note: note || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
const [user, course] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
||||||
|
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
|
||||||
|
]);
|
||||||
|
if (user && course) {
|
||||||
|
await sendCourseAccessEmail(user.email, user.name, course.title);
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeAccess(courseId: string, userId: string) {
|
export async function revokeAccess(courseId: string, userId: string, note?: string) {
|
||||||
await requireAdmin();
|
const session = await requireAdmin();
|
||||||
await prisma.courseEnrollment.delete({
|
await prisma.courseEnrollment.delete({
|
||||||
where: { userId_courseId: { userId, courseId } },
|
where: { userId_courseId: { userId, courseId } },
|
||||||
});
|
});
|
||||||
|
await prisma.accessLog.create({
|
||||||
|
data: {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
action: "revoked",
|
||||||
|
method: "manual",
|
||||||
|
grantedById: session.user.id,
|
||||||
|
note: note || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -13,10 +14,13 @@ async function requireAdmin() {
|
|||||||
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
|
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const title = formData.get("title") as string;
|
const title = formData.get("title") as string;
|
||||||
|
const kinescopeId = (formData.get("kinescopeId") as string | null)?.trim() || null;
|
||||||
const count = await prisma.lesson.count({ where: { moduleId } });
|
const count = await prisma.lesson.count({ where: { moduleId } });
|
||||||
const lesson = await prisma.lesson.create({ data: { moduleId, title, order: count } });
|
const lesson = await prisma.lesson.create({
|
||||||
|
data: { moduleId, title, kinescopeId, order: count },
|
||||||
|
});
|
||||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||||
return lesson.id;
|
redirect(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
|
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
|
||||||
@@ -41,3 +45,46 @@ export async function reorderLessons(moduleId: string, courseId: string, ordered
|
|||||||
);
|
);
|
||||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleLessonPublished(
|
||||||
|
lessonId: string,
|
||||||
|
courseId: string,
|
||||||
|
moduleId: string,
|
||||||
|
currentValue: boolean
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.lesson.update({
|
||||||
|
where: { id: lessonId },
|
||||||
|
data: { published: !currentValue },
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||||
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveLessonToModule(
|
||||||
|
lessonId: string,
|
||||||
|
targetModuleId: string,
|
||||||
|
courseId: string,
|
||||||
|
sourceModuleId: string
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
// verify target module belongs to same course
|
||||||
|
const target = await prisma.module.findFirst({
|
||||||
|
where: { id: targetModuleId, courseId },
|
||||||
|
});
|
||||||
|
if (!target) throw new Error("Module not found");
|
||||||
|
|
||||||
|
const maxOrder = await prisma.lesson.aggregate({
|
||||||
|
where: { moduleId: targetModuleId },
|
||||||
|
_max: { order: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.lesson.update({
|
||||||
|
where: { id: lessonId },
|
||||||
|
data: { moduleId: targetModuleId, order: (maxOrder._max.order ?? -1) + 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/admin/courses/${courseId}/modules/${sourceModuleId}`);
|
||||||
|
revalidatePath(`/admin/courses/${courseId}/modules/${targetModuleId}`);
|
||||||
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
|
}
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHomework(lessonId: string, description: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.homework.upsert({
|
||||||
|
where: { lessonId },
|
||||||
|
update: { description },
|
||||||
|
create: { lessonId, description },
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/courses`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHomework(lessonId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.homework.delete({ where: { lessonId } });
|
||||||
|
revalidatePath(`/admin/courses`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LessonEditor } from "@/components/admin/lesson-editor";
|
import { LessonEditor } from "@/components/admin/lesson-editor";
|
||||||
|
import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
|
||||||
|
import { HomeworkEditor } from "@/components/admin/homework-editor";
|
||||||
|
import { QuizEditor } from "@/components/admin/quiz-editor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
|
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
|
||||||
@@ -10,44 +13,99 @@ interface Props {
|
|||||||
export default async function LessonEditorPage({ params }: Props) {
|
export default async function LessonEditorPage({ params }: Props) {
|
||||||
const { courseId, moduleId, lessonId } = await params;
|
const { courseId, moduleId, lessonId } = await params;
|
||||||
|
|
||||||
const lesson = await prisma.lesson.findUnique({
|
const [lesson, siblings] = await Promise.all([
|
||||||
where: { id: lessonId },
|
prisma.lesson.findUnique({
|
||||||
include: {
|
where: { id: lessonId },
|
||||||
module: {
|
include: {
|
||||||
include: { course: { select: { title: true } } },
|
files: { orderBy: { createdAt: "asc" } },
|
||||||
|
homework: true,
|
||||||
|
quiz: {
|
||||||
|
include: { questions: { orderBy: { order: "asc" } } },
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
include: { course: { select: { title: true, slug: true } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
prisma.lesson.findMany({
|
||||||
|
where: { moduleId },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!lesson || lesson.moduleId !== moduleId) notFound();
|
if (!lesson || lesson.moduleId !== moduleId) notFound();
|
||||||
|
|
||||||
|
const idx = siblings.findIndex((l) => l.id === lessonId);
|
||||||
|
const prevLesson = idx > 0 ? siblings[idx - 1] : null;
|
||||||
|
const nextLesson = idx < siblings.length - 1 ? siblings[idx + 1] : null;
|
||||||
|
|
||||||
|
// Serialize all Prisma proxy objects (DateTime, relations) before passing to Client Components
|
||||||
|
const plain = JSON.parse(JSON.stringify({
|
||||||
|
files: lesson.files,
|
||||||
|
homework: lesson.homework,
|
||||||
|
quiz: lesson.quiz,
|
||||||
|
siblings,
|
||||||
|
})) as {
|
||||||
|
files: typeof lesson.files;
|
||||||
|
homework: typeof lesson.homework;
|
||||||
|
quiz: typeof lesson.quiz;
|
||||||
|
siblings: typeof siblings;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl">
|
<div className="p-8 max-w-4xl">
|
||||||
<nav className="text-sm text-slate-400 mb-6">
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
|
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}`} className="hover:text-slate-600">
|
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{lesson.module.course.title}</Link>
|
||||||
{lesson.module.course.title}
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:text-slate-600">
|
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:underline">{lesson.module.title}</Link>
|
||||||
{lesson.module.title}
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-slate-700">{lesson.title}</span>
|
<span style={{ color: "var(--foreground)" }}>{lesson.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<LessonEditor
|
{/* Lesson editor */}
|
||||||
lesson={{
|
<div className="card-aubade p-6 mb-6">
|
||||||
id: lesson.id,
|
<LessonEditor
|
||||||
title: lesson.title,
|
lesson={{
|
||||||
kinescopeId: lesson.kinescopeId ?? "",
|
id: lesson.id,
|
||||||
content: lesson.content as object ?? {},
|
title: lesson.title,
|
||||||
published: lesson.published,
|
kinescopeId: lesson.kinescopeId ?? "",
|
||||||
}}
|
content: JSON.parse(JSON.stringify(lesson.content ?? {})),
|
||||||
courseId={courseId}
|
published: lesson.published,
|
||||||
moduleId={moduleId}
|
}}
|
||||||
/>
|
courseId={courseId}
|
||||||
|
moduleId={moduleId}
|
||||||
|
courseSlug={lesson.module.course.slug}
|
||||||
|
prevLesson={plain.siblings[idx - 1] ?? null}
|
||||||
|
nextLesson={plain.siblings[idx + 1] ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files section */}
|
||||||
|
<div className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Файлы и материалы
|
||||||
|
</p>
|
||||||
|
<LessonFilesManager lessonId={lessonId} initialFiles={plain.files} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Homework section */}
|
||||||
|
<div className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Домашнее задание
|
||||||
|
</p>
|
||||||
|
<HomeworkEditor lessonId={lessonId} initial={plain.homework} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quiz section */}
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Тест
|
||||||
|
</p>
|
||||||
|
<QuizEditor lessonId={lessonId} initial={plain.quiz} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { SortableLessons } from "@/components/admin/sortable-lessons";
|
import { SortableLessons } from "@/components/admin/sortable-lessons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,35 +10,58 @@ interface Props {
|
|||||||
export default async function ModulePage({ params }: Props) {
|
export default async function ModulePage({ params }: Props) {
|
||||||
const { courseId, moduleId } = await params;
|
const { courseId, moduleId } = await params;
|
||||||
|
|
||||||
const module = await prisma.module.findUnique({
|
const [module, allModules] = await Promise.all([
|
||||||
where: { id: moduleId },
|
prisma.module.findUnique({
|
||||||
include: {
|
where: { id: moduleId },
|
||||||
course: { select: { title: true } },
|
include: {
|
||||||
lessons: { orderBy: { order: "asc" } },
|
course: { select: { title: true } },
|
||||||
},
|
lessons: {
|
||||||
});
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true, order: true, published: true, kinescopeId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.module.findMany({
|
||||||
|
where: { courseId, NOT: { id: moduleId } },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!module || module.courseId !== courseId) notFound();
|
if (!module || module.courseId !== courseId) notFound();
|
||||||
|
|
||||||
|
const plain = JSON.parse(JSON.stringify({ lessons: module.lessons, allModules })) as {
|
||||||
|
lessons: typeof module.lessons;
|
||||||
|
allModules: typeof allModules;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl">
|
<div className="p-8 max-w-3xl">
|
||||||
<nav className="text-sm text-slate-400 mb-6">
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
|
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}`} className="hover:text-slate-600">{module.course.title}</Link>
|
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{module.course.title}</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-slate-700">{module.title}</span>
|
<span style={{ color: "var(--foreground)" }}>{module.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6">
|
||||||
<div>
|
<h1 className="text-2xl font-bold">{module.title}</h1>
|
||||||
<h1 className="text-2xl font-semibold text-slate-800">{module.title}</h1>
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<p className="text-slate-500 text-sm mt-0.5">{module.lessons.length} уроков</p>
|
{module.lessons.length} {module.lessons.length === 1 ? "урок" : module.lessons.length < 5 ? "урока" : "уроков"}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6">
|
<section className="card-aubade p-6">
|
||||||
<SortableLessons courseId={courseId} moduleId={moduleId} lessons={module.lessons} />
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Уроки модуля
|
||||||
|
</p>
|
||||||
|
<SortableLessons
|
||||||
|
courseId={courseId}
|
||||||
|
moduleId={moduleId}
|
||||||
|
lessons={plain.lessons}
|
||||||
|
otherModules={plain.allModules}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { CourseEditForm } from "@/components/admin/course-edit-form";
|
import { CourseEditForm } from "@/components/admin/course-edit-form";
|
||||||
import { SortableModules } from "@/components/admin/sortable-modules";
|
import { SortableModules } from "@/components/admin/sortable-modules";
|
||||||
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
|
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
|
||||||
|
import { CourseTree } from "@/components/admin/course-tree";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ courseId: string }>;
|
params: Promise<{ courseId: string }>;
|
||||||
@@ -12,15 +13,31 @@ interface Props {
|
|||||||
export default async function CourseDetailPage({ params }: Props) {
|
export default async function CourseDetailPage({ params }: Props) {
|
||||||
const { courseId } = await params;
|
const { courseId } = await params;
|
||||||
|
|
||||||
const [course, allStudents] = await Promise.all([
|
const [course, allStudents, categories] = await Promise.all([
|
||||||
prisma.course.findUnique({
|
prisma.course.findUnique({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
include: {
|
include: {
|
||||||
modules: {
|
modules: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: { _count: { select: { lessons: true } } },
|
include: {
|
||||||
|
_count: { select: { lessons: true } },
|
||||||
|
lessons: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true, published: true, kinescopeId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enrollments: {
|
||||||
|
select: { userId: true, expiresAt: true },
|
||||||
|
},
|
||||||
|
accessLogs: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true } },
|
||||||
|
grantedBy: { select: { name: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
enrollments: { include: { user: { select: { id: true, name: true, email: true } } } },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
@@ -28,43 +45,69 @@ export default async function CourseDetailPage({ params }: Props) {
|
|||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
|
prisma.category.findMany({ orderBy: { order: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) notFound();
|
if (!course) notFound();
|
||||||
|
|
||||||
const enrolledIds = new Set(course.enrollments.map((e) => e.userId));
|
// Prisma 7 returns proxy objects for relations/aggregates that RSC cannot serialize.
|
||||||
|
// Convert to plain JS before passing to Client Components.
|
||||||
|
const plain = JSON.parse(JSON.stringify({ course, allStudents, categories })) as {
|
||||||
|
course: typeof course;
|
||||||
|
allStudents: typeof allStudents;
|
||||||
|
categories: typeof categories;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl">
|
<div className="p-8 max-w-4xl">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="text-sm text-slate-400 mb-6">
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
|
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-slate-700">{course.title}</span>
|
<span style={{ color: "var(--foreground)" }}>{plain.course.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Course metadata */}
|
{/* Course metadata */}
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6">
|
<section className="card-aubade p-6 mb-6">
|
||||||
<h2 className="text-base font-semibold text-slate-700 mb-4">Основная информация</h2>
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<CourseEditForm course={course} />
|
Основная информация
|
||||||
|
</p>
|
||||||
|
<CourseEditForm course={plain.course} categories={plain.categories} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Modules */}
|
{/* Modules */}
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6">
|
<section className="card-aubade p-6 mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-base font-semibold text-slate-700">Модули</h2>
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<span className="text-sm text-slate-400">{course.modules.length} модулей</span>
|
Модули
|
||||||
|
</p>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{plain.course.modules.length} модулей
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<SortableModules courseId={courseId} modules={course.modules} />
|
<SortableModules courseId={courseId} modules={plain.course.modules} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Course tree overview */}
|
||||||
|
{plain.course.modules.length > 0 && (
|
||||||
|
<section className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Структура курса
|
||||||
|
</p>
|
||||||
|
<CourseTree courseId={courseId} modules={plain.course.modules} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Access management */}
|
{/* Access management */}
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6">
|
<section className="card-aubade p-6">
|
||||||
<h2 className="text-base font-semibold text-slate-700 mb-4">Доступ к курсу</h2>
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Управление доступом
|
||||||
|
</p>
|
||||||
<EnrollmentManager
|
<EnrollmentManager
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
allStudents={allStudents}
|
allStudents={plain.allStudents}
|
||||||
enrolledIds={[...enrolledIds]}
|
enrollments={plain.course.enrollments}
|
||||||
|
accessLogs={plain.course.accessLogs}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,11 +43,13 @@ export async function updateCourse(courseId: string, formData: FormData) {
|
|||||||
const slug = formData.get("slug") as string;
|
const slug = formData.get("slug") as string;
|
||||||
const description = (formData.get("description") as string) || null;
|
const description = (formData.get("description") as string) || null;
|
||||||
const published = formData.get("published") === "true";
|
const published = formData.get("published") === "true";
|
||||||
|
const allowAudio = formData.get("allowAudio") === "true";
|
||||||
const coverImage = (formData.get("coverImage") as string) || null;
|
const coverImage = (formData.get("coverImage") as string) || null;
|
||||||
|
const categoryId = (formData.get("categoryId") as string) || null;
|
||||||
|
|
||||||
await prisma.course.update({
|
await prisma.course.update({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
data: { title, slug, description, published, coverImage },
|
data: { title, slug, description, published, allowAudio, coverImage, categoryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/courses");
|
revalidatePath("/admin/courses");
|
||||||
|
|||||||
@@ -1,27 +1,231 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default async function AdminDashboard() {
|
||||||
|
const now = new Date();
|
||||||
|
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalStudents,
|
||||||
|
newStudentsMonth,
|
||||||
|
totalCourses,
|
||||||
|
publishedCourses,
|
||||||
|
activeEnrollments,
|
||||||
|
expiringWeek,
|
||||||
|
homeworkPending,
|
||||||
|
homeworkTotal,
|
||||||
|
progressTotal,
|
||||||
|
balanceAggregate,
|
||||||
|
activeLast24h,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.user.count({ where: { role: "student" } }),
|
||||||
|
prisma.user.count({ where: { role: "student", createdAt: { gte: monthAgo } } }),
|
||||||
|
prisma.course.count(),
|
||||||
|
prisma.course.count({ where: { published: true } }),
|
||||||
|
prisma.courseEnrollment.count({
|
||||||
|
where: { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
|
||||||
|
}),
|
||||||
|
prisma.courseEnrollment.count({
|
||||||
|
where: { expiresAt: { gt: now, lte: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) } },
|
||||||
|
}),
|
||||||
|
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
|
||||||
|
prisma.homeworkSubmission.count(),
|
||||||
|
prisma.lessonProgress.count(),
|
||||||
|
prisma.balanceTransaction.aggregate({ _sum: { amount: true } }),
|
||||||
|
prisma.session.findMany({
|
||||||
|
where: { createdAt: { gte: dayAgo } },
|
||||||
|
select: { userId: true },
|
||||||
|
distinct: ["userId"],
|
||||||
|
}).then((rows) => rows.length),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalBalance = Number(balanceAggregate._sum.amount ?? 0);
|
||||||
|
|
||||||
|
// Recent enrollments
|
||||||
|
const recentEnrollments = await prisma.courseEnrollment.findMany({
|
||||||
|
orderBy: { enrolledAt: "desc" },
|
||||||
|
take: 8,
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true, email: true } },
|
||||||
|
course: { select: { title: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Most active courses (by enrollment count)
|
||||||
|
const topCourses = await prisma.course.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
include: { _count: { select: { enrollments: true, modules: true } } },
|
||||||
|
orderBy: { enrollments: { _count: "desc" } },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8 max-w-5xl">
|
||||||
<h1 className="text-2xl font-semibold text-slate-800 mb-1">Обзор</h1>
|
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
|
||||||
<p className="text-slate-500 mb-8">Управление платформой Second Brain.</p>
|
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
Платформа Second Brain · {now.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })}
|
||||||
<Link href="/admin/courses" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors">
|
</p>
|
||||||
<p className="text-3xl mb-2">📚</p>
|
|
||||||
<p className="font-medium text-slate-800">Курсы</p>
|
{/* Stats grid */}
|
||||||
<p className="text-sm text-slate-400 mt-1">Управление контентом</p>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
</Link>
|
<StatCard
|
||||||
<Link href="/admin/users" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors">
|
label="Студентов"
|
||||||
<p className="text-3xl mb-2">👥</p>
|
value={totalStudents}
|
||||||
<p className="font-medium text-slate-800">Пользователи</p>
|
sub={`+${newStudentsMonth} за месяц`}
|
||||||
<p className="text-sm text-slate-400 mt-1">Управление доступом</p>
|
href="/admin/users"
|
||||||
</Link>
|
/>
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 opacity-50">
|
<StatCard
|
||||||
<p className="text-3xl mb-2">📊</p>
|
label="Курсов"
|
||||||
<p className="font-medium text-slate-800">Аналитика</p>
|
value={totalCourses}
|
||||||
<p className="text-sm text-slate-400 mt-1">Этап 10</p>
|
sub={`${publishedCourses} опубликовано`}
|
||||||
|
href="/admin/courses"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Активных доступов"
|
||||||
|
value={activeEnrollments}
|
||||||
|
sub={expiringWeek > 0 ? `${expiringWeek} истекает на неделе` : "нет истекающих"}
|
||||||
|
subAccent={expiringWeek > 0}
|
||||||
|
href="/admin/courses"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="ДЗ на проверку"
|
||||||
|
value={homeworkPending}
|
||||||
|
sub={`${homeworkTotal} всего сдано`}
|
||||||
|
subAccent={homeworkPending > 0}
|
||||||
|
href="/curator/homework"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Recent enrollments */}
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Последние зачисления
|
||||||
|
</p>
|
||||||
|
<Link href="/admin/users" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentEnrollments.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет зачислений</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentEnrollments.map((e) => (
|
||||||
|
<div key={`${e.userId}-${e.courseId}`} className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{e.user.name}</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>{e.course.title}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs shrink-0" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{new Date(e.enrolledAt).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top courses + progress stat */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Популярные курсы
|
||||||
|
</p>
|
||||||
|
<Link href="/admin/courses" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{topCourses.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет курсов</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topCourses.map((c) => (
|
||||||
|
<div key={c.id} className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{c.title}</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{c._count.modules} модулей
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="font-bold">{c._count.enrollments}</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>студентов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Активность
|
||||||
|
</p>
|
||||||
|
<div className="flex items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold">{progressTotal}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уроков пройдено</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold">{homeworkTotal}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>работ сдано</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/admin/users?balance=nonzero" className="card-aubade p-5 block">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
На балансах
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold">
|
||||||
|
{totalBalance.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ₽
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>сумма по всем пользователям</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Авторизации за 24 часа
|
||||||
|
</p>
|
||||||
|
<p className="text-3xl font-bold">{activeLast24h}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уникальных пользователей</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
subAccent,
|
||||||
|
href,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
sub?: string;
|
||||||
|
subAccent?: boolean;
|
||||||
|
href?: string;
|
||||||
|
}) {
|
||||||
|
const content = (
|
||||||
|
<div className="card-aubade p-4">
|
||||||
|
<p className="text-3xl font-bold">{value}</p>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
{sub && (
|
||||||
|
<p className="text-xs mt-1.5" style={{ color: subAccent ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}>
|
||||||
|
{sub}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return href ? <Link href={href}>{content}</Link> : content;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import iconv from "iconv-lite";
|
||||||
|
import { sendWelcomeEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ParsedRow = {
|
||||||
|
index: number;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
// resolved during preview
|
||||||
|
status: "new" | "update" | "error";
|
||||||
|
errorMsg?: string;
|
||||||
|
existingId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreviewResult = {
|
||||||
|
rows: ParsedRow[];
|
||||||
|
countNew: number;
|
||||||
|
countUpdate: number;
|
||||||
|
countError: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImportOptions = {
|
||||||
|
updateExisting: boolean;
|
||||||
|
autoVerifyEmail: boolean;
|
||||||
|
courseId?: string;
|
||||||
|
accessDays: number; // 0 = unlimited
|
||||||
|
sendWelcome: boolean;
|
||||||
|
encoding: "utf8" | "win1251";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplyResult = {
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseCSVLine(line: string): string[] {
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const ch = line[i];
|
||||||
|
if (ch === '"') {
|
||||||
|
if (inQuotes && line[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
} else {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
}
|
||||||
|
} else if ((ch === "," || ch === ";") && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
} else {
|
||||||
|
current += ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHeaders(headers: string[]): Record<string, number> {
|
||||||
|
const map: Record<string, number> = {};
|
||||||
|
const aliases: Record<string, string[]> = {
|
||||||
|
email: ["email", "e-mail", "почта", "login", "логин"],
|
||||||
|
name: ["имя", "name", "firstname", "first_name", "имя пользователя"],
|
||||||
|
lastName: ["фамилия", "lastname", "last_name", "surname"],
|
||||||
|
phone: ["телефон", "phone", "tel", "мобильный"],
|
||||||
|
};
|
||||||
|
|
||||||
|
headers.forEach((h, i) => {
|
||||||
|
const lower = h.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "");
|
||||||
|
for (const [field, aliasList] of Object.entries(aliases)) {
|
||||||
|
if (aliasList.some((a) => lower.includes(a.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "")))) {
|
||||||
|
if (!(field in map)) map[field] = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Parse action ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function parseCSV(
|
||||||
|
base64: string,
|
||||||
|
encoding: "utf8" | "win1251",
|
||||||
|
updateExisting: boolean
|
||||||
|
): Promise<PreviewResult> {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
// Decode bytes
|
||||||
|
const buffer = Buffer.from(base64, "base64");
|
||||||
|
const text = encoding === "win1251"
|
||||||
|
? iconv.decode(buffer, "win1251")
|
||||||
|
: buffer.toString("utf8");
|
||||||
|
|
||||||
|
// Split lines (handle \r\n and \n)
|
||||||
|
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
|
||||||
|
const nonEmpty = lines.filter((l) => l.trim().length > 0);
|
||||||
|
if (nonEmpty.length < 2) {
|
||||||
|
return { rows: [], countNew: 0, countUpdate: 0, countError: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerLine = parseCSVLine(nonEmpty[0]);
|
||||||
|
const colMap = normalizeHeaders(headerLine);
|
||||||
|
|
||||||
|
if (colMap.email === undefined) {
|
||||||
|
throw new Error("Не найдена колонка Email. Проверьте заголовки CSV-файла.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load existing emails for fast lookup
|
||||||
|
const existingUsers = await prisma.user.findMany({
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
const existingByEmail = new Map(existingUsers.map((u) => [u.email.toLowerCase(), u.id]));
|
||||||
|
|
||||||
|
const rows: ParsedRow[] = [];
|
||||||
|
let countNew = 0, countUpdate = 0, countError = 0;
|
||||||
|
|
||||||
|
for (let i = 1; i < nonEmpty.length; i++) {
|
||||||
|
const cols = parseCSVLine(nonEmpty[i]);
|
||||||
|
const email = (cols[colMap.email] ?? "").trim().toLowerCase();
|
||||||
|
const name = (cols[colMap.name ?? -1] ?? "").trim();
|
||||||
|
const lastName = (cols[colMap.lastName ?? -1] ?? "").trim();
|
||||||
|
const phone = (cols[colMap.phone ?? -1] ?? "").trim();
|
||||||
|
|
||||||
|
const row: ParsedRow = {
|
||||||
|
index: i,
|
||||||
|
email,
|
||||||
|
name: [name, lastName].filter(Boolean).join(" ") || email.split("@")[0],
|
||||||
|
lastName,
|
||||||
|
phone,
|
||||||
|
status: "new",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
row.status = "error";
|
||||||
|
row.errorMsg = "Некорректный email";
|
||||||
|
countError++;
|
||||||
|
} else if (existingByEmail.has(email)) {
|
||||||
|
row.existingId = existingByEmail.get(email);
|
||||||
|
if (updateExisting) {
|
||||||
|
row.status = "update";
|
||||||
|
countUpdate++;
|
||||||
|
} else {
|
||||||
|
row.status = "error";
|
||||||
|
row.errorMsg = "Уже существует (обновление отключено)";
|
||||||
|
countError++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
row.status = "new";
|
||||||
|
countNew++;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, countNew, countUpdate, countError };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apply action ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function applyImport(
|
||||||
|
rows: ParsedRow[],
|
||||||
|
options: ImportOptions
|
||||||
|
): Promise<ApplyResult> {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
let created = 0, updated = 0;
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
const validRows = rows.filter((r) => r.status !== "error");
|
||||||
|
|
||||||
|
for (const row of validRows) {
|
||||||
|
try {
|
||||||
|
if (row.status === "new") {
|
||||||
|
// Generate a random password
|
||||||
|
const rawPassword = Math.random().toString(36).slice(-10) + "A1!";
|
||||||
|
const hashedPassword = await bcrypt.hash(rawPassword, 10);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: row.name,
|
||||||
|
email: row.email,
|
||||||
|
emailVerified: options.autoVerifyEmail,
|
||||||
|
role: "student",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.courseId) {
|
||||||
|
const expiresAt = options.accessDays > 0
|
||||||
|
? new Date(Date.now() + options.accessDays * 86_400_000)
|
||||||
|
: null;
|
||||||
|
await prisma.courseEnrollment.upsert({
|
||||||
|
where: { userId_courseId: { userId: user.id, courseId: options.courseId } },
|
||||||
|
update: { expiresAt },
|
||||||
|
create: { userId: user.id, courseId: options.courseId, expiresAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.sendWelcome) {
|
||||||
|
await sendWelcomeEmail(user.email, user.name).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
created++;
|
||||||
|
} else if (row.status === "update" && row.existingId) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: row.existingId },
|
||||||
|
data: {
|
||||||
|
name: row.name || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (options.courseId) {
|
||||||
|
const expiresAt = options.accessDays > 0
|
||||||
|
? new Date(Date.now() + options.accessDays * 86_400_000)
|
||||||
|
: null;
|
||||||
|
await prisma.courseEnrollment.upsert({
|
||||||
|
where: { userId_courseId: { userId: row.existingId, courseId: options.courseId } },
|
||||||
|
update: { expiresAt },
|
||||||
|
create: { userId: row.existingId, courseId: options.courseId, expiresAt },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`${row.email}: ${e instanceof Error ? e.message : "Ошибка"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, errors };
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { CsvImporter } from "@/components/admin/csv-importer";
|
||||||
|
import { CsvExporter } from "@/components/admin/csv-exporter";
|
||||||
|
|
||||||
|
export const metadata = { title: "Импорт и экспорт" };
|
||||||
|
|
||||||
|
export default async function ImportExportPage() {
|
||||||
|
const courses = await prisma.course.findMany({
|
||||||
|
orderBy: { title: "asc" },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Импорт и экспорт
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Import */}
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Импорт учеников из CSV
|
||||||
|
</p>
|
||||||
|
<CsvImporter courses={courses} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export */}
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Экспорт учеников в CSV
|
||||||
|
</p>
|
||||||
|
<CsvExporter courses={courses} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-20
@@ -1,30 +1,24 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminNav } from "@/components/admin/admin-nav";
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
if (session.user.role !== "admin") redirect("/dashboard");
|
if (session.user.role !== "admin") redirect("/dashboard");
|
||||||
|
|
||||||
return (
|
const questionsBadge = await prisma.studentQuestion.count({
|
||||||
<div className="min-h-screen flex bg-slate-50">
|
where: {
|
||||||
<aside className="w-56 bg-slate-900 text-white flex flex-col shrink-0 fixed h-full z-10">
|
messages: {
|
||||||
<div className="px-5 py-5 border-b border-slate-800">
|
some: {
|
||||||
<p className="font-bold text-amber-400 text-base">Second Brain</p>
|
isRead: false,
|
||||||
<p className="text-xs text-slate-400 mt-0.5">Админ-панель</p>
|
author: { role: "student" },
|
||||||
</div>
|
},
|
||||||
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
|
},
|
||||||
<AdminNav />
|
},
|
||||||
</nav>
|
});
|
||||||
<div className="p-4 border-t border-slate-800">
|
|
||||||
<p className="text-xs text-slate-400 mb-3 truncate">{session.user.name}</p>
|
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div className="ml-56 flex-1 min-h-screen">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
|
||||||
|
|
||||||
|
export default async function AdminQuestionsPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") redirect("/login");
|
||||||
|
|
||||||
|
return <QuestionSplitView currentUserId={session.user.id} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ quizId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminQuizAttemptsPage({ params }: Props) {
|
||||||
|
const { quizId } = await params;
|
||||||
|
|
||||||
|
const quiz = await prisma.quiz.findUnique({
|
||||||
|
where: { id: quizId },
|
||||||
|
include: {
|
||||||
|
questions: { orderBy: { order: "asc" } },
|
||||||
|
attempts: { orderBy: { completedAt: "desc" } },
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
module: {
|
||||||
|
select: {
|
||||||
|
course: { select: { title: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!quiz) notFound();
|
||||||
|
|
||||||
|
const userIds = [...new Set(quiz.attempts.map((a) => a.userId))];
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: userIds } },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
});
|
||||||
|
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-4xl">
|
||||||
|
<nav
|
||||||
|
className="text-xs mb-6 uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Link href="/admin/quizzes" className="hover:underline">
|
||||||
|
Тесты
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span style={{ color: "var(--foreground)" }}>{quiz.lesson.title}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-lg font-bold">{quiz.lesson.title}</h1>
|
||||||
|
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{quiz.lesson.module.course.title} · {quiz.questions.length} вопросов · {quiz.attempts.length} ответов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quiz.attempts.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Ответов пока нет
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{quiz.attempts.map((attempt) => {
|
||||||
|
const answers = attempt.answers as Record<string, string>;
|
||||||
|
const user = userMap[attempt.userId];
|
||||||
|
const date = new Date(attempt.completedAt).toLocaleString("ru-RU", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "2-digit",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={attempt.id}
|
||||||
|
className="px-4 py-4 space-y-3"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{user?.name ?? "—"}</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{user?.email ?? attempt.userId}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{date}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2" style={{ borderTop: "1px solid var(--border)" }}>
|
||||||
|
{quiz.questions.map((q, idx) => (
|
||||||
|
<div key={q.id}>
|
||||||
|
<p
|
||||||
|
className="text-xs font-medium mb-0.5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{idx + 1}. {q.text}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">
|
||||||
|
{answers[q.id]?.trim() || <span style={{ color: "var(--muted-foreground)" }}>—</span>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata = { title: "Тесты" };
|
||||||
|
|
||||||
|
export default async function AdminQuizzesPage() {
|
||||||
|
const quizzes = await prisma.quiz.findMany({
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
_count: { select: { questions: true, attempts: true } },
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
module: {
|
||||||
|
select: {
|
||||||
|
course: { select: { title: true, slug: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Тесты
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{quizzes.length} тестов
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{quizzes.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Тестов нет
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
{quizzes.map((quiz) => (
|
||||||
|
<Link
|
||||||
|
key={quiz.id}
|
||||||
|
href={`/admin/quizzes/${quiz.id}`}
|
||||||
|
className="flex items-center gap-4 px-4 py-3 hover:[background:var(--muted)] transition-colors"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium truncate">
|
||||||
|
{quiz.lesson.title}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="text-xs mt-0.5 truncate"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{quiz.lesson.module.course.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 shrink-0 text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
<span>{quiz._count.questions} вопр.</span>
|
||||||
|
<span
|
||||||
|
className="font-bold"
|
||||||
|
style={{ color: quiz._count.attempts > 0 ? "var(--foreground)" : undefined }}
|
||||||
|
>
|
||||||
|
{quiz._count.attempts} ответов
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>→</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { SETTINGS_DEFAULTS, type SettingsKey } from "@/lib/settings";
|
||||||
|
|
||||||
|
export async function saveSettings(data: Record<string, string>) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
|
||||||
|
|
||||||
|
const validKeys = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
|
||||||
|
const ops = validKeys
|
||||||
|
.filter((key) => key in data)
|
||||||
|
.map((key) =>
|
||||||
|
prisma.settings.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value: data[key] },
|
||||||
|
create: { key, value: data[key] },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(ops);
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { SettingsForm } from "@/components/admin/settings-form";
|
||||||
|
|
||||||
|
export const metadata = { title: "Настройки платформы" };
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Настройки платформы
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<SettingsForm initial={settings} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function bulkGrantAccess(
|
||||||
|
userId: string,
|
||||||
|
courseIds: string[],
|
||||||
|
expiresAt?: string | null
|
||||||
|
) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
const expiry = expiresAt ? new Date(expiresAt) : null;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
courseIds.map(async (courseId) => {
|
||||||
|
await prisma.courseEnrollment.upsert({
|
||||||
|
where: { userId_courseId: { userId, courseId } },
|
||||||
|
update: { expiresAt: expiry },
|
||||||
|
create: { userId, courseId, expiresAt: expiry },
|
||||||
|
});
|
||||||
|
await prisma.accessLog.create({
|
||||||
|
data: {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
action: "granted",
|
||||||
|
method: "bulk",
|
||||||
|
grantedById: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUserContact(
|
||||||
|
userId: string,
|
||||||
|
data: { name: string; email: string; phone: string; birthday: string; comment: string }
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
name: data.name.trim() || undefined,
|
||||||
|
email: data.email.trim() || undefined,
|
||||||
|
phone: data.phone.trim() || null,
|
||||||
|
birthday: data.birthday ? new Date(data.birthday) : null,
|
||||||
|
comment: data.comment.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addBalanceTransaction(
|
||||||
|
userId: string,
|
||||||
|
data: { amount: string; description: string }
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
const amount = parseFloat(data.amount.replace(",", "."));
|
||||||
|
if (isNaN(amount) || amount === 0) throw new Error("Некорректная сумма");
|
||||||
|
await prisma.balanceTransaction.create({
|
||||||
|
data: { userId, amount, description: data.description.trim() },
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBalanceTransaction(userId: string, txId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.balanceTransaction.delete({ where: { id: txId } });
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetUserPassword(userId: string): Promise<{ tempPassword: string }> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
const account = await prisma.account.findFirst({
|
||||||
|
where: { userId, providerId: "credential" },
|
||||||
|
});
|
||||||
|
if (!account) throw new Error("Аккаунт с паролем не найден");
|
||||||
|
|
||||||
|
const chars = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
const tempPassword = Array.from({ length: 10 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
|
||||||
|
|
||||||
|
const hash = await bcrypt.hash(tempPassword, 10);
|
||||||
|
await prisma.account.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data: { password: hash },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { tempPassword };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeUserAccess(userId: string, courseId: string) {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
await prisma.courseEnrollment.delete({
|
||||||
|
where: { userId_courseId: { userId, courseId } },
|
||||||
|
});
|
||||||
|
await prisma.accessLog.create({
|
||||||
|
data: {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
action: "revoked",
|
||||||
|
method: "manual",
|
||||||
|
grantedById: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
|
||||||
|
import { UserContactEditor } from "@/components/admin/user-contact-editor";
|
||||||
|
import { UserBalanceBlock } from "@/components/admin/user-balance-block";
|
||||||
|
import { ResetPasswordButton } from "@/components/admin/reset-password-button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ userId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function UserPage({ params }: Props) {
|
||||||
|
const { userId } = await params;
|
||||||
|
|
||||||
|
const [user, allCourses] = await Promise.all([
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
enrollments: {
|
||||||
|
include: { course: { select: { id: true, title: true, published: true } } },
|
||||||
|
orderBy: { enrolledAt: "desc" },
|
||||||
|
},
|
||||||
|
accessLogs: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 30,
|
||||||
|
include: {
|
||||||
|
course: { select: { title: true } },
|
||||||
|
grantedBy: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
balanceTransactions: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.course.findMany({
|
||||||
|
orderBy: { title: "asc" },
|
||||||
|
select: { id: true, title: true, published: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!user) notFound();
|
||||||
|
|
||||||
|
const roleLabel: Record<string, string> = { admin: "Администратор", curator: "Куратор", student: "Ученик" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl">
|
||||||
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
<Link href="/admin/users" className="hover:underline">Пользователи</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span style={{ color: "var(--foreground)" }}>{user.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User info */}
|
||||||
|
<section className="card-aubade p-6 mb-6 space-y-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">{user.name}</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<span className="tag-aubade">{roleLabel[user.role] ?? user.role}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
с {new Date(user.createdAt).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ borderTop: "2px solid var(--border)", paddingTop: "1rem" }}>
|
||||||
|
<UserContactEditor
|
||||||
|
userId={userId}
|
||||||
|
name={user.name ?? ""}
|
||||||
|
email={user.email}
|
||||||
|
phone={user.phone ?? null}
|
||||||
|
birthday={user.birthday ?? null}
|
||||||
|
comment={user.comment ?? null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Reset password */}
|
||||||
|
<section className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Пароль
|
||||||
|
</p>
|
||||||
|
<ResetPasswordButton userId={userId} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Balance */}
|
||||||
|
<section className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Баланс
|
||||||
|
</p>
|
||||||
|
<UserBalanceBlock
|
||||||
|
userId={userId}
|
||||||
|
transactions={user.balanceTransactions.map((tx) => ({
|
||||||
|
id: tx.id,
|
||||||
|
amount: Number(tx.amount),
|
||||||
|
description: tx.description,
|
||||||
|
createdAt: tx.createdAt,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Enrollments + bulk grant */}
|
||||||
|
<section className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Доступ к курсам
|
||||||
|
</p>
|
||||||
|
<UserEnrollmentManager
|
||||||
|
userId={userId}
|
||||||
|
allCourses={allCourses}
|
||||||
|
enrollments={user.enrollments.map((e) => ({
|
||||||
|
courseId: e.courseId,
|
||||||
|
expiresAt: e.expiresAt,
|
||||||
|
courseTitle: e.course.title,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Access log */}
|
||||||
|
{user.accessLogs.length > 0 && (
|
||||||
|
<section className="card-aubade p-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
История доступа
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5 max-h-72 overflow-y-auto">
|
||||||
|
{user.accessLogs.map((log) => (
|
||||||
|
<div key={log.id} className="flex items-center gap-3 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<span style={{ color: log.action === "granted" ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", fontWeight: 700, minWidth: 70 }}>
|
||||||
|
{log.action === "granted" ? "▲ Выдан" : "▼ Отозван"}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1">{log.course.title}</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>{log.grantedBy?.name ?? "—"}</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{new Date(log.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { sendWelcomeEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
export async function createUser(data: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
sendWelcome: boolean;
|
||||||
|
}): Promise<{ success: true; userId: string } | { success: false; error: string }> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return { success: false, error: "Нет доступа" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, password, role, emailVerified, sendWelcome } = data;
|
||||||
|
|
||||||
|
if (!name.trim() || !email.trim() || !password.trim()) {
|
||||||
|
return { success: false, error: "Заполните все обязательные поля" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
return { success: false, error: "Пользователь с таким email уже существует" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { name: name.trim(), email: email.trim().toLowerCase(), role, emailVerified },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create credential account (Better Auth's internal structure)
|
||||||
|
await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sendWelcome) {
|
||||||
|
await sendWelcomeEmail(user.email, user.name).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, userId: user.id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { sendCourseAccessEmail } from "@/lib/email";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantCourseAccess(
|
||||||
|
userId: string,
|
||||||
|
courseId: string,
|
||||||
|
expiresAt: Date | null
|
||||||
|
): Promise<{ ok: true } | { error: string }> {
|
||||||
|
try {
|
||||||
|
const session = await requireAdmin();
|
||||||
|
|
||||||
|
const [user, course] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
||||||
|
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!user) return { error: "Пользователь не найден" };
|
||||||
|
if (!course) return { error: "Курс не найден" };
|
||||||
|
|
||||||
|
const existing = await prisma.courseEnrollment.findUnique({
|
||||||
|
where: { userId_courseId: { userId, courseId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.courseEnrollment.upsert({
|
||||||
|
where: { userId_courseId: { userId, courseId } },
|
||||||
|
update: { expiresAt },
|
||||||
|
create: { userId, courseId, expiresAt },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.accessLog.create({
|
||||||
|
data: {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
action: "granted",
|
||||||
|
method: "quick",
|
||||||
|
grantedById: session.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email only on new enrollment (not on update)
|
||||||
|
if (!existing) {
|
||||||
|
await sendCourseAccessEmail(user.email, user.name ?? user.email, course.title).catch(
|
||||||
|
(e) => console.error("[enroll-action] sendCourseAccessEmail:", e)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/users");
|
||||||
|
revalidatePath(`/admin/users/${userId}`);
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[enroll-action] grantCourseAccess:", e);
|
||||||
|
return { error: "Произошла ошибка. Попробуйте ещё раз." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPublishedCourses(): Promise<{ id: string; title: string }[]> {
|
||||||
|
await requireAdmin();
|
||||||
|
return prisma.course.findMany({
|
||||||
|
where: { published: true },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
orderBy: { title: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { CreateUserForm } from "@/components/admin/create-user-form";
|
||||||
|
|
||||||
|
export const metadata = { title: "Новый пользователь" };
|
||||||
|
|
||||||
|
export default function NewUserPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-8">
|
||||||
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
<Link href="/admin/users" className="hover:underline">Пользователи</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span style={{ color: "var(--foreground)" }}>Новый пользователь</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Создание пользователя
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<CreateUserForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+131
-58
@@ -1,70 +1,143 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import Link from "next/link";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { UsersTable } from "@/components/admin/users-table";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { UsersSearch } from "@/components/admin/users-search";
|
||||||
|
|
||||||
const roleLabel: Record<string, string> = {
|
const PAGE_SIZE = 20;
|
||||||
admin: "Администратор",
|
|
||||||
curator: "Куратор",
|
|
||||||
student: "Ученик",
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
|
interface Props {
|
||||||
admin: "default",
|
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string; emailVerified?: string }>;
|
||||||
curator: "secondary",
|
}
|
||||||
student: "outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
export default async function UsersPage({ searchParams }: Props) {
|
||||||
const users = await prisma.user.findMany({
|
const { search = "", role = "", page = "1", balance = "", emailVerified = "" } = await searchParams;
|
||||||
orderBy: { createdAt: "desc" },
|
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||||
include: { _count: { select: { enrollments: true } } },
|
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||||
});
|
|
||||||
|
// Collect userIds with non-zero balance if filter is active
|
||||||
|
let balanceUserIds: string[] | null = null;
|
||||||
|
if (balance === "nonzero") {
|
||||||
|
const groups = await prisma.balanceTransaction.groupBy({
|
||||||
|
by: ["userId"],
|
||||||
|
_sum: { amount: true },
|
||||||
|
having: { amount: { _sum: { not: { equals: 0 } } } },
|
||||||
|
});
|
||||||
|
balanceUserIds = groups.map((g) => g.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(search
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ email: { contains: search, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(role ? { role } : {}),
|
||||||
|
...(emailVerified === "true" ? { emailVerified: true } : {}),
|
||||||
|
...(emailVerified === "false" ? { emailVerified: false } : {}),
|
||||||
|
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: PAGE_SIZE,
|
||||||
|
include: {
|
||||||
|
_count: { select: { enrollments: true } },
|
||||||
|
enrollments: {
|
||||||
|
include: { course: { select: { title: true } } },
|
||||||
|
orderBy: { enrolledAt: "desc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
const tableUsers = users.map((u) => ({
|
||||||
|
id: u.id,
|
||||||
|
name: u.name,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role,
|
||||||
|
emailVerified: u.emailVerified,
|
||||||
|
createdAt: u.createdAt,
|
||||||
|
enrollmentCount: u._count.enrollments,
|
||||||
|
enrollments: u.enrollments.map((e) => ({
|
||||||
|
courseId: e.courseId,
|
||||||
|
courseTitle: e.course.title,
|
||||||
|
expiresAt: e.expiresAt,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function pageUrl(p: number) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (search) params.set("search", search);
|
||||||
|
if (role) params.set("role", role);
|
||||||
|
if (emailVerified) params.set("emailVerified", emailVerified);
|
||||||
|
if (balance) params.set("balance", balance);
|
||||||
|
params.set("page", String(p));
|
||||||
|
return `/admin/users?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
|
<div>
|
||||||
<p className="text-slate-500 text-sm mt-0.5">{users.length} пользователей</p>
|
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-0.5">{total} пользователей</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/users/new"
|
||||||
|
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<UserPlus size={14} />
|
||||||
|
Добавить пользователя
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden">
|
{/* Filters */}
|
||||||
<table className="w-full">
|
<Suspense>
|
||||||
<thead>
|
<UsersSearch initialSearch={search} initialRole={role} initialEmailVerified={emailVerified} initialBalance={balance} />
|
||||||
<tr className="border-b border-slate-100 bg-slate-50">
|
</Suspense>
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Пользователь</th>
|
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Роль</th>
|
<UsersTable users={tableUsers} />
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Курсов</th>
|
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Email подтверждён</th>
|
{/* Pagination */}
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Зарегистрирован</th>
|
{totalPages > 1 && (
|
||||||
</tr>
|
<div className="flex items-center gap-1 mt-4">
|
||||||
</thead>
|
{currentPage > 1 && (
|
||||||
<tbody>
|
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs">←</Link>
|
||||||
{users.map((user) => (
|
)}
|
||||||
<tr key={user.id} className="border-b border-slate-50 last:border-0 hover:bg-slate-50">
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
<td className="px-5 py-3">
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
<p className="font-medium text-slate-800">{user.name}</p>
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||||
<p className="text-xs text-slate-400">{user.email}</p>
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||||
</td>
|
acc.push(p);
|
||||||
<td className="px-5 py-3">
|
return acc;
|
||||||
<Badge variant={roleVariant[user.role] ?? "outline"}>
|
}, [])
|
||||||
{roleLabel[user.role] ?? user.role}
|
.map((p, i) =>
|
||||||
</Badge>
|
p === "…" ? (
|
||||||
</td>
|
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}>…</span>
|
||||||
<td className="px-5 py-3 text-sm text-slate-600">
|
) : (
|
||||||
{user._count.enrollments}
|
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
|
||||||
</td>
|
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
|
||||||
<td className="px-5 py-3">
|
{p}
|
||||||
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
|
</Link>
|
||||||
{user.emailVerified ? "Да" : "Нет"}
|
)
|
||||||
</span>
|
)}
|
||||||
</td>
|
{currentPage < totalPages && (
|
||||||
<td className="px-5 py-3 text-sm text-slate-400">
|
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||||
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
|
)}
|
||||||
</td>
|
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
)}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import iconv from "iconv-lite";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Нет доступа" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const courseId = searchParams.get("courseId") || undefined;
|
||||||
|
const encoding = (searchParams.get("encoding") as "utf8" | "win1251") ?? "utf8";
|
||||||
|
|
||||||
|
// Fetch users
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: courseId
|
||||||
|
? { enrollments: { some: { courseId } } }
|
||||||
|
: { role: "student" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
enrollments: {
|
||||||
|
include: { course: { select: { title: true } } },
|
||||||
|
},
|
||||||
|
progress: { select: { lessonId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build CSV rows
|
||||||
|
const csvHeaders = ["Email", "Имя", "Телефон", "Дата регистрации", "Курсы", "Прогресс (уроков)"];
|
||||||
|
const rows = users.map((u) => {
|
||||||
|
const courses = u.enrollments.map((e) => e.course.title).join(" | ");
|
||||||
|
const progress = u.progress.length;
|
||||||
|
const registeredAt = new Date(u.createdAt).toLocaleDateString("ru-RU");
|
||||||
|
return [u.email, u.name, "", registeredAt, courses, String(progress)];
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRows = [csvHeaders, ...rows];
|
||||||
|
const csvText = allRows
|
||||||
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(";"))
|
||||||
|
.join("\r\n");
|
||||||
|
|
||||||
|
// Encode
|
||||||
|
let body: Buffer;
|
||||||
|
let charset: string;
|
||||||
|
if (encoding === "win1251") {
|
||||||
|
body = iconv.encode(csvText, "win1251");
|
||||||
|
charset = "windows-1251";
|
||||||
|
} else {
|
||||||
|
body = Buffer.from("\uFEFF" + csvText, "utf8"); // BOM for Excel
|
||||||
|
charset = "utf-8";
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `students_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
|
||||||
|
return new NextResponse(body as unknown as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": `text/csv; charset=${charset}`,
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { mdToTiptap } from "@/lib/md-to-tiptap";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!file.name.endsWith(".md")) {
|
||||||
|
return NextResponse.json({ error: "Only .md files are supported" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await file.text();
|
||||||
|
const { data: fm, content } = matter(raw);
|
||||||
|
|
||||||
|
// Extract known frontmatter fields (Obsidian-compatible naming)
|
||||||
|
const title =
|
||||||
|
typeof fm.title === "string" ? fm.title.trim() : null;
|
||||||
|
const kinescopeId =
|
||||||
|
(fm.kinescopeId ?? fm.kinescope_id ?? fm.videoId ?? fm.video_id ?? "") as string;
|
||||||
|
const order =
|
||||||
|
typeof fm.order === "number" ? fm.order : null;
|
||||||
|
const published =
|
||||||
|
typeof fm.published === "boolean" ? fm.published : null;
|
||||||
|
|
||||||
|
const tiptapContent = mdToTiptap(content);
|
||||||
|
|
||||||
|
return NextResponse.json({ title, kinescopeId, order, published, content: tiptapContent });
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { uploadFile, deleteFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") return null;
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
if (!await requireAdmin()) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
const lessonId = form.get("lessonId") as string | null;
|
||||||
|
const label = (form.get("label") as string | null)?.trim() || null;
|
||||||
|
if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||||
|
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
|
||||||
|
|
||||||
|
const name = label ?? file.name;
|
||||||
|
const existing = await prisma.lessonFile.findFirst({ where: { lessonId, name } });
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop() ?? "bin";
|
||||||
|
const key = `lessons/${lessonId}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const oldKey = existing.url.split(`/${process.env.S3_BUCKET}/`)[1];
|
||||||
|
if (oldKey) await deleteFile(oldKey).catch(() => {});
|
||||||
|
const lessonFile = await prisma.lessonFile.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { url, size: file.size },
|
||||||
|
});
|
||||||
|
return NextResponse.json(lessonFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lessonFile = await prisma.lessonFile.create({
|
||||||
|
data: { lessonId, name, url, size: file.size },
|
||||||
|
});
|
||||||
|
return NextResponse.json(lessonFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(req: NextRequest) {
|
||||||
|
if (!await requireAdmin()) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fileId, label } = await req.json();
|
||||||
|
if (!fileId || typeof label !== "string") {
|
||||||
|
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const updated = await prisma.lessonFile.update({
|
||||||
|
where: { id: fileId },
|
||||||
|
data: { name: label.trim() || undefined },
|
||||||
|
});
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
if (!await requireAdmin()) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fileId, url } = await req.json();
|
||||||
|
if (url) {
|
||||||
|
const key = (url as string).split(`/${process.env.S3_BUCKET}/`)[1];
|
||||||
|
if (key) await deleteFile(key).catch(() => {});
|
||||||
|
}
|
||||||
|
await prisma.lessonFile.delete({ where: { id: fileId } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ lessonId: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { lessonId } = await params;
|
||||||
|
const body = await req.json() as {
|
||||||
|
title: string;
|
||||||
|
kinescopeId: string;
|
||||||
|
content: object;
|
||||||
|
published: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.lesson.update({
|
||||||
|
where: { id: lessonId },
|
||||||
|
data: {
|
||||||
|
title: body.title,
|
||||||
|
kinescopeId: body.kinescopeId || null,
|
||||||
|
content: body.content,
|
||||||
|
published: body.published,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -14,6 +14,9 @@ export async function POST(req: NextRequest) {
|
|||||||
const file = form.get("file") as File | null;
|
const file = form.get("file") as File | null;
|
||||||
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
|
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
|
||||||
|
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
|
||||||
|
|
||||||
const ext = file.name.split(".").pop() ?? "bin";
|
const ext = file.name.split(".").pop() ?? "bin";
|
||||||
const key = `uploads/${randomUUID()}.${ext}`;
|
const key = `uploads/${randomUUID()}.${ext}`;
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { uploadFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||||
|
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
|
||||||
|
|
||||||
|
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
|
||||||
|
const key = `feedback-audio/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type || "audio/webm");
|
||||||
|
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { uploadFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||||
|
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop() ?? "bin";
|
||||||
|
const key = `feedback/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type);
|
||||||
|
|
||||||
|
return NextResponse.json({ name: file.name, url, size: file.size });
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
if (session.user.role !== "admin" && session.user.role !== "curator") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const question = await prisma.studentQuestion.findUnique({ where: { id } });
|
||||||
|
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (question.status === "CLOSED") {
|
||||||
|
return NextResponse.json({ error: "Already closed" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.studentQuestion.update({
|
||||||
|
where: { id },
|
||||||
|
data: { status: "CLOSED", closedAt: new Date(), closedById: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
interface FileAttachment {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildS3Prefix(): string {
|
||||||
|
const endpoint = process.env.S3_ENDPOINT ?? "";
|
||||||
|
const bucket = process.env.S3_BUCKET ?? "";
|
||||||
|
// e.g. https://fsn1.your-objectstorage.com/lms-uploads/
|
||||||
|
return `${endpoint}/${bucket}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const isStaff = session.user.role === "admin" || session.user.role === "curator";
|
||||||
|
|
||||||
|
const question = await prisma.studentQuestion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!isStaff && question.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (question.status === "CLOSED") {
|
||||||
|
return NextResponse.json({ error: "Question is closed" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { text, files } = body as { text: string; files?: FileAttachment[] };
|
||||||
|
|
||||||
|
if (!text?.trim()) {
|
||||||
|
return NextResponse.json({ error: "text is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Prefix = buildS3Prefix();
|
||||||
|
const safeFiles = files
|
||||||
|
?.filter(
|
||||||
|
(f) =>
|
||||||
|
typeof f.name === "string" &&
|
||||||
|
typeof f.url === "string" &&
|
||||||
|
f.url.startsWith("https://") &&
|
||||||
|
f.url.startsWith(s3Prefix) &&
|
||||||
|
typeof f.size === "number"
|
||||||
|
)
|
||||||
|
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
||||||
|
|
||||||
|
const [msg] = await prisma.$transaction([
|
||||||
|
prisma.studentQuestionMessage.create({
|
||||||
|
data: {
|
||||||
|
questionId: id,
|
||||||
|
authorId: session.user.id,
|
||||||
|
text: text.trim(),
|
||||||
|
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
|
||||||
|
},
|
||||||
|
include: { author: { select: { id: true, name: true, role: true } } },
|
||||||
|
}),
|
||||||
|
prisma.studentQuestion.update({
|
||||||
|
where: { id },
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send notifications (fire-and-forget, outside transaction)
|
||||||
|
if (isStaff) {
|
||||||
|
void sendQuestionReplyEmail(
|
||||||
|
question.user.email,
|
||||||
|
question.user.name,
|
||||||
|
question.title,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const staff = await prisma.user.findMany({
|
||||||
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
});
|
||||||
|
void Promise.all(
|
||||||
|
staff.map((s) =>
|
||||||
|
sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(msg, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const isStaff = session.user.role === "admin" || session.user.role === "curator";
|
||||||
|
|
||||||
|
const question = await prisma.studentQuestion.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
course: { select: { id: true, title: true } },
|
||||||
|
messages: {
|
||||||
|
include: { author: { select: { id: true, name: true, role: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!isStaff && question.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark unread messages as read
|
||||||
|
const unreadWhere = isStaff
|
||||||
|
? { questionId: id, isRead: false, author: { role: "student" } }
|
||||||
|
: { questionId: id, isRead: false, NOT: { authorId: session.user.id } };
|
||||||
|
|
||||||
|
await prisma.studentQuestionMessage.updateMany({
|
||||||
|
where: unreadWhere,
|
||||||
|
data: { isRead: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(question);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendQuestionCreatedEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
interface FileAttachment {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildS3Prefix(): string {
|
||||||
|
const endpoint = process.env.S3_ENDPOINT ?? "";
|
||||||
|
const bucket = process.env.S3_BUCKET ?? "";
|
||||||
|
return `${endpoint}/${bucket}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const isStaff = session.user.role === "admin" || session.user.role === "curator";
|
||||||
|
|
||||||
|
const userSelect = isStaff
|
||||||
|
? { id: true as const, name: true as const, email: true as const }
|
||||||
|
: { id: true as const, name: true as const };
|
||||||
|
|
||||||
|
const questions = await prisma.studentQuestion.findMany({
|
||||||
|
where: isStaff ? undefined : { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
user: { select: userSelect },
|
||||||
|
course: { select: { id: true, title: true } },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
messages: {
|
||||||
|
where: isStaff
|
||||||
|
? { isRead: false, author: { role: "student" } }
|
||||||
|
: { isRead: false, NOT: { authorId: session.user.id } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
questions.map((q) => ({
|
||||||
|
id: q.id,
|
||||||
|
title: q.title,
|
||||||
|
status: q.status,
|
||||||
|
createdAt: q.createdAt,
|
||||||
|
updatedAt: q.updatedAt,
|
||||||
|
user: q.user,
|
||||||
|
course: q.course,
|
||||||
|
unreadCount: q._count.messages,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
if (session.user.role !== "student") {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const { title, text, courseId, files } = body as {
|
||||||
|
title: string;
|
||||||
|
text: string;
|
||||||
|
courseId?: string;
|
||||||
|
files?: FileAttachment[];
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!title?.trim() || !text?.trim()) {
|
||||||
|
return NextResponse.json({ error: "title and text are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const s3Prefix = buildS3Prefix();
|
||||||
|
const safeFiles = files
|
||||||
|
?.filter(
|
||||||
|
(f) =>
|
||||||
|
typeof f.name === "string" &&
|
||||||
|
typeof f.url === "string" &&
|
||||||
|
f.url.startsWith("https://") &&
|
||||||
|
f.url.startsWith(s3Prefix) &&
|
||||||
|
typeof f.size === "number"
|
||||||
|
)
|
||||||
|
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
||||||
|
|
||||||
|
const question = await prisma.studentQuestion.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
courseId: courseId ?? null,
|
||||||
|
title: title.trim(),
|
||||||
|
messages: {
|
||||||
|
create: {
|
||||||
|
authorId: session.user.id,
|
||||||
|
text: text.trim(),
|
||||||
|
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const staff = await prisma.user.findMany({
|
||||||
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
});
|
||||||
|
void Promise.all(
|
||||||
|
staff.map((s) =>
|
||||||
|
sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(question, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { uploadFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||||
|
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
|
||||||
|
|
||||||
|
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
|
||||||
|
const key = `homework-audio/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type || "audio/webm");
|
||||||
|
|
||||||
|
return NextResponse.json({ url });
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { uploadFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||||
|
|
||||||
|
const MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop() ?? "bin";
|
||||||
|
const key = `homework/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type);
|
||||||
|
|
||||||
|
return NextResponse.json({ name: file.name, url, size: file.size });
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { uploadFile } from "@/lib/s3";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
const ALLOWED_TYPES = new Set([
|
||||||
|
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||||
|
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
|
||||||
|
]);
|
||||||
|
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file") as File | null;
|
||||||
|
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
|
||||||
|
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Разрешены только jpg, png, pdf, md" },
|
||||||
|
{ status: 415 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
||||||
|
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
|
||||||
|
if (!ALLOWED_EXTS.has(ext)) {
|
||||||
|
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `questions/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
|
const url = await uploadFile(key, buffer, file.type);
|
||||||
|
|
||||||
|
return NextResponse.json({ name: file.name, url, size: file.size });
|
||||||
|
}
|
||||||
@@ -1,43 +1,57 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function CuratorDashboard() {
|
export default async function CuratorDashboard() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
if (session.user.role !== "curator" && session.user.role !== "admin") {
|
|
||||||
redirect("/dashboard");
|
const [pending, total, recentFeedbacks] = await Promise.all([
|
||||||
}
|
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
|
||||||
|
prisma.homeworkSubmission.count(),
|
||||||
|
prisma.homeworkFeedback.count({
|
||||||
|
where: {
|
||||||
|
createdAt: { gte: new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) },
|
||||||
|
curatorId: session.user.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-green-50">
|
<div className="p-8 max-w-3xl">
|
||||||
<header className="bg-white border-b border-green-100 px-6 py-4 flex items-center justify-between">
|
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
|
||||||
<h1 className="text-xl font-bold text-green-900">Second Brain — Куратор</h1>
|
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>Панель куратора</p>
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
<div className="grid grid-cols-3 gap-4 mb-8">
|
||||||
<LogoutButton />
|
<StatCard label="Ожидают проверки" value={pending} accent={pending > 0} />
|
||||||
|
<StatCard label="Всего сдано" value={total} />
|
||||||
|
<StatCard label="Проверено за 7 дней" value={recentFeedbacks} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pending > 0 ? (
|
||||||
|
<Link href="/curator/homework" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
|
||||||
|
Перейти к проверке ({pending}) →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="card-aubade p-8 text-center">
|
||||||
|
<p className="text-3xl mb-2">✓</p>
|
||||||
|
<p className="font-bold">Все работы проверены</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>Новых заданий нет</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
)}
|
||||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
</div>
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
);
|
||||||
Панель куратора
|
}
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p>
|
function StatCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
<div className="card-aubade p-4">
|
||||||
<p className="text-3xl mb-2">📝</p>
|
<p className="text-3xl font-bold" style={{ color: accent ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
|
||||||
<p className="font-medium text-gray-800">Домашние задания</p>
|
{value}
|
||||||
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
|
</p>
|
||||||
</div>
|
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{label}</p>
|
||||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
|
||||||
<p className="text-3xl mb-2">👥</p>
|
|
||||||
<p className="font-medium text-gray-800">Мои ученики</p>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { sendFeedbackReceivedEmail } from "@/lib/email";
|
||||||
|
import { getSetting, asBool } from "@/lib/settings";
|
||||||
|
|
||||||
|
async function requireCurator() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
|
||||||
|
throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function submitFeedback(
|
||||||
|
submissionId: string,
|
||||||
|
data: {
|
||||||
|
text: string;
|
||||||
|
files?: { name: string; url: string; size: number }[];
|
||||||
|
audioUrl?: string | null;
|
||||||
|
action: "approve" | "reject";
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const session = await requireCurator();
|
||||||
|
const status = data.action === "approve" ? "APPROVED" : "REJECTED";
|
||||||
|
|
||||||
|
const submission = await prisma.homeworkSubmission.findUnique({
|
||||||
|
where: { id: submissionId },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, email: true, name: true } },
|
||||||
|
homework: {
|
||||||
|
include: {
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
id: true,
|
||||||
|
module: { select: { course: { select: { slug: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!submission) throw new Error("Submission not found");
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.homeworkFeedback.create({
|
||||||
|
data: {
|
||||||
|
submissionId,
|
||||||
|
curatorId: session.user.id,
|
||||||
|
text: data.text,
|
||||||
|
files: data.files ?? [],
|
||||||
|
audioUrl: data.audioUrl ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.homeworkSubmission.update({
|
||||||
|
where: { id: submissionId },
|
||||||
|
data: { status, statusAt: new Date() },
|
||||||
|
});
|
||||||
|
if (status === "APPROVED") {
|
||||||
|
await tx.lessonProgress.upsert({
|
||||||
|
where: {
|
||||||
|
userId_lessonId: {
|
||||||
|
userId: submission.user.id,
|
||||||
|
lessonId: submission.homework.lesson.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: submission.user.id,
|
||||||
|
lessonId: submission.homework.lesson.id,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const { lesson } = submission.homework;
|
||||||
|
const notifySetting = await getSetting("notifyStudentOnFeedback");
|
||||||
|
if (asBool(notifySetting)) {
|
||||||
|
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`;
|
||||||
|
await sendFeedbackReceivedEmail(
|
||||||
|
submission.user.email,
|
||||||
|
submission.user.name,
|
||||||
|
lesson.title,
|
||||||
|
data.text,
|
||||||
|
lessonUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/curator/homework");
|
||||||
|
revalidatePath(`/curator/homework/${submissionId}`);
|
||||||
|
revalidatePath(`/courses/${lesson.module.course.slug}/lessons/${lesson.id}`);
|
||||||
|
revalidatePath(`/courses/${lesson.module.course.slug}`);
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setReviewing(submissionId: string) {
|
||||||
|
await requireCurator();
|
||||||
|
await prisma.homeworkSubmission.update({
|
||||||
|
where: { id: submissionId },
|
||||||
|
data: { status: "REVIEWING", statusAt: new Date() },
|
||||||
|
});
|
||||||
|
revalidatePath("/curator/homework");
|
||||||
|
revalidatePath(`/curator/homework/${submissionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSubmission(submissionId: string) {
|
||||||
|
await requireCurator();
|
||||||
|
await prisma.homeworkSubmission.delete({ where: { id: submissionId } });
|
||||||
|
revalidatePath("/curator/homework");
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ContentViewer } from "@/components/curator/content-viewer";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
homeworkDescription: string;
|
||||||
|
lessonContent: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContentTabs({ homeworkDescription, lessonContent }: Props) {
|
||||||
|
const [tab, setTab] = useState<"homework" | "lesson">("homework");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex items-center gap-0" style={{ borderBottom: "2px solid var(--border)" }}>
|
||||||
|
{(["homework", "lesson"] as const).map((t) => {
|
||||||
|
const label = t === "homework" ? "Содержимое ДЗ" : "Содержимое урока";
|
||||||
|
const active = tab === t;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab(t)}
|
||||||
|
className="px-4 py-2 text-xs font-medium"
|
||||||
|
style={{
|
||||||
|
borderBottom: active ? "2px solid var(--foreground)" : "2px solid transparent",
|
||||||
|
marginBottom: -2,
|
||||||
|
color: active ? "var(--foreground)" : "var(--muted-foreground)",
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div
|
||||||
|
className="px-4 py-4 text-sm"
|
||||||
|
style={{ border: "2px solid var(--border)", borderTop: "none" }}
|
||||||
|
>
|
||||||
|
{tab === "homework" ? (
|
||||||
|
<div className="whitespace-pre-wrap">{homeworkDescription}</div>
|
||||||
|
) : (
|
||||||
|
<ContentViewer content={lessonContent} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { deleteSubmission } from "./actions";
|
||||||
|
|
||||||
|
export function DeleteSubmissionButton({
|
||||||
|
submissionId,
|
||||||
|
userName,
|
||||||
|
}: {
|
||||||
|
submissionId: string;
|
||||||
|
userName: string;
|
||||||
|
}) {
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!confirm(`Удалить работу студента ${userName}? Это действие нельзя отменить.`)) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
await deleteSubmission(submissionId);
|
||||||
|
router.push("/curator/homework");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs px-3 py-1.5"
|
||||||
|
style={{
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
color: "oklch(0.577 0.245 27.325)",
|
||||||
|
opacity: pending ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🗑 Удалить работу
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition, useRef } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { submitFeedback, setReviewing } from "./actions";
|
||||||
|
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedbackForm({
|
||||||
|
submissionId,
|
||||||
|
currentStatus,
|
||||||
|
}: {
|
||||||
|
submissionId: string;
|
||||||
|
currentStatus: string;
|
||||||
|
}) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
|
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const picked = Array.from(e.target.files ?? []);
|
||||||
|
if (!picked.length) return;
|
||||||
|
setUploading(true);
|
||||||
|
const uploaded: FileItem[] = [];
|
||||||
|
for (const f of picked) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", f);
|
||||||
|
const res = await fetch("/api/curator/upload", { method: "POST", body: form });
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.url) uploaded.push({ name: data.name, url: data.url, size: data.size });
|
||||||
|
}
|
||||||
|
setFiles((prev) => [...prev, ...uploaded]);
|
||||||
|
setUploading(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAction(action: "approve" | "reject") {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
await submitFeedback(submissionId, {
|
||||||
|
text: text.trim(),
|
||||||
|
files,
|
||||||
|
audioUrl,
|
||||||
|
action,
|
||||||
|
});
|
||||||
|
router.push("/curator/homework");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReviewing() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await setReviewing(submissionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWorking = pending || uploading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Ваш ответ
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Text */}
|
||||||
|
<textarea
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
placeholder="Напишите обратную связь студенту..."
|
||||||
|
disabled={isWorking}
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
outline: "none",
|
||||||
|
width: "100%",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
fontSize: "16px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
resize: "vertical",
|
||||||
|
minHeight: "120px",
|
||||||
|
}}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* File upload */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
className="btn-aubade text-xs px-3 py-1.5 cursor-pointer"
|
||||||
|
style={{ opacity: isWorking ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
📎 Прикрепить файл
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={isWorking}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{uploading && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Загрузка...
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{files.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={f.url}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-xs"
|
||||||
|
style={{ border: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<span>📎</span>
|
||||||
|
<span className="flex-1 truncate">{f.name}</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
|
||||||
|
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Audio recorder */}
|
||||||
|
<AudioRecorder value={audioUrl} onChange={setAudioUrl} />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("approve")}
|
||||||
|
disabled={isWorking || !text.trim()}
|
||||||
|
className="btn-aubade-accent px-4 py-2 text-sm"
|
||||||
|
style={{ opacity: isWorking || !text.trim() ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
{pending ? "Отправка..." : "Отправить ответ"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{currentStatus !== "REVIEWING" && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReviewing}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="px-4 py-2 text-sm"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "oklch(0.9 0.08 80)",
|
||||||
|
color: "oklch(0.4 0.1 80)",
|
||||||
|
opacity: isWorking ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
На рассмотрение
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAction("reject")}
|
||||||
|
disabled={isWorking || !text.trim()}
|
||||||
|
className="px-4 py-2 text-sm"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "oklch(0.9 0.06 27)",
|
||||||
|
color: "oklch(0.45 0.2 27)",
|
||||||
|
opacity: isWorking || !text.trim() ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отклонить и отправить
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/curator/homework"
|
||||||
|
className="px-4 py-2 text-sm"
|
||||||
|
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
К списку ДЗ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { FeedbackForm } from "./feedback-form";
|
||||||
|
import { ContentTabs } from "./content-tabs";
|
||||||
|
import { DeleteSubmissionButton } from "./delete-button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ submissionId: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number) {
|
||||||
|
if (bytes < 1024) return `${bytes} Б`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
const map: Record<string, { label: string; bg: string; color: string }> = {
|
||||||
|
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)" },
|
||||||
|
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)" },
|
||||||
|
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" },
|
||||||
|
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)" },
|
||||||
|
};
|
||||||
|
const s = map[status] ?? map.PENDING;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 font-medium"
|
||||||
|
style={{ background: s.bg, color: s.color }}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function SubmissionPage({ params }: Props) {
|
||||||
|
const { submissionId } = await params;
|
||||||
|
|
||||||
|
const submission = await prisma.homeworkSubmission.findUnique({
|
||||||
|
where: { id: submissionId },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true, email: true } },
|
||||||
|
feedbacks: {
|
||||||
|
include: { curator: { select: { name: true } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
homework: {
|
||||||
|
include: {
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
content: true,
|
||||||
|
module: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
course: { select: { title: true, slug: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!submission) notFound();
|
||||||
|
|
||||||
|
const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
|
||||||
|
const lesson = submission.homework.lesson;
|
||||||
|
const course = lesson.module.course;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
{/* Breadcrumb */}
|
||||||
|
<nav
|
||||||
|
className="text-xs mb-6 uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Link href="/curator/homework" className="hover:underline">
|
||||||
|
ДЗ на проверку
|
||||||
|
</Link>
|
||||||
|
<span className="mx-2">/</span>
|
||||||
|
<span style={{ color: "var(--foreground)" }}>{submission.user.name}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Meta table */}
|
||||||
|
<div
|
||||||
|
className="mb-6"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
{[
|
||||||
|
{ label: "Автор", value: submission.user.name },
|
||||||
|
{ label: "Логин", value: submission.user.email },
|
||||||
|
{
|
||||||
|
label: "Урок",
|
||||||
|
value: (
|
||||||
|
<Link
|
||||||
|
href={`/courses/${course.slug}/lessons/${lesson.id}`}
|
||||||
|
target="_blank"
|
||||||
|
className="hover:underline"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{lesson.title}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ label: "Курс", value: course.title },
|
||||||
|
{ label: "Статус", value: <StatusBadge status={submission.status} /> },
|
||||||
|
{
|
||||||
|
label: "Время последнего изменения статуса",
|
||||||
|
value: submission.statusAt
|
||||||
|
? new Date(submission.statusAt).toLocaleString("ru-RU")
|
||||||
|
: new Date(submission.submittedAt).toLocaleString("ru-RU"),
|
||||||
|
},
|
||||||
|
].map(({ label, value }) => (
|
||||||
|
<div
|
||||||
|
key={label}
|
||||||
|
className="flex items-start gap-4 px-4 py-2.5 text-sm"
|
||||||
|
style={{ borderBottom: "1px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="w-52 shrink-0 font-medium"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span>{value}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content tabs */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<ContentTabs
|
||||||
|
homeworkDescription={submission.homework.description}
|
||||||
|
lessonContent={lesson.content}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Student answer */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-2"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Ответ студента
|
||||||
|
</p>
|
||||||
|
{submission.text ? (
|
||||||
|
<div
|
||||||
|
className="px-4 py-3 text-sm whitespace-pre-wrap"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
{submission.text}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Текст не добавлен
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Student audio */}
|
||||||
|
{submission.audioUrl && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-2"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Аудио студента
|
||||||
|
</p>
|
||||||
|
<audio controls src={submission.audioUrl} style={{ width: "100%", height: 40 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Student files */}
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-2"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Файлы студента
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{files.map((f) => (
|
||||||
|
<a
|
||||||
|
key={f.url}
|
||||||
|
href={f.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<span>📎</span>
|
||||||
|
<span className="flex-1 underline">{f.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{formatSize(f.size)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Existing feedbacks */}
|
||||||
|
{submission.feedbacks.length > 0 && (
|
||||||
|
<div className="mb-6 space-y-3">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
История фидбека
|
||||||
|
</p>
|
||||||
|
{submission.feedbacks.map((fb) => {
|
||||||
|
const fbFiles = (fb.files as { name: string; url: string; size: number }[]) ?? [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={fb.id}
|
||||||
|
className="px-4 py-3"
|
||||||
|
style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-xs font-bold uppercase tracking-widest">
|
||||||
|
{fb.curator.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{new Date(fb.createdAt).toLocaleString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
|
||||||
|
{fbFiles.length > 0 && (
|
||||||
|
<div className="space-y-1 mt-2">
|
||||||
|
{fbFiles.map((f) => (
|
||||||
|
<a
|
||||||
|
key={f.url}
|
||||||
|
href={f.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 text-xs underline"
|
||||||
|
>
|
||||||
|
<span>📎</span>
|
||||||
|
<span>{f.name}</span>
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{formatSize(f.size)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fb.audioUrl && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<audio controls src={fb.audioUrl} style={{ height: 32 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Feedback form */}
|
||||||
|
<div
|
||||||
|
className="p-5 mb-4"
|
||||||
|
style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}
|
||||||
|
>
|
||||||
|
<FeedbackForm
|
||||||
|
submissionId={submissionId}
|
||||||
|
currentStatus={submission.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DeleteSubmissionButton submissionId={submissionId} userName={submission.user.name} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
searchParams: Promise<{
|
||||||
|
q?: string;
|
||||||
|
status?: string;
|
||||||
|
courseId?: string;
|
||||||
|
page?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomeworkListPage({ searchParams }: Props) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const q = sp.q ?? "";
|
||||||
|
const status = sp.status ?? "";
|
||||||
|
const courseId = sp.courseId ?? "";
|
||||||
|
const currentPage = Math.max(1, parseInt(sp.page ?? "1") || 1);
|
||||||
|
const skip = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
|
||||||
|
// Build where clause
|
||||||
|
const where = {
|
||||||
|
...(q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ user: { name: { contains: q, mode: "insensitive" as const } } },
|
||||||
|
{ user: { email: { contains: q, mode: "insensitive" as const } } },
|
||||||
|
{ homework: { lesson: { title: { contains: q, mode: "insensitive" as const } } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(courseId
|
||||||
|
? {
|
||||||
|
homework: {
|
||||||
|
lesson: { module: { courseId } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(status === "pending" ? { feedbacks: { none: {} } } : {}),
|
||||||
|
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [submissions, total, courses] = await Promise.all([
|
||||||
|
prisma.homeworkSubmission.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { submittedAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: PAGE_SIZE,
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true, email: true } },
|
||||||
|
feedbacks: { select: { id: true }, take: 1 },
|
||||||
|
homework: {
|
||||||
|
include: {
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
module: { select: { title: true, course: { select: { id: true, title: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.homeworkSubmission.count({ where }),
|
||||||
|
prisma.course.findMany({ orderBy: { title: "asc" }, select: { id: true, title: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||||
|
|
||||||
|
function pageUrl(p: number) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set("q", q);
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
if (courseId) params.set("courseId", courseId);
|
||||||
|
params.set("page", String(p));
|
||||||
|
return `/curator/homework?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
outline: "none",
|
||||||
|
padding: "0.4rem 0.75rem",
|
||||||
|
fontSize: "0.8rem",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-3xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Домашние задания
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{total} {total === 1 ? "работа" : "работ"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Suspense>
|
||||||
|
<form method="GET" className="flex flex-wrap gap-2 mb-5">
|
||||||
|
<input
|
||||||
|
name="q"
|
||||||
|
defaultValue={q}
|
||||||
|
placeholder="Поиск по ученику или уроку"
|
||||||
|
style={{ ...inputStyle, width: 260 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="courseId"
|
||||||
|
defaultValue={courseId}
|
||||||
|
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer", maxWidth: 220 }}
|
||||||
|
>
|
||||||
|
<option value="">Все курсы</option>
|
||||||
|
{courses.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
defaultValue={status}
|
||||||
|
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
<option value="">Все</option>
|
||||||
|
<option value="pending">Без ответа</option>
|
||||||
|
<option value="reviewed">С отзывом</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-3 py-1 text-xs font-medium"
|
||||||
|
style={{ border: "2px solid var(--foreground)", background: "var(--foreground)", color: "var(--background)", cursor: "pointer" }}
|
||||||
|
>
|
||||||
|
Найти
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(q || status || courseId) && (
|
||||||
|
<Link
|
||||||
|
href="/curator/homework"
|
||||||
|
className="px-3 py-1 text-xs"
|
||||||
|
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
{submissions.length === 0 ? (
|
||||||
|
<div className="card-aubade p-10 text-center">
|
||||||
|
<p className="text-3xl mb-2">📭</p>
|
||||||
|
<p className="font-bold">Ничего не найдено</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Попробуйте изменить фильтры
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{submissions.map((s) => {
|
||||||
|
const hasReview = s.feedbacks.length > 0;
|
||||||
|
const reviewBadge = hasReview
|
||||||
|
? { label: "С отзывом", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" }
|
||||||
|
: { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={s.id}
|
||||||
|
href={`/curator/homework/${s.id}`}
|
||||||
|
className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80"
|
||||||
|
style={{ border: "2px solid var(--border)", display: "flex" }}
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{s.user.name}</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{s.homework.lesson.module.course.title} · {s.homework.lesson.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{s.user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<span
|
||||||
|
className="text-xs px-2 py-0.5 font-medium"
|
||||||
|
style={{ background: reviewBadge.bg, color: reviewBadge.color }}
|
||||||
|
>
|
||||||
|
{reviewBadge.label}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center gap-1 mt-5">
|
||||||
|
{currentPage > 1 && (
|
||||||
|
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs">←</Link>
|
||||||
|
)}
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||||
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((p, i) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}>…</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={p}
|
||||||
|
href={pageUrl(p as number)}
|
||||||
|
className="px-3 py-1 text-xs"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: p === currentPage ? "var(--foreground)" : "transparent",
|
||||||
|
color: p === currentPage ? "var(--background)" : "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{currentPage < totalPages && (
|
||||||
|
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||||
|
)}
|
||||||
|
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Страница {currentPage} из {totalPages} · Всего: {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { LogoutButton } from "@/components/layout/logout-button";
|
||||||
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
|
import { getSetting } from "@/lib/settings";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) redirect("/login");
|
||||||
|
if (session.user.role !== "curator" && session.user.role !== "admin") redirect("/dashboard");
|
||||||
|
|
||||||
|
// Maintenance mode: curators (non-admin) see the maintenance page
|
||||||
|
if (session.user.role === "curator") {
|
||||||
|
const maintenance = await getSetting("maintenanceMode");
|
||||||
|
if (maintenance === "true") redirect("/maintenance");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin uses the admin shell with sidebar
|
||||||
|
if (session.user.role === "admin") {
|
||||||
|
const questionsBadge = await prisma.studentQuestion.count({
|
||||||
|
where: {
|
||||||
|
messages: {
|
||||||
|
some: {
|
||||||
|
isRead: false,
|
||||||
|
author: { role: "student" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex" style={{ backgroundColor: "var(--background)" }}>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-52 shrink-0 flex flex-col min-h-screen" style={{ backgroundColor: "var(--sidebar-bg)" }}>
|
||||||
|
<div className="px-5 py-5" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
|
||||||
|
<p className="text-sm font-bold tracking-wide" style={{ color: "#F5F5F0" }}>Second Brain</p>
|
||||||
|
<p className="text-xs mt-0.5 uppercase tracking-widest" style={{ color: "#888" }}>Куратор</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 py-3 space-y-0.5 px-2">
|
||||||
|
<NavLink href="/curator/dashboard">Обзор</NavLink>
|
||||||
|
<NavLink href="/curator/homework">ДЗ на проверку</NavLink>
|
||||||
|
<NavLink href="/curator/questions">Вопросы</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
||||||
|
<p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="block px-3 py-2 text-sm rounded-sm transition-colors"
|
||||||
|
style={{ color: "#CCCCCC" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user