Compare commits
53 Commits
v1.0
...
47840901c5
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -5,7 +5,6 @@
|
||||
.env.production
|
||||
node_modules
|
||||
.next
|
||||
src/generated
|
||||
*.md
|
||||
docker-compose.yml
|
||||
docker-compose.prod.yml
|
||||
|
||||
@@ -45,3 +45,6 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
# Claude Code local plugins (external git repos, не коммитим)
|
||||
.claude/plugins/
|
||||
|
||||
@@ -1,5 +1,308 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
# AGENTS.md — LMS Second Brain
|
||||
|
||||
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.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
Собственная LMS-платформа для образовательных курсов по PKM и Obsidian.
|
||||
Заменяет 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 | Язык |
|
||||
| React | 19 | UI |
|
||||
| PostgreSQL | 16 | База данных |
|
||||
| Prisma | 6.x | ORM + миграции |
|
||||
| Prisma | 7.x | ORM + миграции |
|
||||
| Better Auth | latest | Аутентификация и сессии |
|
||||
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
|
||||
| shadcn/ui | latest | UI-компоненты |
|
||||
|
||||
+24
@@ -275,6 +275,30 @@
|
||||
|
||||
## Бэклог (после 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)
|
||||
- Сертификаты по окончании курса
|
||||
|
||||
@@ -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,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 секунд
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import type { NextConfig } from "next";
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
transpilePackages: ["unified", "remark-parse"],
|
||||
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
Generated
+96
-3040
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,6 @@
|
||||
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||
"@prisma/adapter-pg": "^7.6.0",
|
||||
"@prisma/client": "^7.6.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tiptap/extension-image": "^3.22.2",
|
||||
"@tiptap/extension-link": "^3.22.2",
|
||||
"@tiptap/extension-placeholder": "^3.22.2",
|
||||
@@ -44,10 +43,8 @@
|
||||
"react-dom": "19.2.4",
|
||||
"remark-parse": "^11.0.0",
|
||||
"resend": "^6.10.0",
|
||||
"shadcn": "^4.1.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"unified": "^11.0.5",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
+54
-25
@@ -18,32 +18,37 @@ model User {
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
role String @default("student") // student | curator | admin
|
||||
phone String?
|
||||
birthday DateTime?
|
||||
banned Boolean? @default(false)
|
||||
banReason String?
|
||||
banExpires DateTime?
|
||||
comment String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
enrollments CourseEnrollment[]
|
||||
progress LessonProgress[]
|
||||
submissions HomeworkSubmission[]
|
||||
comments LessonComment[]
|
||||
feedbacks HomeworkFeedback[]
|
||||
accessLogs AccessLog[] @relation("AccessLogUser")
|
||||
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
||||
sessions Session[]
|
||||
accounts Account[]
|
||||
enrollments CourseEnrollment[]
|
||||
progress LessonProgress[]
|
||||
submissions HomeworkSubmission[]
|
||||
comments LessonComment[]
|
||||
feedbacks HomeworkFeedback[]
|
||||
accessLogs AccessLog[] @relation("AccessLogUser")
|
||||
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
||||
balanceTransactions BalanceTransaction[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
token String @unique
|
||||
expiresAt DateTime
|
||||
ipAddress String?
|
||||
userAgent String?
|
||||
impersonatedBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
@@ -96,6 +101,7 @@ model Course {
|
||||
description String?
|
||||
coverImage String?
|
||||
published Boolean @default(false)
|
||||
allowAudio Boolean @default(false)
|
||||
order Int @default(0)
|
||||
categoryId String?
|
||||
createdAt DateTime @default(now())
|
||||
@@ -126,6 +132,7 @@ model Lesson {
|
||||
title String
|
||||
content Json?
|
||||
kinescopeId String?
|
||||
coverImage String?
|
||||
order Int @default(0)
|
||||
published Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
@@ -258,12 +265,15 @@ model Homework {
|
||||
}
|
||||
|
||||
model HomeworkSubmission {
|
||||
id String @id @default(cuid())
|
||||
homeworkId String
|
||||
userId String
|
||||
text String?
|
||||
files Json?
|
||||
submittedAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
homeworkId String
|
||||
userId String
|
||||
text String?
|
||||
files Json?
|
||||
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)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -275,6 +285,8 @@ model HomeworkFeedback {
|
||||
submissionId String
|
||||
curatorId String
|
||||
text String
|
||||
files Json? // [{name, url, size}]
|
||||
audioUrl String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
|
||||
@@ -292,9 +304,26 @@ model LessonComment {
|
||||
text String
|
||||
deleted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
parentId String?
|
||||
|
||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
lesson Lesson @relation(fields: [lessonId], 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")
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
// 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)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────
|
||||
|
||||
@@ -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: "0.65rem" }}>
|
||||
Образовательная платформа
|
||||
</p>
|
||||
</div>
|
||||
<div className="card-aubade p-8">
|
||||
<ForgotPasswordForm />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,12 +95,14 @@ export function LoginForm() {
|
||||
>
|
||||
{loading ? "Вход..." : "Войти"}
|
||||
</button>
|
||||
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Нет аккаунта?{" "}
|
||||
<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>
|
||||
<Link href="/register" className="underline" style={{ color: "var(--foreground)" }}>
|
||||
Зарегистрироваться
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,43 @@
|
||||
import { getSetting } from "@/lib/settings";
|
||||
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 (
|
||||
<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)" }}>
|
||||
Second Brain
|
||||
{schoolName}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}>
|
||||
Образовательная платформа
|
||||
</p>
|
||||
</div>
|
||||
{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 />
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSettings } from "@/lib/settings";
|
||||
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 (
|
||||
<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)" }}>
|
||||
Second Brain
|
||||
{settings.schoolName}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}>
|
||||
Образовательная платформа
|
||||
</p>
|
||||
</div>
|
||||
<div className="card-aubade p-8">
|
||||
<RegisterForm />
|
||||
<RegisterForm
|
||||
showTermsCheckbox={settings.showTermsCheckbox === "true"}
|
||||
privacyPolicyUrl={settings.privacyPolicyUrl}
|
||||
termsUrl={settings.termsUrl}
|
||||
offerUrl={settings.offerUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,10 +4,18 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { signUp } from "@/lib/auth-client";
|
||||
|
||||
export function RegisterForm() {
|
||||
interface Props {
|
||||
showTermsCheckbox: boolean;
|
||||
privacyPolicyUrl: string;
|
||||
termsUrl: string;
|
||||
offerUrl: string;
|
||||
}
|
||||
|
||||
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) {
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
@@ -22,8 +30,18 @@ export function RegisterForm() {
|
||||
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) {
|
||||
e.preventDefault();
|
||||
if (showTermsCheckbox && !termsAccepted) {
|
||||
setError("Необходимо принять условия для продолжения");
|
||||
return;
|
||||
}
|
||||
setError("");
|
||||
setLoading(true);
|
||||
|
||||
@@ -102,6 +120,38 @@ export function RegisterForm() {
|
||||
placeholder="Минимум 8 символов"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
|
||||
@@ -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: "0.65rem" }}>
|
||||
Образовательная платформа
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export default async function CourseLayout({ children, params }: Props) {
|
||||
<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>
|
||||
|
||||
@@ -5,7 +5,7 @@ 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) {
|
||||
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");
|
||||
|
||||
@@ -20,7 +20,8 @@ export async function addComment(lessonId: string, slug: string, text: string) {
|
||||
if (!lesson) throw new Error("Lesson not found");
|
||||
|
||||
const isAdmin = session.user.role === "admin";
|
||||
if (!isAdmin) {
|
||||
const isCurator = session.user.role === "curator";
|
||||
if (!isAdmin && !isCurator) {
|
||||
const enrollment = await prisma.courseEnrollment.findUnique({
|
||||
where: {
|
||||
userId_courseId: {
|
||||
@@ -33,8 +34,14 @@ export async function addComment(lessonId: string, slug: string, text: string) {
|
||||
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 },
|
||||
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
||||
});
|
||||
|
||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||
|
||||
@@ -17,7 +17,8 @@ export async function submitHomework(
|
||||
slug: string,
|
||||
lessonId: string,
|
||||
text: string,
|
||||
files: HomeworkFile[]
|
||||
files: HomeworkFile[],
|
||||
audioUrl?: string | null
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
@@ -36,12 +37,12 @@ export async function submitHomework(
|
||||
if (existing) {
|
||||
const updated = await prisma.homeworkSubmission.update({
|
||||
where: { id: existing.id },
|
||||
data: { text, files: files as object[], submittedAt: new Date() },
|
||||
data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
|
||||
});
|
||||
submissionId = updated.id;
|
||||
} else {
|
||||
const created = await prisma.homeworkSubmission.create({
|
||||
data: { homeworkId, userId: session.user.id, text, files: files as object[] },
|
||||
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
|
||||
});
|
||||
submissionId = created.id;
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ 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 }>;
|
||||
@@ -25,6 +27,9 @@ export default async function LessonPage({ params }: Props) {
|
||||
include: {
|
||||
files: { orderBy: { createdAt: "asc" } },
|
||||
homework: true,
|
||||
quiz: {
|
||||
include: { questions: { orderBy: { order: "asc" } } },
|
||||
},
|
||||
module: {
|
||||
include: {
|
||||
course: {
|
||||
@@ -51,9 +56,15 @@ export default async function LessonPage({ params }: Props) {
|
||||
})
|
||||
: null,
|
||||
prisma.lessonComment.findMany({
|
||||
where: { lessonId },
|
||||
where: { lessonId, parentId: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
include: {
|
||||
user: { select: { id: true, name: true } },
|
||||
replies: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { user: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -61,7 +72,18 @@ export default async function LessonPage({ params }: Props) {
|
||||
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: "desc" } } },
|
||||
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;
|
||||
|
||||
@@ -91,7 +113,7 @@ export default async function LessonPage({ params }: Props) {
|
||||
{/* Video */}
|
||||
{lesson.kinescopeId && (
|
||||
<div className="mb-8">
|
||||
<KinescopePlayer videoId={lesson.kinescopeId} />
|
||||
<KinescopePlayer videoId={lesson.kinescopeId} poster={lesson.coverImage ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -115,12 +137,10 @@ export default async function LessonPage({ params }: Props) {
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors"
|
||||
style={{ border: "2px solid var(--border)", display: "flex" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
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)" }}
|
||||
>
|
||||
<span className="text-lg">📎</span>
|
||||
<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)}
|
||||
@@ -145,13 +165,48 @@ export default async function LessonPage({ params }: Props) {
|
||||
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"
|
||||
@@ -168,9 +223,12 @@ export default async function LessonPage({ params }: Props) {
|
||||
<div />
|
||||
)}
|
||||
|
||||
{!isAdmin && (
|
||||
{!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
|
||||
@@ -193,7 +251,10 @@ export default async function LessonPage({ params }: Props) {
|
||||
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.filter(c => !c.deleted).length +
|
||||
comments.flatMap(c => c.replies).filter(r => !r.deleted).length
|
||||
})
|
||||
</p>
|
||||
<LessonComments
|
||||
lessonId={lessonId}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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() });
|
||||
@@ -15,21 +16,70 @@ export default async function StudentLayout({ children }: { children: React.Reac
|
||||
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="font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
|
||||
Second Brain
|
||||
<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">
|
||||
<span className="text-sm" style={{ color: "var(--muted-foreground)" }}>{session.user.name}</span>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
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 {
|
||||
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
|
||||
@@ -18,6 +19,9 @@ export default async function LessonEditorPage({ params }: Props) {
|
||||
include: {
|
||||
files: { orderBy: { createdAt: "asc" } },
|
||||
homework: true,
|
||||
quiz: {
|
||||
include: { questions: { orderBy: { order: "asc" } } },
|
||||
},
|
||||
module: {
|
||||
include: { course: { select: { title: true, slug: true } } },
|
||||
},
|
||||
@@ -36,6 +40,19 @@ export default async function LessonEditorPage({ params }: Props) {
|
||||
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 (
|
||||
<div className="p-8 max-w-4xl">
|
||||
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
@@ -55,14 +72,14 @@ export default async function LessonEditorPage({ params }: Props) {
|
||||
id: lesson.id,
|
||||
title: lesson.title,
|
||||
kinescopeId: lesson.kinescopeId ?? "",
|
||||
content: (lesson.content as object) ?? {},
|
||||
content: JSON.parse(JSON.stringify(lesson.content ?? {})),
|
||||
published: lesson.published,
|
||||
}}
|
||||
courseId={courseId}
|
||||
moduleId={moduleId}
|
||||
courseSlug={lesson.module.course.slug}
|
||||
prevLesson={prevLesson}
|
||||
nextLesson={nextLesson}
|
||||
prevLesson={plain.siblings[idx - 1] ?? null}
|
||||
nextLesson={plain.siblings[idx + 1] ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -71,15 +88,23 @@ export default async function LessonEditorPage({ params }: Props) {
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||
Файлы и материалы
|
||||
</p>
|
||||
<LessonFilesManager lessonId={lessonId} initialFiles={lesson.files} />
|
||||
<LessonFilesManager lessonId={lessonId} initialFiles={plain.files} />
|
||||
</div>
|
||||
|
||||
{/* Homework section */}
|
||||
<div className="card-aubade p-6">
|
||||
<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={lesson.homework} />
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -15,7 +15,10 @@ export default async function ModulePage({ params }: Props) {
|
||||
where: { id: moduleId },
|
||||
include: {
|
||||
course: { select: { title: true } },
|
||||
lessons: { orderBy: { order: "asc" } },
|
||||
lessons: {
|
||||
orderBy: { order: "asc" },
|
||||
select: { id: true, title: true, order: true, published: true, kinescopeId: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.module.findMany({
|
||||
@@ -27,6 +30,11 @@ export default async function ModulePage({ params }: Props) {
|
||||
|
||||
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 (
|
||||
<div className="p-8 max-w-3xl">
|
||||
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
@@ -51,8 +59,8 @@ export default async function ModulePage({ params }: Props) {
|
||||
<SortableLessons
|
||||
courseId={courseId}
|
||||
moduleId={moduleId}
|
||||
lessons={module.lessons}
|
||||
otherModules={allModules}
|
||||
lessons={plain.lessons}
|
||||
otherModules={plain.allModules}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -50,13 +50,21 @@ export default async function CourseDetailPage({ params }: Props) {
|
||||
|
||||
if (!course) notFound();
|
||||
|
||||
// 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 (
|
||||
<div className="p-8 max-w-4xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<span style={{ color: "var(--foreground)" }}>{course.title}</span>
|
||||
<span style={{ color: "var(--foreground)" }}>{plain.course.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* Course metadata */}
|
||||
@@ -64,7 +72,7 @@ export default async function CourseDetailPage({ params }: Props) {
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||
Основная информация
|
||||
</p>
|
||||
<CourseEditForm course={course} categories={categories} />
|
||||
<CourseEditForm course={plain.course} categories={plain.categories} />
|
||||
</section>
|
||||
|
||||
{/* Modules */}
|
||||
@@ -74,19 +82,19 @@ export default async function CourseDetailPage({ params }: Props) {
|
||||
Модули
|
||||
</p>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{course.modules.length} модулей
|
||||
{plain.course.modules.length} модулей
|
||||
</span>
|
||||
</div>
|
||||
<SortableModules courseId={courseId} modules={course.modules} />
|
||||
<SortableModules courseId={courseId} modules={plain.course.modules} />
|
||||
</section>
|
||||
|
||||
{/* Course tree overview */}
|
||||
{course.modules.length > 0 && (
|
||||
{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={course.modules} />
|
||||
<CourseTree courseId={courseId} modules={plain.course.modules} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -97,9 +105,9 @@ export default async function CourseDetailPage({ params }: Props) {
|
||||
</p>
|
||||
<EnrollmentManager
|
||||
courseId={courseId}
|
||||
allStudents={allStudents}
|
||||
enrollments={course.enrollments}
|
||||
accessLogs={course.accessLogs}
|
||||
allStudents={plain.allStudents}
|
||||
enrollments={plain.course.enrollments}
|
||||
accessLogs={plain.course.accessLogs}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -43,12 +43,13 @@ export async function updateCourse(courseId: string, formData: FormData) {
|
||||
const slug = formData.get("slug") as string;
|
||||
const description = (formData.get("description") as string) || null;
|
||||
const published = formData.get("published") === "true";
|
||||
const allowAudio = formData.get("allowAudio") === "true";
|
||||
const coverImage = (formData.get("coverImage") as string) || null;
|
||||
const categoryId = (formData.get("categoryId") as string) || null;
|
||||
|
||||
await prisma.course.update({
|
||||
where: { id: courseId },
|
||||
data: { title, slug, description, published, coverImage, categoryId },
|
||||
data: { title, slug, description, published, allowAudio, coverImage, categoryId },
|
||||
});
|
||||
|
||||
revalidatePath("/admin/courses");
|
||||
|
||||
@@ -3,6 +3,7 @@ import Link from "next/link";
|
||||
|
||||
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);
|
||||
|
||||
@@ -16,6 +17,8 @@ export default async function AdminDashboard() {
|
||||
homeworkPending,
|
||||
homeworkTotal,
|
||||
progressTotal,
|
||||
balanceAggregate,
|
||||
activeLast24h,
|
||||
] = await Promise.all([
|
||||
prisma.user.count({ where: { role: "student" } }),
|
||||
prisma.user.count({ where: { role: "student", createdAt: { gte: monthAgo } } }),
|
||||
@@ -30,8 +33,16 @@ export default async function AdminDashboard() {
|
||||
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" },
|
||||
@@ -165,6 +176,24 @@ export default async function AdminDashboard() {
|
||||
</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>
|
||||
@@ -179,7 +208,7 @@ function StatCard({
|
||||
href,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
value: number | string;
|
||||
sub?: string;
|
||||
subAccent?: boolean;
|
||||
href?: string;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ 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() });
|
||||
@@ -41,6 +42,63 @@ export async function bulkGrantAccess(
|
||||
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({
|
||||
|
||||
@@ -2,6 +2,9 @@ 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 }>;
|
||||
@@ -26,6 +29,9 @@ export default async function UserPage({ params }: Props) {
|
||||
grantedBy: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
balanceTransactions: {
|
||||
orderBy: { createdAt: "desc" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.course.findMany({
|
||||
@@ -47,7 +53,7 @@ export default async function UserPage({ params }: Props) {
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<section className="card-aubade p-6 mb-6">
|
||||
<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>
|
||||
@@ -60,6 +66,40 @@ export default async function UserPage({ params }: Props) {
|
||||
</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 */}
|
||||
|
||||
@@ -8,14 +8,25 @@ import { UsersSearch } from "@/components/admin/users-search";
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
interface Props {
|
||||
searchParams: Promise<{ search?: string; role?: string; page?: string }>;
|
||||
searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string }>;
|
||||
}
|
||||
|
||||
export default async function UsersPage({ searchParams }: Props) {
|
||||
const { search = "", role = "", page = "1" } = await searchParams;
|
||||
const { search = "", role = "", page = "1", balance = "" } = await searchParams;
|
||||
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||
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
|
||||
? {
|
||||
@@ -26,6 +37,7 @@ export default async function UsersPage({ searchParams }: Props) {
|
||||
}
|
||||
: {}),
|
||||
...(role ? { role } : {}),
|
||||
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
|
||||
};
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
@@ -66,6 +78,7 @@ export default async function UsersPage({ searchParams }: Props) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (role) params.set("role", role);
|
||||
if (balance) params.set("balance", balance);
|
||||
params.set("page", String(p));
|
||||
return `/admin/users?${params.toString()}`;
|
||||
}
|
||||
@@ -88,7 +101,7 @@ export default async function UsersPage({ searchParams }: Props) {
|
||||
|
||||
{/* Filters */}
|
||||
<Suspense>
|
||||
<UsersSearch initialSearch={search} initialRole={role} />
|
||||
<UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} />
|
||||
</Suspense>
|
||||
|
||||
<UsersTable users={tableUsers} />
|
||||
|
||||
@@ -5,37 +5,76 @@ import { prisma } from "@/lib/prisma";
|
||||
import { uploadFile, deleteFile } from "@/lib/s3";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
async function requireAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") {
|
||||
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);
|
||||
|
||||
const lessonFile = await prisma.lessonFile.create({
|
||||
data: { lessonId, name: file.name, url, size: file.size },
|
||||
});
|
||||
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 DELETE(req: NextRequest) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") {
|
||||
export async function PATCH(req: NextRequest) {
|
||||
if (!await requireAdmin()) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { fileId, key } = await req.json();
|
||||
if (key) await deleteFile(key).catch(() => {});
|
||||
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;
|
||||
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 key = `uploads/${randomUUID()}.${ext}`;
|
||||
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,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 });
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export async function POST(req: NextRequest) {
|
||||
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());
|
||||
|
||||
@@ -13,7 +13,7 @@ export default async function CuratorDashboard() {
|
||||
prisma.homeworkSubmission.count(),
|
||||
prisma.homeworkFeedback.count({
|
||||
where: {
|
||||
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
createdAt: { gte: new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) },
|
||||
curatorId: session.user.id,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -5,27 +5,38 @@ 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";
|
||||
|
||||
export async function submitFeedback(submissionId: string, text: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
await prisma.homeworkFeedback.create({
|
||||
data: { submissionId, curatorId: session.user.id, text },
|
||||
});
|
||||
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";
|
||||
|
||||
// Send email to student
|
||||
const submission = await prisma.homeworkSubmission.findUnique({
|
||||
where: { id: submissionId },
|
||||
include: {
|
||||
user: { select: { email: true, name: true } },
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
title: true,
|
||||
id: true,
|
||||
module: { select: { course: { select: { slug: true } } } },
|
||||
},
|
||||
},
|
||||
@@ -34,18 +45,71 @@ export async function submitFeedback(submissionId: string, text: string) {
|
||||
},
|
||||
});
|
||||
|
||||
if (submission) {
|
||||
const { lesson } = submission.homework;
|
||||
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${submission.homework.lessonId}`;
|
||||
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,
|
||||
text,
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useState, useTransition, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { submitFeedback } from "./actions";
|
||||
import Link from "next/link";
|
||||
import { submitFeedback, setReviewing } from "./actions";
|
||||
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||
|
||||
export function FeedbackForm({ submissionId }: { submissionId: string }) {
|
||||
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();
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
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.trim());
|
||||
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 (
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
Написать фидбек
|
||||
<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)}
|
||||
required
|
||||
placeholder="Напишите обратную связь студенту..."
|
||||
disabled={isWorking}
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
@@ -42,14 +100,110 @@ export function FeedbackForm({ submissionId }: { submissionId: string }) {
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || !text.trim()}
|
||||
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
|
||||
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }}
|
||||
>
|
||||
{pending ? "Отправка..." : "Отправить фидбек"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ 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 }>;
|
||||
@@ -13,6 +15,24 @@ function formatSize(bytes: number) {
|
||||
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;
|
||||
|
||||
@@ -22,14 +42,21 @@ export default async function SubmissionPage({ params }: Props) {
|
||||
user: { select: { name: true, email: true } },
|
||||
feedbacks: {
|
||||
include: { curator: { select: { name: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
module: { select: { title: true, course: { select: { title: true } } } },
|
||||
content: true,
|
||||
module: {
|
||||
select: {
|
||||
title: true,
|
||||
course: { select: { title: true, slug: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -40,59 +67,121 @@ export default async function SubmissionPage({ params }: Props) {
|
||||
if (!submission) notFound();
|
||||
|
||||
const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
|
||||
const isReviewed = submission.feedbacks.length > 0;
|
||||
const lesson = submission.homework.lesson;
|
||||
const course = lesson.module.course;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-2xl">
|
||||
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
<Link href="/curator/homework" className="hover:underline">ДЗ на проверку</Link>
|
||||
{/* 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 */}
|
||||
{/* 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">
|
||||
<h1 className="text-xl font-bold">{submission.homework.lesson.title}</h1>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.module.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Student info */}
|
||||
<div className="flex items-center justify-between px-4 py-3 mb-6" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{submission.user.name}</p>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{submission.user.email}</p>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Homework description */}
|
||||
<div className="mb-4">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Задание</p>
|
||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||
{submission.homework.description}
|
||||
</div>
|
||||
<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>
|
||||
<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)" }}>
|
||||
<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>
|
||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||
Текст не добавлен
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Files */}
|
||||
{/* 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>
|
||||
<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
|
||||
@@ -105,28 +194,86 @@ export default async function SubmissionPage({ params }: Props) {
|
||||
>
|
||||
<span>📎</span>
|
||||
<span className="flex-1 underline">{f.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{formatSize(f.size)}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Existing feedback */}
|
||||
{submission.feedbacks.map((fb) => (
|
||||
<div key={fb.id} className="mb-4 px-4 py-3" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs font-bold uppercase tracking-widest">Фидбек</p>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
|
||||
{/* 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 */}
|
||||
{!isReviewed && <FeedbackForm submissionId={submissionId} />}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,8 +38,10 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(status === "pending" ? { feedbacks: { none: {} } } : {}),
|
||||
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
|
||||
...(status === "pending" ? { status: "PENDING" } : {}),
|
||||
...(status === "reviewing" ? { status: "REVIEWING" } : {}),
|
||||
...(status === "approved" ? { status: "APPROVED" } : {}),
|
||||
...(status === "rejected" ? { status: "REJECTED" } : {}),
|
||||
};
|
||||
|
||||
const [submissions, total, courses] = await Promise.all([
|
||||
@@ -50,7 +52,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
take: PAGE_SIZE,
|
||||
include: {
|
||||
user: { select: { name: true, email: true } },
|
||||
feedbacks: { select: { id: true } },
|
||||
feedbacks: { select: { id: true }, take: 1 },
|
||||
homework: {
|
||||
include: {
|
||||
lesson: {
|
||||
@@ -69,7 +71,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
const pendingCount = submissions.filter((s) => s.feedbacks.length === 0).length;
|
||||
const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
|
||||
|
||||
function pageUrl(p: number) {
|
||||
const params = new URLSearchParams();
|
||||
@@ -109,16 +111,19 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{submissions.map((s) => {
|
||||
const isPending = s.feedbacks.length === 0;
|
||||
const statusMap: Record<string, { label: string; bg: string; color: string; border: string }> = {
|
||||
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)", border: "var(--foreground)" },
|
||||
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)", border: "oklch(0.75 0.1 80)" },
|
||||
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)", border: "oklch(0.7 0.15 145)" },
|
||||
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)", border: "oklch(0.75 0.1 27)" },
|
||||
};
|
||||
const st = statusMap[s.status] ?? statusMap.PENDING;
|
||||
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 ${isPending ? "var(--foreground)" : "var(--border)"}`,
|
||||
display: "flex",
|
||||
}}
|
||||
style={{ border: `2px solid ${st.border}`, display: "flex" }}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium truncate">{s.user.name}</p>
|
||||
@@ -131,14 +136,10 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
background: isPending ? "var(--foreground)" : "transparent",
|
||||
color: isPending ? "var(--background)" : "var(--muted-foreground)",
|
||||
}}
|
||||
className="text-xs px-2 py-0.5 font-medium"
|
||||
style={{ background: st.bg, color: st.color }}
|
||||
>
|
||||
{isPending ? "Новое" : "Проверено"}
|
||||
{st.label}
|
||||
</span>
|
||||
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
|
||||
|
||||
+14
-3
@@ -1,7 +1,4 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@@ -148,6 +145,20 @@
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ── Lesson content (TipTap nested lists fix) ──────────────────────── */
|
||||
.prose :where(li > p) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.prose :where(li > ul, li > ol) {
|
||||
margin-top: 0.375em;
|
||||
margin-bottom: 0.375em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.prose :where(ul > li, ol > li) {
|
||||
padding-left: 0.375em;
|
||||
}
|
||||
|
||||
/* Admin sidebar (dark) */
|
||||
.admin-sidebar {
|
||||
background-color: var(--sidebar-bg);
|
||||
|
||||
@@ -9,6 +9,7 @@ const links = [
|
||||
{ href: "/admin/categories", label: "Категории" },
|
||||
{ href: "/admin/users", label: "Пользователи" },
|
||||
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
||||
{ href: "/admin/quizzes", label: "Тесты" },
|
||||
{ href: "/admin/comments", label: "Комментарии" },
|
||||
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
|
||||
{ href: "/admin/settings", label: "Настройки" },
|
||||
|
||||
@@ -14,6 +14,7 @@ interface Course {
|
||||
description: string | null;
|
||||
coverImage: string | null;
|
||||
published: boolean;
|
||||
allowAudio: boolean;
|
||||
categoryId: string | null;
|
||||
}
|
||||
|
||||
@@ -24,6 +25,7 @@ interface Category {
|
||||
|
||||
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
|
||||
const [published, setPublished] = useState(course.published);
|
||||
const [allowAudio, setAllowAudio] = useState(course.allowAudio);
|
||||
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
|
||||
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -45,6 +47,7 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
fd.set("published", String(published));
|
||||
fd.set("allowAudio", String(allowAudio));
|
||||
fd.set("coverImage", coverImage);
|
||||
fd.set("categoryId", categoryId);
|
||||
startTransition(() => updateCourse(course.id, fd));
|
||||
@@ -99,17 +102,33 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
|
||||
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={published}
|
||||
onClick={() => setPublished(!published)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
|
||||
>
|
||||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={published}
|
||||
onClick={() => setPublished(!published)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
|
||||
>
|
||||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={allowAudio}
|
||||
onClick={() => setAllowAudio(!allowAudio)}
|
||||
className={`relative w-10 h-6 rounded-full transition-colors ${allowAudio ? "bg-blue-500" : "bg-slate-300"}`}
|
||||
>
|
||||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${allowAudio ? "translate-x-4" : ""}`} />
|
||||
</button>
|
||||
<span className="text-sm text-slate-600">
|
||||
{allowAudio ? "🎤 Аудио-ответ в ДЗ включён" : "Аудио-ответ в ДЗ выключен"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2">
|
||||
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useTransition } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
|
||||
import { grantAccess, revokeAccess } from "@/lib/actions/course-actions";
|
||||
|
||||
interface Student {
|
||||
id: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { saveHomework, deleteHomework } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/homework-actions";
|
||||
import { saveHomework, deleteHomework } from "@/lib/actions/homework-actions";
|
||||
|
||||
interface Props {
|
||||
lessonId: string;
|
||||
|
||||
@@ -64,8 +64,10 @@ export function HomeworkFilters({ courses }: { courses: Course[] }) {
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="pending">Ожидают проверки</option>
|
||||
<option value="reviewed">Проверено</option>
|
||||
<option value="pending">Новые</option>
|
||||
<option value="reviewing">На рассмотрении</option>
|
||||
<option value="approved">Одобрено</option>
|
||||
<option value="rejected">Отклонено</option>
|
||||
</select>
|
||||
|
||||
{/* Course */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useTransition } from "react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
@@ -9,7 +9,6 @@ import Underline from "@tiptap/extension-underline";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
|
||||
|
||||
interface LessonData {
|
||||
id: string;
|
||||
@@ -47,7 +46,8 @@ export function LessonEditor({
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const inputStyle = {
|
||||
border: "2px solid var(--border)",
|
||||
@@ -73,7 +73,7 @@ export function LessonEditor({
|
||||
class: "prose prose-slate max-w-none min-h-[300px] focus:outline-none p-4",
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [lesson.id]);
|
||||
|
||||
const uploadImage = useCallback(async () => {
|
||||
const input = document.createElement("input");
|
||||
@@ -133,18 +133,27 @@ export function LessonEditor({
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
function handleSave() {
|
||||
async function handleSave() {
|
||||
if (!editor) return;
|
||||
startTransition(async () => {
|
||||
await saveLesson(lesson.id, courseId, moduleId, {
|
||||
title,
|
||||
kinescopeId,
|
||||
content: editor.getJSON(),
|
||||
published,
|
||||
setPending(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/lessons/${lesson.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, kinescopeId, content: editor.getJSON(), published }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
});
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : "Ошибка сохранения");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTo(lessonId: string) {
|
||||
@@ -154,7 +163,22 @@ export function LessonEditor({
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header controls */}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 flex-wrap"
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: 0,
|
||||
zIndex: 30,
|
||||
background: "var(--background)",
|
||||
marginLeft: "-1.5rem",
|
||||
marginRight: "-1.5rem",
|
||||
paddingLeft: "1.5rem",
|
||||
paddingRight: "1.5rem",
|
||||
paddingTop: "0.75rem",
|
||||
paddingBottom: "0.75rem",
|
||||
borderBottom: "2px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{/* Left: published toggle + prev/next */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
@@ -251,6 +275,12 @@ export function LessonEditor({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{saveError && (
|
||||
<p className="text-xs px-3 py-2" style={{ background: "oklch(0.577 0.245 27.325 / 0.1)", color: "oklch(0.577 0.245 27.325)", border: "1px solid oklch(0.577 0.245 27.325 / 0.3)" }}>
|
||||
Ошибка сохранения: {saveError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
@@ -289,7 +319,14 @@ export function LessonEditor({
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex flex-wrap gap-0.5 p-2"
|
||||
style={{ border: "2px solid var(--border)", borderBottom: "1px solid var(--border)", background: "var(--color-surface)" }}
|
||||
style={{
|
||||
position: "sticky",
|
||||
top: "62px",
|
||||
zIndex: 20,
|
||||
border: "2px solid var(--border)",
|
||||
borderBottom: "1px solid var(--border)",
|
||||
background: "var(--color-surface)",
|
||||
}}
|
||||
>
|
||||
{/* Text style */}
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}><strong>Ж</strong></ToolBtn>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState, useRef } from "react";
|
||||
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
||||
|
||||
interface LessonFile {
|
||||
id: string;
|
||||
@@ -13,6 +13,10 @@ interface LessonFile {
|
||||
export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: string; initialFiles: LessonFile[] }) {
|
||||
const [files, setFiles] = useState(initialFiles);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [labelInput, setLabelInput] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingLabel, setEditingLabel] = useState("");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -21,23 +25,52 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("lessonId", lessonId);
|
||||
if (labelInput.trim()) fd.append("label", labelInput.trim());
|
||||
const res = await fetch("/api/admin/lesson-files", { method: "POST", body: fd });
|
||||
const created = await res.json();
|
||||
if (created.id) setFiles((prev) => [...prev, created]);
|
||||
if (created.id) {
|
||||
setFiles((prev) => [...prev, created]);
|
||||
setLabelInput("");
|
||||
}
|
||||
setUploading(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
async function handleDelete(fileId: string) {
|
||||
async function handleDelete(fileId: string, url: string) {
|
||||
if (!confirm("Удалить файл?")) return;
|
||||
await fetch("/api/admin/lesson-files", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId }),
|
||||
body: JSON.stringify({ fileId, url }),
|
||||
});
|
||||
setFiles((prev) => prev.filter((f) => f.id !== fileId));
|
||||
}
|
||||
|
||||
async function saveLabel(fileId: string) {
|
||||
const trimmed = editingLabel.trim();
|
||||
if (!trimmed) return cancelEdit();
|
||||
const res = await fetch("/api/admin/lesson-files", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fileId, label: trimmed }),
|
||||
});
|
||||
const updated = await res.json();
|
||||
if (updated.id) {
|
||||
setFiles((prev) => prev.map((f) => (f.id === fileId ? { ...f, name: updated.name } : f)));
|
||||
}
|
||||
cancelEdit();
|
||||
}
|
||||
|
||||
function startEdit(file: LessonFile) {
|
||||
setEditingId(file.id);
|
||||
setEditingLabel(file.name);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditingLabel("");
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (bytes < 1024) return `${bytes} Б`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
||||
@@ -49,24 +82,92 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin
|
||||
{files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{files.map((f) => (
|
||||
<div key={f.id} className="flex items-center gap-3 px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||
<span className="text-base">📎</span>
|
||||
<a href={f.url} target="_blank" rel="noopener noreferrer" className="flex-1 underline font-medium">{f.name}</a>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
||||
<button onClick={() => handleDelete(f.id)} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||
<div
|
||||
key={f.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 text-sm"
|
||||
style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<FileFormatBadge url={f.url} />
|
||||
{editingId === f.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingLabel}
|
||||
onChange={(e) => setEditingLabel(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveLabel(f.id);
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
}}
|
||||
onBlur={() => saveLabel(f.id)}
|
||||
className="flex-1 text-sm px-2 py-0.5"
|
||||
style={{
|
||||
border: "1px solid var(--foreground)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEdit(f)}
|
||||
className="flex-1 text-left font-medium"
|
||||
title="Нажмите, чтобы изменить название"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
{f.name}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{formatSize(f.size)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(f.id, f.url)}
|
||||
className="text-xs"
|
||||
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="btn-aubade text-xs cursor-pointer">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={labelInput}
|
||||
onChange={(e) => setLabelInput(e.target.value)}
|
||||
placeholder="Название (например, Презентация)"
|
||||
className="flex-1 text-sm px-3 py-2"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="btn-aubade text-xs whitespace-nowrap"
|
||||
style={{ flexShrink: 0, opacity: uploading ? 0.6 : 1 }}
|
||||
>
|
||||
{uploading ? "Загрузка..." : "+ Добавить файл"}
|
||||
<input type="file" className="sr-only" onChange={handleUpload} disabled={uploading} />
|
||||
</label>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX — до 100 МБ</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
onChange={handleUpload}
|
||||
disabled={uploading}
|
||||
accept=".pdf,.zip,.docx,.xlsx,.doc,.xls,.pptx,.ppt,.mp4,.mp3"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX — до 100 МБ</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { saveQuiz, deleteQuiz } from "@/lib/actions/quiz-actions";
|
||||
|
||||
type QType = "TEXT" | "SINGLE" | "MULTIPLE";
|
||||
|
||||
interface Question {
|
||||
id?: string;
|
||||
text: string;
|
||||
type: QType;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
lessonId: string;
|
||||
initial: { id: string; questions: Question[] } | null;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<QType, string> = {
|
||||
TEXT: "Текстовый ответ",
|
||||
SINGLE: "Один вариант",
|
||||
MULTIPLE: "Несколько вариантов",
|
||||
};
|
||||
|
||||
export function QuizEditor({ lessonId, initial }: Props) {
|
||||
const [questions, setQuestions] = useState<Question[]>(
|
||||
initial?.questions ?? []
|
||||
);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const inputStyle = {
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
|
||||
function addQuestion() {
|
||||
setQuestions((prev) => [
|
||||
...prev,
|
||||
{ text: "", type: "TEXT", order: prev.length },
|
||||
]);
|
||||
}
|
||||
|
||||
function updateQuestion(idx: number, patch: Partial<Question>) {
|
||||
setQuestions((prev) =>
|
||||
prev.map((q, i) => (i === idx ? { ...q, ...patch } : q))
|
||||
);
|
||||
}
|
||||
|
||||
function removeQuestion(idx: number) {
|
||||
setQuestions((prev) =>
|
||||
prev.filter((_, i) => i !== idx).map((q, i) => ({ ...q, order: i }))
|
||||
);
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
if (questions.length === 0) return;
|
||||
startTransition(async () => {
|
||||
await saveQuiz(
|
||||
lessonId,
|
||||
questions.map((q, i) => ({ text: q.text.trim(), type: q.type, order: i }))
|
||||
);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!confirm("Удалить квиз и все ответы студентов?")) return;
|
||||
startTransition(async () => {
|
||||
await deleteQuiz(lessonId);
|
||||
setQuestions([]);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{questions.length === 0 && (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Вопросов нет
|
||||
</p>
|
||||
)}
|
||||
|
||||
{questions.map((q, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="space-y-2 px-4 py-3"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className="text-xs font-bold uppercase tracking-widest"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Вопрос {idx + 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => removeQuestion(idx)}
|
||||
className="text-xs"
|
||||
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
value={q.text}
|
||||
onChange={(e) => updateQuestion(idx, { text: e.target.value })}
|
||||
style={{ ...inputStyle, minHeight: "unset" }}
|
||||
placeholder="Текст вопроса..."
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
{(["TEXT", "SINGLE", "MULTIPLE"] as QType[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => updateQuestion(idx, { type: t })}
|
||||
className="text-xs px-2 py-1"
|
||||
style={{
|
||||
border: `2px solid ${q.type === t ? "var(--foreground)" : "var(--border)"}`,
|
||||
background: q.type === t ? "var(--foreground)" : "transparent",
|
||||
color: q.type === t ? "var(--background)" : "var(--foreground)",
|
||||
}}
|
||||
>
|
||||
{TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button onClick={addQuestion} className="btn-aubade text-xs px-3 py-1.5">
|
||||
+ Добавить вопрос
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={pending || questions.length === 0 || questions.some((q) => !q.text.trim())}
|
||||
className="btn-aubade btn-aubade-accent text-xs px-4 py-1.5"
|
||||
style={{
|
||||
opacity:
|
||||
pending || questions.length === 0 || questions.some((q) => !q.text.trim())
|
||||
? 0.6
|
||||
: 1,
|
||||
}}
|
||||
>
|
||||
{pending ? "Сохранение..." : "Сохранить квиз"}
|
||||
</button>
|
||||
{initial && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={pending}
|
||||
className="text-xs px-3 py-1.5"
|
||||
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||
>
|
||||
Удалить квиз
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<span
|
||||
className="text-xs self-center"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
✓ Сохранено
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { resetUserPassword } from "@/app/admin/users/[userId]/actions";
|
||||
|
||||
export function ResetPasswordButton({ userId }: { userId: string }) {
|
||||
const [tempPassword, setTempPassword] = useState<string | null>(null);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function handleReset() {
|
||||
if (!confirm("Сгенерировать новый временный пароль для пользователя?")) return;
|
||||
setIsPending(true);
|
||||
setTempPassword(null);
|
||||
try {
|
||||
const { tempPassword: pw } = await resetUserPassword(userId);
|
||||
setTempPassword(pw);
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : "Ошибка");
|
||||
} finally {
|
||||
setIsPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
if (!tempPassword) return;
|
||||
await navigator.clipboard.writeText(tempPassword);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
disabled={isPending}
|
||||
className="btn-aubade text-sm"
|
||||
style={isPending ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
|
||||
>
|
||||
{isPending ? "Генерация..." : "Сбросить пароль"}
|
||||
</button>
|
||||
|
||||
{tempPassword && (
|
||||
<div className="p-3 text-sm space-y-2" style={{ border: "2px solid var(--border)", backgroundColor: "var(--color-highlight)" }}>
|
||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
Временный пароль — передай пользователю
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<code className="text-base font-bold tracking-wider">{tempPassword}</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-xs underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{copied ? "Скопировано" : "Копировать"}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Пользователь сможет сменить пароль в профиле после входа.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -432,6 +432,62 @@ export function SettingsForm({ initial }: { initial: Settings }) {
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* ── 7. Логотип ── */}
|
||||
<Section
|
||||
title="Логотип"
|
||||
hint="URL изображения логотипа школы. Отображается рядом с названием в шапке личного кабинета ученика."
|
||||
>
|
||||
<Field label="URL логотипа">
|
||||
<input
|
||||
value={s.logoUrl}
|
||||
onChange={(e) => set("logoUrl", e.target.value)}
|
||||
placeholder="https://..."
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</Field>
|
||||
<Toggle
|
||||
label="Показывать логотип в шапке"
|
||||
hint="Если выключено — логотип скрыт, отображается только название школы."
|
||||
checked={bool("showLogo")}
|
||||
onChange={(v) => set("showLogo", v ? "true" : "false")}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
{/* ── 8. Социальные сети ── */}
|
||||
<Section
|
||||
title="Социальные сети"
|
||||
hint="Ссылки отображаются в подвале личного кабинета ученика."
|
||||
>
|
||||
<Field label="YouTube (URL канала)">
|
||||
<input
|
||||
value={s.socialYoutube}
|
||||
onChange={(e) => set("socialYoutube", e.target.value)}
|
||||
placeholder="https://youtube.com/@..."
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="VK (URL сообщества)">
|
||||
<input
|
||||
value={s.socialVk}
|
||||
onChange={(e) => set("socialVk", e.target.value)}
|
||||
placeholder="https://vk.com/..."
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Telegram (ссылка на канал или группу)">
|
||||
<input
|
||||
value={s.socialTelegram}
|
||||
onChange={(e) => set("socialTelegram", e.target.value)}
|
||||
placeholder="https://t.me/..."
|
||||
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
{/* Bottom save button */}
|
||||
<div className="flex justify-end pb-4">
|
||||
<button
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
reorderLessons,
|
||||
toggleLessonPublished,
|
||||
moveLessonToModule,
|
||||
} from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
|
||||
} from "@/lib/actions/module-actions";
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import Link from "next/link";
|
||||
import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions";
|
||||
import { createModule, deleteModule, updateModule, reorderModules } from "@/lib/actions/course-actions";
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export function StopImpersonateBanner({ userName }: { userName: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleStop() {
|
||||
setLoading(true);
|
||||
try {
|
||||
await authClient.admin.stopImpersonating();
|
||||
window.location.href = "/admin/users";
|
||||
} catch {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-2 text-sm"
|
||||
style={{
|
||||
backgroundColor: "var(--color-highlight)",
|
||||
borderBottom: "2px solid var(--foreground)",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
Вы просматриваете платформу как <strong>{userName}</strong>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
disabled={loading}
|
||||
className="btn-aubade text-xs px-3 py-1"
|
||||
>
|
||||
{loading ? "..." : "← Вернуться в админку"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { addBalanceTransaction, deleteBalanceTransaction } from "@/app/admin/users/[userId]/actions";
|
||||
|
||||
interface Transaction {
|
||||
id: string;
|
||||
amount: number;
|
||||
description: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
transactions: Transaction[];
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
padding: "0.4rem 0.6rem",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "inherit",
|
||||
} as React.CSSProperties;
|
||||
|
||||
const focusHandlers = {
|
||||
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(e.currentTarget.style.borderColor = "var(--foreground)"),
|
||||
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(e.currentTarget.style.borderColor = "var(--border)"),
|
||||
};
|
||||
|
||||
function formatAmount(amount: number): string {
|
||||
const sign = amount > 0 ? "+" : "";
|
||||
return `${sign}${amount.toLocaleString("ru-RU", { minimumFractionDigits: 0, maximumFractionDigits: 2 })} ₽`;
|
||||
}
|
||||
|
||||
export function UserBalanceBlock({ userId, transactions }: Props) {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [amountVal, setAmountVal] = useState("");
|
||||
const [descVal, setDescVal] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
|
||||
const balance = transactions.reduce((sum, t) => sum + t.amount, 0);
|
||||
|
||||
function handleAdd() {
|
||||
setError("");
|
||||
const num = parseFloat(amountVal.replace(",", "."));
|
||||
if (isNaN(num) || num === 0) { setError("Введите ненулевую сумму"); return; }
|
||||
if (!descVal.trim()) { setError("Добавьте описание"); return; }
|
||||
startTransition(async () => {
|
||||
await addBalanceTransaction(userId, { amount: amountVal, description: descVal });
|
||||
setAmountVal("");
|
||||
setDescVal("");
|
||||
setShowForm(false);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(txId: string) {
|
||||
setDeletingId(txId);
|
||||
startTransition(async () => {
|
||||
await deleteBalanceTransaction(userId, txId);
|
||||
setDeletingId(null);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Balance summary */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: balance > 0 ? "#3A6A3A" : balance < 0 ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}
|
||||
>
|
||||
{formatAmount(balance)}
|
||||
</span>
|
||||
<span className="text-xs uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
на балансе
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowForm((v) => !v); setError(""); }}
|
||||
className="btn-aubade btn-aubade-accent px-3 py-1.5 text-xs"
|
||||
>
|
||||
{showForm ? "Отмена" : "+ Операция"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Add form */}
|
||||
{showForm && (
|
||||
<div className="p-4 space-y-3" style={{ border: "2px solid var(--border)" }}>
|
||||
<div className="flex gap-3">
|
||||
<div className="space-y-1" style={{ width: 140 }}>
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Сумма, ₽
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={amountVal}
|
||||
onChange={(e) => setAmountVal(e.target.value)}
|
||||
placeholder="-3490 или 1000"
|
||||
style={{ ...inputStyle, width: "100%" }}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 flex-1">
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Описание
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={descVal}
|
||||
onChange={(e) => setDescVal(e.target.value)}
|
||||
placeholder="Предоплата курса, возврат, партнёрка..."
|
||||
style={{ ...inputStyle, width: "100%" }}
|
||||
{...focusHandlers}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAdd}
|
||||
disabled={pending}
|
||||
className="btn-aubade btn-aubade-accent px-4 py-1.5 text-xs"
|
||||
style={{ opacity: pending ? 0.6 : 1 }}
|
||||
>
|
||||
{pending ? "Сохранение..." : "Добавить"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction list */}
|
||||
{transactions.length > 0 ? (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{transactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="flex items-center gap-3 px-3 py-2 text-xs group"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
<span
|
||||
className="font-bold"
|
||||
style={{
|
||||
minWidth: 80,
|
||||
color: tx.amount > 0 ? "#3A6A3A" : "oklch(0.577 0.245 27.325)",
|
||||
}}
|
||||
>
|
||||
{formatAmount(tx.amount)}
|
||||
</span>
|
||||
<span className="flex-1">{tx.description}</span>
|
||||
<span style={{ color: "var(--muted-foreground)", whiteSpace: "nowrap" }}>
|
||||
{new Date(tx.createdAt).toLocaleString("ru-RU", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(tx.id)}
|
||||
disabled={deletingId === tx.id || pending}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs"
|
||||
style={{ color: "oklch(0.577 0.245 27.325)", flexShrink: 0 }}
|
||||
title="Удалить"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Операций ещё нет</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { updateUserContact } from "@/app/admin/users/[userId]/actions";
|
||||
|
||||
const inputStyle = {
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
padding: "0.4rem 0.6rem",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "inherit",
|
||||
} as React.CSSProperties;
|
||||
|
||||
const focusHandlers = {
|
||||
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(e.currentTarget.style.borderColor = "var(--foreground)"),
|
||||
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
|
||||
(e.currentTarget.style.borderColor = "var(--border)"),
|
||||
};
|
||||
|
||||
interface Props {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
birthday: Date | null;
|
||||
comment: string | null;
|
||||
}
|
||||
|
||||
export function UserContactEditor({ userId, name, email, phone, birthday, comment }: Props) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [nameVal, setNameVal] = useState(name);
|
||||
const [emailVal, setEmailVal] = useState(email);
|
||||
const [phoneVal, setPhoneVal] = useState(phone ?? "");
|
||||
const [birthdayVal, setBirthdayVal] = useState(
|
||||
birthday ? birthday.toISOString().slice(0, 10) : ""
|
||||
);
|
||||
const [commentVal, setCommentVal] = useState(comment ?? "");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
function handleSave() {
|
||||
startTransition(async () => {
|
||||
await updateUserContact(userId, {
|
||||
name: nameVal,
|
||||
email: emailVal,
|
||||
phone: phoneVal,
|
||||
birthday: birthdayVal,
|
||||
comment: commentVal,
|
||||
});
|
||||
setEditing(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start gap-6 flex-wrap">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Телефон
|
||||
</p>
|
||||
<p className="text-sm">{phone || "—"}</p>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
День рождения
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{birthday
|
||||
? new Date(birthday).toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-xs underline self-end pb-0.5"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Изменить
|
||||
</button>
|
||||
</div>
|
||||
{comment && (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Комментарий
|
||||
</p>
|
||||
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--foreground)" }}>{comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Имя
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={nameVal}
|
||||
onChange={(e) => setNameVal(e.target.value)}
|
||||
style={inputStyle}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={emailVal}
|
||||
onChange={(e) => setEmailVal(e.target.value)}
|
||||
style={inputStyle}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Телефон
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={phoneVal}
|
||||
onChange={(e) => setPhoneVal(e.target.value)}
|
||||
placeholder="+7 900 000-00-00"
|
||||
style={inputStyle}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
День рождения
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={birthdayVal}
|
||||
onChange={(e) => setBirthdayVal(e.target.value)}
|
||||
style={inputStyle}
|
||||
{...focusHandlers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
|
||||
Комментарий
|
||||
</label>
|
||||
<textarea
|
||||
value={commentVal}
|
||||
onChange={(e) => setCommentVal(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Заметки об этом пользователе..."
|
||||
style={{ ...inputStyle, resize: "vertical" }}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={pending}
|
||||
className="btn-aubade btn-aubade-accent px-4 py-1.5 text-xs"
|
||||
style={{ opacity: pending ? 0.6 : 1 }}
|
||||
>
|
||||
{pending ? "Сохранение..." : "Сохранить"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(false)}
|
||||
className="text-xs underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { bulkGrantAccess, revokeUserAccess } from "@/app/admin/users/[userId]/actions";
|
||||
import { bulkGrantAccess, revokeUserAccess } from "@/lib/actions/user-actions";
|
||||
|
||||
interface Course {
|
||||
id: string;
|
||||
@@ -28,7 +26,7 @@ export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props
|
||||
() => new Map(enrollments.map((e) => [e.courseId, e.expiresAt]))
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [expiryDate, setExpiryDate] = useState("");
|
||||
const [days, setDays] = useState("");
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const unenrolled = allCourses.filter((c) => !enrolledMap.has(c.id));
|
||||
@@ -44,12 +42,16 @@ export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props
|
||||
function handleBulkGrant() {
|
||||
if (selected.size === 0) return;
|
||||
const ids = [...selected];
|
||||
const expiry = expiryDate || null;
|
||||
const daysNum = parseInt(days, 10);
|
||||
const expiresAt = !isNaN(daysNum) && daysNum > 0
|
||||
? new Date(Date.now() + daysNum * 86_400_000).toISOString()
|
||||
: null;
|
||||
const newMap = new Map(enrolledMap);
|
||||
ids.forEach((id) => newMap.set(id, expiry ? new Date(expiry) : null));
|
||||
ids.forEach((id) => newMap.set(id, expiresAt ? new Date(expiresAt) : null));
|
||||
setEnrolledMap(newMap);
|
||||
setSelected(new Set());
|
||||
startTransition(() => bulkGrantAccess(userId, ids, expiry));
|
||||
setDays("");
|
||||
startTransition(() => bulkGrantAccess(userId, ids, expiresAt));
|
||||
}
|
||||
|
||||
function handleRevoke(courseId: string) {
|
||||
@@ -119,9 +121,26 @@ export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<label className="text-xs uppercase tracking-wider block mb-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
Срок доступа
|
||||
Дней (0 — бессрочно)
|
||||
</label>
|
||||
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-44" />
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={days}
|
||||
onChange={(e) => setDays(e.target.value)}
|
||||
placeholder="0"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
padding: "0.4rem 0.6rem",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "inherit",
|
||||
width: "6rem",
|
||||
}}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
</div>
|
||||
<div className="pt-5">
|
||||
<button onClick={handleBulkGrant} disabled={pending} className="btn-aubade btn-aubade-accent">
|
||||
|
||||
@@ -13,15 +13,24 @@ const inputStyle: React.CSSProperties = {
|
||||
fontFamily: "inherit",
|
||||
};
|
||||
|
||||
export function UsersSearch({ initialSearch, initialRole }: { initialSearch: string; initialRole: string }) {
|
||||
export function UsersSearch({
|
||||
initialSearch,
|
||||
initialRole,
|
||||
initialBalance,
|
||||
}: {
|
||||
initialSearch: string;
|
||||
initialRole: string;
|
||||
initialBalance: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [, startTransition] = useTransition();
|
||||
|
||||
function update(search: string, role: string) {
|
||||
function update(search: string, role: string, balance: string) {
|
||||
const params = new URLSearchParams();
|
||||
if (search) params.set("search", search);
|
||||
if (role) params.set("role", role);
|
||||
if (balance) params.set("balance", balance);
|
||||
startTransition(() => router.push(`${pathname}?${params.toString()}`));
|
||||
}
|
||||
|
||||
@@ -36,7 +45,7 @@ export function UsersSearch({ initialSearch, initialRole }: { initialSearch: str
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = "var(--border)";
|
||||
update(e.currentTarget.value.trim(), initialRole);
|
||||
update(e.currentTarget.value.trim(), initialRole, initialBalance);
|
||||
}}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
|
||||
/>
|
||||
@@ -44,7 +53,7 @@ export function UsersSearch({ initialSearch, initialRole }: { initialSearch: str
|
||||
|
||||
<select
|
||||
defaultValue={initialRole}
|
||||
onChange={(e) => update(initialSearch, e.target.value)}
|
||||
onChange={(e) => update(initialSearch, e.target.value, initialBalance)}
|
||||
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
@@ -55,7 +64,21 @@ export function UsersSearch({ initialSearch, initialRole }: { initialSearch: str
|
||||
<option value="admin">Администраторы</option>
|
||||
</select>
|
||||
|
||||
{(initialSearch || initialRole) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => update(initialSearch, initialRole, initialBalance === "nonzero" ? "" : "nonzero")}
|
||||
className="text-xs px-3"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
background: initialBalance === "nonzero" ? "var(--foreground)" : "transparent",
|
||||
color: initialBalance === "nonzero" ? "var(--background)" : "var(--muted-foreground)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
С балансом
|
||||
</button>
|
||||
|
||||
{(initialSearch || initialRole || initialBalance) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startTransition(() => router.push(pathname))}
|
||||
|
||||
@@ -90,6 +90,43 @@ function UserPopup({ user }: { user: UserRow }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ImpersonateButton({ userId }: { userId: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleImpersonate() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/auth/admin/impersonate-user", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId }),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
window.location.href = "/dashboard";
|
||||
} catch (e) {
|
||||
console.error("Impersonation failed:", e);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleImpersonate}
|
||||
disabled={loading}
|
||||
className="text-xs px-2 py-1 transition-colors"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
color: loading ? "var(--muted-foreground)" : "var(--foreground)",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
{loading ? "..." : "Войти как"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function UsersTable({ users }: { users: UserRow[] }) {
|
||||
const [hoveredId, setHoveredId] = useState<string | null>(null);
|
||||
|
||||
@@ -134,21 +171,24 @@ export function UsersTable({ users }: { users: UserRow[] }) {
|
||||
<td className="px-5 py-3 text-sm text-slate-400">
|
||||
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
|
||||
</td>
|
||||
{/* Hover popup trigger */}
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-3 relative">
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={() => setHoveredId(user.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1"
|
||||
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||
<div className="flex items-center gap-2">
|
||||
{user.role !== "admin" && <ImpersonateButton userId={user.id} />}
|
||||
<div
|
||||
className="relative inline-block"
|
||||
onMouseEnter={() => setHoveredId(user.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
>
|
||||
···
|
||||
</button>
|
||||
{hoveredId === user.id && <UserPopup user={user} />}
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1"
|
||||
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||
>
|
||||
···
|
||||
</button>
|
||||
{hoveredId === user.id && <UserPopup user={user} />}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef } from "react";
|
||||
|
||||
interface AudioRecorderProps {
|
||||
value: string | null;
|
||||
onChange: (url: string | null) => void;
|
||||
uploadUrl?: string;
|
||||
}
|
||||
|
||||
export function AudioRecorder({ value, onChange, uploadUrl = "/api/curator/audio-upload" }: AudioRecorderProps) {
|
||||
const [state, setState] = useState<"idle" | "recording" | "recorded" | "uploading">("idle");
|
||||
const [localUrl, setLocalUrl] = useState<string | null>(null);
|
||||
const mediaRecorder = useRef<MediaRecorder | null>(null);
|
||||
const chunks = useRef<Blob[]>([]);
|
||||
const mimeType = useRef<string>("audio/webm");
|
||||
|
||||
async function startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mr = new MediaRecorder(stream);
|
||||
mimeType.current = mr.mimeType || "audio/webm";
|
||||
chunks.current = [];
|
||||
mr.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunks.current.push(e.data);
|
||||
};
|
||||
mr.onstop = () => {
|
||||
const blob = new Blob(chunks.current, { type: mimeType.current });
|
||||
setLocalUrl(URL.createObjectURL(blob));
|
||||
setState("recorded");
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
mediaRecorder.current = mr;
|
||||
mr.start();
|
||||
setState("recording");
|
||||
} catch {
|
||||
alert("Нет доступа к микрофону");
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
mediaRecorder.current?.stop();
|
||||
}
|
||||
|
||||
async function uploadAudio() {
|
||||
setState("uploading");
|
||||
const blob = new Blob(chunks.current, { type: mimeType.current });
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "audio-response.webm");
|
||||
const res = await fetch(uploadUrl, { method: "POST", body: form });
|
||||
const data = await res.json();
|
||||
if (data.url) {
|
||||
onChange(data.url);
|
||||
}
|
||||
setState("idle");
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setLocalUrl(null);
|
||||
chunks.current = [];
|
||||
setState("idle");
|
||||
}
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
Аудио-ответ:
|
||||
</span>
|
||||
<audio controls src={value} style={{ height: 32 }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(null)}
|
||||
className="text-xs"
|
||||
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{state === "idle" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startRecording}
|
||||
className="btn-aubade text-xs px-3 py-1.5 flex items-center gap-1.5"
|
||||
>
|
||||
🎤 Записать аудио-ответ
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state === "recording" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stopRecording}
|
||||
className="btn-aubade-accent text-xs px-3 py-1.5 flex items-center gap-1.5 animate-pulse"
|
||||
>
|
||||
⏹ Остановить запись
|
||||
</button>
|
||||
)}
|
||||
|
||||
{state === "recorded" && localUrl && (
|
||||
<div className="flex items-center gap-2">
|
||||
<audio controls src={localUrl} style={{ height: 32 }} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={uploadAudio}
|
||||
className="btn-aubade text-xs px-3 py-1.5"
|
||||
>
|
||||
Загрузить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={reset}
|
||||
className="text-xs"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Перезаписать
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === "uploading" && (
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
Загрузка аудио...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
|
||||
export function ContentViewer({ content }: { content: unknown }) {
|
||||
const editor = useEditor({
|
||||
extensions: [StarterKit, Link, Underline],
|
||||
content: content as object,
|
||||
editable: false,
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||
Контент урока не добавлен
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="prose prose-sm max-w-none text-sm"
|
||||
style={{ color: "var(--foreground)" }}
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import KinescopeReactPlayer from "@kinescope/react-kinescope-player";
|
||||
|
||||
interface Props {
|
||||
videoId: string;
|
||||
poster?: string;
|
||||
}
|
||||
|
||||
export function KinescopePlayer({ videoId }: Props) {
|
||||
export function KinescopePlayer({ videoId, poster }: Props) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
return (
|
||||
<div className="w-full aspect-video" style={{ border: "2px solid var(--border)" }}>
|
||||
<KinescopeReactPlayer
|
||||
videoId={videoId}
|
||||
width="100%"
|
||||
height="100%"
|
||||
/>
|
||||
{mounted && (
|
||||
<KinescopeReactPlayer
|
||||
videoId={videoId}
|
||||
width="100%"
|
||||
height="100%"
|
||||
poster={poster}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
const FORMAT_MAP: Record<string, { label: string; bg: string }> = {
|
||||
pdf: { label: "PDF", bg: "#DC2626" },
|
||||
zip: { label: "ZIP", bg: "#D97706" },
|
||||
docx: { label: "DOCX", bg: "#2563EB" },
|
||||
doc: { label: "DOC", bg: "#2563EB" },
|
||||
xlsx: { label: "XLSX", bg: "#16A34A" },
|
||||
xls: { label: "XLS", bg: "#16A34A" },
|
||||
pptx: { label: "PPTX", bg: "#EA580C" },
|
||||
ppt: { label: "PPT", bg: "#EA580C" },
|
||||
mp4: { label: "MP4", bg: "#7C3AED" },
|
||||
mp3: { label: "MP3", bg: "#7C3AED" },
|
||||
};
|
||||
|
||||
export function getFileFormatInfo(url: string): { label: string; bg: string } {
|
||||
const ext = url.split("?")[0].split(".").pop()?.toLowerCase() ?? "";
|
||||
return FORMAT_MAP[ext] ?? { label: ext.toUpperCase().slice(0, 4) || "FILE", bg: "#6B7280" };
|
||||
}
|
||||
|
||||
export function FileFormatBadge({ url }: { url: string }) {
|
||||
const { label, bg } = getFileFormatInfo(url);
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
minWidth: "44px",
|
||||
padding: "2px 6px",
|
||||
fontSize: "10px",
|
||||
fontWeight: "800",
|
||||
letterSpacing: "0.06em",
|
||||
background: bg,
|
||||
color: "#fff",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Menu, X } from "lucide-react";
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
@@ -29,7 +30,7 @@ export function CourseSidebar({
|
||||
completedLessonIds?: Set<string>;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||
const completedCount = course.modules
|
||||
@@ -39,21 +40,39 @@ export function CourseSidebar({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile toggle */}
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="md:hidden fixed bottom-4 right-4 z-20 btn-aubade px-3 py-2 text-sm"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="lg:hidden fixed top-3 left-3 z-50 p-2 rounded"
|
||||
style={{
|
||||
backgroundColor: "var(--background)",
|
||||
border: "1.5px solid var(--border)",
|
||||
color: "var(--foreground)",
|
||||
}}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="Навигация по курсу"
|
||||
>
|
||||
{open ? "✕" : "☰ Уроки"}
|
||||
{open ? <X size={20} /> : <Menu size={20} />}
|
||||
</button>
|
||||
|
||||
{/* Overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 bg-black/50 z-30"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside
|
||||
className={`w-64 shrink-0 flex flex-col overflow-y-auto ${open ? "flex" : "hidden md:flex"}`}
|
||||
className={[
|
||||
"w-64 flex flex-col overflow-y-auto",
|
||||
"fixed top-[53px] left-0 bottom-0 z-40 transition-transform duration-200",
|
||||
"lg:sticky lg:shrink-0 lg:translate-x-0",
|
||||
open ? "translate-x-0" : "-translate-x-full lg:translate-x-0",
|
||||
].join(" ")}
|
||||
style={{
|
||||
borderRight: "2px solid var(--border)",
|
||||
backgroundColor: "var(--background)",
|
||||
maxHeight: "calc(100vh - 53px)",
|
||||
position: "sticky",
|
||||
top: "53px",
|
||||
}}
|
||||
>
|
||||
@@ -127,6 +146,7 @@ export function CourseSidebar({
|
||||
<Link
|
||||
key={lesson.id}
|
||||
href={`/courses/${course.slug}/lessons/${lesson.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
|
||||
style={{
|
||||
borderLeftColor: active ? "var(--foreground)" : "transparent",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
|
||||
import { submitHomework } from "@/lib/actions/student-actions";
|
||||
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||
|
||||
interface HWFile { name: string; url: string; size: number }
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
text: string;
|
||||
files?: HWFile[];
|
||||
audioUrl?: string | null;
|
||||
createdAt: Date;
|
||||
curator: { name: string };
|
||||
}
|
||||
@@ -16,6 +19,7 @@ interface Submission {
|
||||
id: string;
|
||||
text: string | null;
|
||||
files: HWFile[];
|
||||
audioUrl?: string | null;
|
||||
submittedAt: Date;
|
||||
feedbacks: Feedback[];
|
||||
}
|
||||
@@ -25,6 +29,7 @@ interface Props {
|
||||
submission: Submission | null;
|
||||
slug: string;
|
||||
lessonId: string;
|
||||
allowAudio?: boolean;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
@@ -33,9 +38,10 @@ function formatSize(bytes: number) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
||||
}
|
||||
|
||||
export function HomeworkSection({ homework, submission, slug, lessonId }: Props) {
|
||||
export function HomeworkSection({ homework, submission, slug, lessonId, allowAudio = false }: Props) {
|
||||
const [text, setText] = useState(submission?.text ?? "");
|
||||
const [files, setFiles] = useState<HWFile[]>(submission?.files ?? []);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(submission?.audioUrl ?? null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [editing, setEditing] = useState(!submission);
|
||||
@@ -72,7 +78,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files));
|
||||
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files, audioUrl));
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
@@ -91,18 +97,44 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
||||
{submission!.text || "—"}
|
||||
</div>
|
||||
</div>
|
||||
{submission!.feedbacks.map((fb) => (
|
||||
<div key={fb.id} className="px-4 py-3 space-y-1" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-widest">Обратная связь</p>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
{submission!.audioUrl && (
|
||||
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
||||
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
|
||||
<audio controls src={submission!.audioUrl} style={{ height: 32, width: "100%" }} />
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
{submission!.feedbacks.map((fb) => {
|
||||
const fbFiles = fb.files ?? [];
|
||||
return (
|
||||
<div key={fb.id} className="px-4 py-3 space-y-2" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-bold uppercase tracking-widest">Обратная связь</p>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
|
||||
{fbFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{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>
|
||||
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио от куратора:</p>
|
||||
<audio controls src={fb.audioUrl} style={{ height: 32 }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -127,6 +159,12 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
||||
{submission.text}
|
||||
</div>
|
||||
)}
|
||||
{submission.audioUrl && (
|
||||
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
||||
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
|
||||
<audio controls src={submission.audioUrl} style={{ height: 32, width: "100%" }} />
|
||||
</div>
|
||||
)}
|
||||
{submission.files.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{submission.files.map((f) => (
|
||||
@@ -169,12 +207,21 @@ export function HomeworkSection({ homework, submission, slug, lessonId }: Props)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio recorder */}
|
||||
{allowAudio && (
|
||||
<AudioRecorder
|
||||
value={audioUrl}
|
||||
onChange={setAudioUrl}
|
||||
uploadUrl="/api/student/audio-upload"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={pending || (!text.trim() && files.length === 0)}
|
||||
disabled={pending || (!text.trim() && files.length === 0 && !audioUrl)}
|
||||
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
|
||||
style={{ opacity: pending || (!text.trim() && files.length === 0) ? 0.6 : 1 }}
|
||||
style={{ opacity: pending || (!text.trim() && files.length === 0 && !audioUrl) ? 0.6 : 1 }}
|
||||
>
|
||||
{pending ? "Отправка..." : submission ? "Обновить ответ" : "Сдать работу"}
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { addComment, deleteComment } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions";
|
||||
import { addComment, deleteComment } from "@/lib/actions/student-actions";
|
||||
|
||||
type Reply = {
|
||||
id: string;
|
||||
text: string;
|
||||
deleted: boolean;
|
||||
createdAt: Date;
|
||||
user: { id: string; name: string };
|
||||
};
|
||||
|
||||
type Comment = {
|
||||
id: string;
|
||||
@@ -9,6 +17,7 @@ type Comment = {
|
||||
deleted: boolean;
|
||||
createdAt: Date;
|
||||
user: { id: string; name: string };
|
||||
replies: Reply[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
@@ -19,8 +28,20 @@ interface Props {
|
||||
currentUserRole: string;
|
||||
}
|
||||
|
||||
function formatDate(date: Date) {
|
||||
return new Date(date).toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [replyToId, setReplyToId] = useState<string | null>(null);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -40,6 +61,19 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
||||
});
|
||||
}
|
||||
|
||||
function handleReply(parentId: string) {
|
||||
if (!replyText.trim()) return;
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await addComment(lessonId, slug, replyText.trim(), parentId);
|
||||
setReplyText("");
|
||||
setReplyToId(null);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(commentId: string) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
@@ -52,68 +86,161 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Comment list */}
|
||||
<div className="space-y-5 mb-6">
|
||||
{comments.length === 0 && (
|
||||
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Пока нет комментариев. Будьте первым!
|
||||
</p>
|
||||
)}
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="w-8 h-8 shrink-0 flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--foreground)",
|
||||
border: "2px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{comment.user.name[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-sm font-bold">{comment.user.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{new Date(comment.createdAt).toLocaleDateString("ru-RU", {
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id}>
|
||||
{/* Root comment */}
|
||||
<div className="flex gap-3">
|
||||
<div
|
||||
className="w-8 h-8 shrink-0 flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--foreground)",
|
||||
border: "2px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{comment.user.name[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
|
||||
{comment.deleted ? (
|
||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||
[Комментарий удалён]
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
||||
{comment.text}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-sm font-bold">{comment.user.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{formatDate(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!comment.deleted && (comment.user.id === currentUserId || canModerate) && (
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
disabled={isPending}
|
||||
className="text-xs mt-1 underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
{comment.deleted ? (
|
||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||
[Комментарий удалён]
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
||||
{comment.text}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 mt-1">
|
||||
{!comment.deleted && (comment.user.id === currentUserId || canModerate) && (
|
||||
<button
|
||||
onClick={() => handleDelete(comment.id)}
|
||||
disabled={isPending}
|
||||
className="text-xs underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
{!comment.deleted && canModerate && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setReplyToId(replyToId === comment.id ? null : comment.id);
|
||||
setReplyText("");
|
||||
}}
|
||||
disabled={isPending}
|
||||
className="text-xs underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
{replyToId === comment.id ? "Отмена" : "Ответить"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Inline reply form */}
|
||||
{replyToId === comment.id && (
|
||||
<div className="mt-3">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
placeholder="Напишите ответ..."
|
||||
rows={2}
|
||||
maxLength={2000}
|
||||
disabled={isPending}
|
||||
className="w-full text-sm p-3 resize-y"
|
||||
style={{
|
||||
border: "2px solid var(--border)",
|
||||
backgroundColor: "var(--background)",
|
||||
color: "var(--foreground)",
|
||||
outline: "none",
|
||||
fontFamily: "inherit",
|
||||
}}
|
||||
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<button
|
||||
onClick={() => handleReply(comment.id)}
|
||||
disabled={isPending || !replyText.trim()}
|
||||
className="btn-aubade btn-aubade-accent text-sm"
|
||||
>
|
||||
{isPending ? "Отправка..." : "Отправить ответ"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replies */}
|
||||
{comment.replies.length > 0 && (
|
||||
<div
|
||||
className="mt-3 ml-11 space-y-3 pl-4"
|
||||
style={{ borderLeft: "2px solid var(--border)" }}
|
||||
>
|
||||
{comment.replies.map((reply) => (
|
||||
<div key={reply.id} className="flex gap-3">
|
||||
<div
|
||||
className="w-7 h-7 shrink-0 flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: "var(--accent)",
|
||||
color: "var(--foreground)",
|
||||
border: "2px solid var(--border)",
|
||||
}}
|
||||
>
|
||||
{reply.user.name[0]?.toUpperCase() ?? "?"}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||
<span className="text-sm font-bold">{reply.user.name}</span>
|
||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||
{formatDate(reply.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{reply.deleted ? (
|
||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||
[Комментарий удалён]
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
||||
{reply.text}
|
||||
</p>
|
||||
)}
|
||||
{!reply.deleted && (reply.user.id === currentUserId || canModerate) && (
|
||||
<button
|
||||
onClick={() => handleDelete(reply.id)}
|
||||
disabled={isPending}
|
||||
className="text-xs mt-1 underline"
|
||||
style={{ color: "var(--muted-foreground)" }}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add comment form */}
|
||||
{/* New comment form */}
|
||||
<form onSubmit={handleAdd}>
|
||||
<textarea
|
||||
value={text}
|
||||
|
||||
@@ -2,19 +2,30 @@
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { Check } from "lucide-react";
|
||||
import { toggleLessonProgress } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/actions";
|
||||
import { toggleLessonProgress } from "@/lib/actions/student-actions";
|
||||
|
||||
export function LessonCompleteButton({
|
||||
lessonId,
|
||||
slug,
|
||||
isCompleted,
|
||||
readOnly = false,
|
||||
}: {
|
||||
lessonId: string;
|
||||
slug: string;
|
||||
isCompleted: boolean;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
if (readOnly) {
|
||||
return (
|
||||
<div className="btn-aubade btn-aubade-accent flex items-center gap-2 px-5 py-2.5 text-sm cursor-default select-none">
|
||||
<Check size={15} strokeWidth={3} />
|
||||
Пройдено
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => startTransition(() => toggleLessonProgress(lessonId, slug))}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { submitQuizAttempt } from "@/lib/actions/student-actions";
|
||||
|
||||
interface Question {
|
||||
id: string;
|
||||
text: string;
|
||||
type: "TEXT" | "SINGLE" | "MULTIPLE";
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
quiz: { id: string; questions: Question[] };
|
||||
attempt: { answers: Record<string, string> } | null;
|
||||
slug: string;
|
||||
lessonId: string;
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
border: "2px solid var(--border)",
|
||||
background: "var(--background)",
|
||||
outline: "none",
|
||||
width: "100%",
|
||||
padding: "0.5rem 0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
fontFamily: "inherit",
|
||||
resize: "vertical" as const,
|
||||
minHeight: "80px",
|
||||
};
|
||||
|
||||
export function QuizSection({ quiz, attempt, slug, lessonId }: Props) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>(
|
||||
attempt?.answers ?? {}
|
||||
);
|
||||
const [pending, startTransition] = useTransition();
|
||||
|
||||
const allFilled = quiz.questions.every((q) => answers[q.id]?.trim());
|
||||
|
||||
if (attempt) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 font-bold uppercase tracking-widest"
|
||||
style={{
|
||||
border: "2px solid var(--foreground)",
|
||||
background: "var(--accent)",
|
||||
}}
|
||||
>
|
||||
Заполнено
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{quiz.questions.map((q) => (
|
||||
<div key={q.id} className="space-y-1">
|
||||
<p className="text-sm font-medium">{q.text}</p>
|
||||
<div
|
||||
className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70"
|
||||
style={{ border: "2px solid var(--border)" }}
|
||||
>
|
||||
{attempt.answers[q.id] || "—"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{quiz.questions.map((q, idx) => (
|
||||
<div key={q.id} className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{idx + 1}. {q.text}
|
||||
</p>
|
||||
<textarea
|
||||
value={answers[q.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setAnswers((prev) => ({ ...prev, [q.id]: e.target.value }))
|
||||
}
|
||||
style={inputStyle}
|
||||
placeholder="Ваш ответ..."
|
||||
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
disabled={pending || !allFilled}
|
||||
onClick={() =>
|
||||
startTransition(() =>
|
||||
submitQuizAttempt(quiz.id, lessonId, slug, answers)
|
||||
)
|
||||
}
|
||||
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
|
||||
style={{ opacity: pending || !allFilled ? 0.6 : 1 }}
|
||||
>
|
||||
{pending ? "Отправка..." : "Отправить"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"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";
|
||||
import { sendCourseAccessEmail } from "@/lib/email";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||
return session;
|
||||
}
|
||||
|
||||
// ── Modules ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createModule(courseId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const title = formData.get("title") as string;
|
||||
const count = await prisma.module.count({ where: { courseId } });
|
||||
const mod = await prisma.module.create({ data: { courseId, title, order: count } });
|
||||
revalidatePath(`/admin/courses/${courseId}`);
|
||||
redirect(`/admin/courses/${courseId}/modules/${mod.id}`);
|
||||
}
|
||||
|
||||
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const title = formData.get("title") as string;
|
||||
const description = (formData.get("description") as string | null)?.trim() || null;
|
||||
await prisma.module.update({ where: { id: moduleId }, data: { title, description } });
|
||||
revalidatePath(`/admin/courses/${courseId}`);
|
||||
}
|
||||
|
||||
export async function deleteModule(moduleId: string, courseId: string) {
|
||||
await requireAdmin();
|
||||
await prisma.module.delete({ where: { id: moduleId } });
|
||||
revalidatePath(`/admin/courses/${courseId}`);
|
||||
}
|
||||
|
||||
export async function reorderModules(courseId: string, orderedIds: string[]) {
|
||||
await requireAdmin();
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) =>
|
||||
prisma.module.update({ where: { id }, data: { order: index } })
|
||||
)
|
||||
);
|
||||
revalidatePath(`/admin/courses/${courseId}`);
|
||||
}
|
||||
|
||||
// ── Enrollment ───────────────────────────────────────────────────────────────
|
||||
|
||||
export async function grantAccess(
|
||||
courseId: string,
|
||||
userId: string,
|
||||
expiresAt?: string | null,
|
||||
note?: string
|
||||
) {
|
||||
const session = await requireAdmin();
|
||||
await prisma.courseEnrollment.upsert({
|
||||
where: { userId_courseId: { userId, courseId } },
|
||||
update: { expiresAt: expiresAt ? new Date(expiresAt) : null },
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
export async function revokeAccess(courseId: string, userId: string, note?: 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,
|
||||
note: note || null,
|
||||
},
|
||||
});
|
||||
revalidatePath(`/admin/courses/${courseId}`);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
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 saveLesson(
|
||||
lessonId: string,
|
||||
courseId: string,
|
||||
moduleId: string,
|
||||
data: {
|
||||
title: string;
|
||||
kinescopeId: string;
|
||||
content: object;
|
||||
published: boolean;
|
||||
}
|
||||
) {
|
||||
try {
|
||||
await requireAdmin();
|
||||
} catch (e) {
|
||||
console.error("[saveLesson] auth failed:", e);
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
await prisma.lesson.update({
|
||||
where: { id: lessonId },
|
||||
data: {
|
||||
title: data.title,
|
||||
kinescopeId: data.kinescopeId || null,
|
||||
content: JSON.parse(JSON.stringify(data.content)),
|
||||
published: data.published,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("[saveLesson] db update failed:", e);
|
||||
throw e;
|
||||
}
|
||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
"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");
|
||||
}
|
||||
|
||||
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
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 lesson = await prisma.lesson.create({
|
||||
data: { moduleId, title, kinescopeId, order: count },
|
||||
});
|
||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||
redirect(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`);
|
||||
}
|
||||
|
||||
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const title = formData.get("title") as string;
|
||||
await prisma.lesson.update({ where: { id: lessonId }, data: { title } });
|
||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||
}
|
||||
|
||||
export async function deleteLesson(lessonId: string, courseId: string, moduleId: string) {
|
||||
await requireAdmin();
|
||||
await prisma.lesson.delete({ where: { id: lessonId } });
|
||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||
}
|
||||
|
||||
export async function reorderLessons(moduleId: string, courseId: string, orderedIds: string[]) {
|
||||
await requireAdmin();
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) =>
|
||||
prisma.lesson.update({ where: { id }, data: { order: index } })
|
||||
)
|
||||
);
|
||||
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();
|
||||
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}`);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"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 saveQuiz(
|
||||
lessonId: string,
|
||||
questions: { text: string; type: "TEXT" | "SINGLE" | "MULTIPLE"; order: number }[]
|
||||
) {
|
||||
await requireAdmin();
|
||||
|
||||
const existing = await prisma.quiz.findUnique({ where: { lessonId } });
|
||||
|
||||
if (existing) {
|
||||
await prisma.quizQuestion.deleteMany({ where: { quizId: existing.id } });
|
||||
await prisma.quizQuestion.createMany({
|
||||
data: questions.map((q) => ({
|
||||
quizId: existing.id,
|
||||
text: q.text,
|
||||
type: q.type,
|
||||
order: q.order,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
await prisma.quiz.create({
|
||||
data: {
|
||||
lessonId,
|
||||
questions: {
|
||||
create: questions.map((q) => ({
|
||||
text: q.text,
|
||||
type: q.type,
|
||||
order: q.order,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/admin`);
|
||||
}
|
||||
|
||||
export async function deleteQuiz(lessonId: string) {
|
||||
await requireAdmin();
|
||||
await prisma.quiz.delete({ where: { lessonId } });
|
||||
revalidatePath(`/admin`);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { headers } from "next/headers";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { sendHomeworkSubmittedEmail } from "@/lib/email";
|
||||
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
||||
|
||||
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// ── Homework ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
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;
|
||||
} else {
|
||||
const created = await prisma.homeworkSubmission.create({
|
||||
data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
|
||||
});
|
||||
submissionId = created.id;
|
||||
|
||||
const [lesson, settings] = await Promise.all([
|
||||
prisma.homework.findUnique({
|
||||
where: { id: homeworkId },
|
||||
include: { lesson: { select: { title: true } } },
|
||||
}),
|
||||
getSettings(),
|
||||
]);
|
||||
if (lesson && asBool(settings.notifyOnHomework)) {
|
||||
const configuredEmails = parseNotificationEmails(settings.notificationEmails);
|
||||
const recipients = configuredEmails.length > 0
|
||||
? configuredEmails.map((email) => ({ email, name: "" }))
|
||||
: await prisma.user.findMany({
|
||||
where: { role: { in: ["admin", "curator"] } },
|
||||
select: { email: true, name: true },
|
||||
});
|
||||
await Promise.all(
|
||||
recipients.map((r) =>
|
||||
sendHomeworkSubmittedEmail(r.email, r.name, session.user.name, lesson.lesson.title, submissionId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||
}
|
||||
|
||||
// ── Quiz ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function submitQuizAttempt(
|
||||
quizId: string,
|
||||
lessonId: string,
|
||||
slug: string,
|
||||
answers: Record<string, string>
|
||||
) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) throw new Error("Unauthorized");
|
||||
|
||||
const existing = await prisma.quizAttempt.findFirst({
|
||||
where: { quizId, userId: session.user.id },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.quizAttempt.create({
|
||||
data: {
|
||||
userId: session.user.id,
|
||||
quizId,
|
||||
score: 0,
|
||||
answers: answers as object,
|
||||
},
|
||||
});
|
||||
await tx.lessonProgress.upsert({
|
||||
where: { userId_lessonId: { userId: session.user.id, lessonId } },
|
||||
create: { userId: session.user.id, lessonId },
|
||||
update: {},
|
||||
});
|
||||
});
|
||||
|
||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||
revalidatePath(`/courses/${slug}`);
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
// ── Comments ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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");
|
||||
|
||||
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,59 @@
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
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 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 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}`);
|
||||
}
|
||||
+4
-1
@@ -3,7 +3,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
|
||||
import { admin } from "better-auth/plugins";
|
||||
import { prisma } from "./prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { sendWelcomeEmail } from "./email";
|
||||
import { sendWelcomeEmail, sendPasswordResetEmail } from "./email";
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, {
|
||||
@@ -16,6 +16,9 @@ export const auth = betterAuth({
|
||||
hash: (password) => bcrypt.hash(password, 10),
|
||||
verify: ({ hash, password }) => bcrypt.compare(password, hash),
|
||||
},
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
await sendPasswordResetEmail(user.email, user.name, url);
|
||||
},
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
|
||||
@@ -189,3 +189,19 @@ export async function sendTestEmail(to: string) {
|
||||
`, school),
|
||||
}).catch((e) => console.error("[email] sendTestEmail:", e));
|
||||
}
|
||||
|
||||
export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) {
|
||||
const school = await getSchoolName();
|
||||
await getResend().emails.send({
|
||||
from: FROM,
|
||||
to,
|
||||
subject: `Сброс пароля — ${school}`,
|
||||
html: base(`
|
||||
<p ${p}>Привет, ${name}!</p>
|
||||
<p ${p}>Вы запросили сброс пароля для вашего аккаунта на платформе <strong>${school}</strong>.</p>
|
||||
<p ${p}>Нажмите на кнопку ниже чтобы задать новый пароль. Ссылка действительна <strong>1 час</strong>.</p>
|
||||
<p ${pLast}>Если вы не запрашивали сброс — просто проигнорируйте это письмо.</p>
|
||||
${btn(resetUrl, "Задать новый пароль")}
|
||||
`, school),
|
||||
}).catch((e) => console.error("[email] sendPasswordResetEmail:", e));
|
||||
}
|
||||
|
||||
+12
-2
@@ -1,3 +1,4 @@
|
||||
import { cache } from "react";
|
||||
import { prisma } from "./prisma";
|
||||
|
||||
// ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
@@ -33,6 +34,15 @@ export const SETTINGS_DEFAULTS = {
|
||||
curatorCanAnswerQuestions: "true",
|
||||
curatorCanSeeStudents: "true",
|
||||
|
||||
// Logo
|
||||
logoUrl: "",
|
||||
showLogo: "true",
|
||||
|
||||
// Social networks
|
||||
socialYoutube: "",
|
||||
socialVk: "",
|
||||
socialTelegram: "",
|
||||
|
||||
// Code injection
|
||||
headCode: "",
|
||||
bodyCode: "",
|
||||
@@ -43,7 +53,7 @@ export type Settings = Record<SettingsKey, string>;
|
||||
|
||||
// ── Getters ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSettings(): Promise<Settings> {
|
||||
export const getSettings = cache(async (): Promise<Settings> => {
|
||||
try {
|
||||
const rows = await prisma.settings.findMany();
|
||||
const stored: Record<string, string> = {};
|
||||
@@ -55,7 +65,7 @@ export async function getSettings(): Promise<Settings> {
|
||||
// DB unavailable at build time — return defaults
|
||||
return { ...SETTINGS_DEFAULTS } as Settings;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function getSetting(key: SettingsKey): Promise<string> {
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getSessionCookie } from "better-auth/cookies";
|
||||
|
||||
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/api/auth", "/maintenance"];
|
||||
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/maintenance"];
|
||||
|
||||
export function proxy(request: NextRequest) {
|
||||
export function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (
|
||||
Reference in New Issue
Block a user