Initialize Stage 0: Next.js 16 scaffold with auth and role-based routing

- Next.js 16.2.2 + React 19 + TypeScript + Tailwind v4
- Better Auth with email/password and role system (student/curator/admin)
- Prisma 7 schema: User, Session, Account, Verification + full LMS model
- Role-based dashboards: student /dashboard, curator /curator/dashboard, admin /admin/dashboard
- Auth pages: login, register, verify-email
- Better Auth API route handler
- Middleware for route protection
- Docker Compose with PostgreSQL 16
- Seed script with test users (admin/curator/student)
- CLAUDE.md and ROADMAP.md project documentation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 10:32:37 +05:00
commit 80ca4b2d9d
41 changed files with 10138 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
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@school.second-brain.ru"
S3_ENDPOINT="https://fsn1.your-objectstorage.com"
S3_BUCKET="lms-uploads"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
S3_REGION="eu-central"
# Kinescope (добавить при получении платного плана)
# KINESCOPE_API_KEY=""
+47
View File
@@ -0,0 +1,47 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files — секреты не коммитить
.env
.env.local
.env.production
# .env.example коммитим — это шаблон без секретов
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+213
View File
@@ -0,0 +1,213 @@
# CLAUDE.md — LMS Second Brain
> Читай AGENTS.md перед тем как писать любой Next.js код — версия отличается от обучающих данных.
## Стек и версии
| Технология | Версия | Назначение |
|---|---|---|
| Next.js | 16.2.2 (App Router) | Full-stack фреймворк |
| Node.js | 20 LTS | Runtime |
| TypeScript | 5.x | Язык |
| React | 19 | UI |
| PostgreSQL | 16 | База данных |
| Prisma | 6.x | ORM + миграции |
| Better Auth | latest | Аутентификация и сессии |
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
| shadcn/ui | latest | UI-компоненты |
| TipTap | 2.x | WYSIWYG-редактор уроков |
| @kinescope/react-kinescope-player | latest | Видеоплеер |
| Resend | latest | Email-уведомления |
| AWS SDK (S3) | 3.x | Hetzner Object Storage |
| Zod | 3.x | Валидация данных |
| Docker Compose | 2.x | Локальная разработка и деплой |
**Важно по Tailwind v4:** конфиг — только в CSS через `@import "tailwindcss"` и `@theme`. Нет `tailwind.config.ts`. Кастомизация — через CSS переменные в `globals.css`.
**Важно по Better Auth:** не NextAuth. Сессии — cookie-based. Роли через плагин `admin`. Подробнее: https://www.better-auth.com/docs
---
## Структура каталогов
```
lms-system/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Вход, регистрация, подтверждение email
│ │ │ ├── login/
│ │ │ ├── register/
│ │ │ └── verify-email/
│ │ ├── (student)/ # Личный кабинет ученика
│ │ │ ├── dashboard/
│ │ │ ├── courses/[slug]/
│ │ │ └── courses/[slug]/lessons/[lessonId]/
│ │ ├── curator/ # Панель куратора
│ │ │ ├── dashboard/
│ │ │ └── homework/
│ │ ├── admin/ # Панель администратора
│ │ │ ├── courses/
│ │ │ ├── users/
│ │ │ └── settings/
│ │ └── api/ # API-маршруты
│ │ ├── auth/ # Better Auth handler
│ │ ├── courses/
│ │ ├── lessons/
│ │ ├── progress/
│ │ ├── homework/
│ │ └── upload/
│ ├── 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 client
│ │ ├── s3.ts # Hetzner Object Storage клиент
│ │ ├── email.ts # Resend email helpers
│ │ └── utils.ts # cn() и прочие утилиты
│ ├── types/
│ │ └── index.ts # Общие TypeScript-типы
│ └── middleware.ts # Auth middleware (защита маршрутов)
├── prisma/
│ ├── schema.prisma # Схема БД
│ ├── seed.ts # Seed-скрипт
│ └── migrations/ # НИКОГДА не редактировать вручную
├── public/
│ └── images/
├── docker-compose.yml # Локальная разработка
├── docker-compose.prod.yml # Production
├── Dockerfile
├── .env.example # Шаблон переменных (без секретов)
├── .env.local # Локальные секреты (в .gitignore)
├── CLAUDE.md
├── ROADMAP.md
└── PROJECT_BRIEF.md
```
---
## Команды
```bash
# Разработка
npm run dev # Запустить dev-сервер (localhost:3000)
docker compose up -d # Поднять PostgreSQL локально
# Сборка и проверка
npm run build # Production build
npm run lint # ESLint
npm run type-check # TypeScript без сборки (tsc --noEmit)
# База данных (Prisma)
npx prisma migrate dev --name <название> # Создать и применить миграцию
npx prisma migrate deploy # Применить миграции в production
npx prisma studio # GUI для просмотра БД
npx prisma generate # Пересоздать клиент после изменений schema
npx prisma db seed # Запустить seed-скрипт
# Docker (production)
docker compose -f docker-compose.prod.yml up -d --build
docker compose -f docker-compose.prod.yml logs -f app
```
---
## Правила работы
### Деплой и окружение
- Production-домен: **school.second-brain.ru**
- Git-сервер: **Gitea — https://git.second-brain.ru/admins/lms-sb**
- **После завершения каждого этапа из ROADMAP.md** — делать `git push` в Gitea
### Коммиты
- Один коммит = одна логически завершённая единица работы (маршрут, компонент, миграция)
- Сообщения коммитов — на английском, в повелительном наклонении: `Add lesson progress tracking`, `Fix auth redirect`
- Перед коммитом всегда запускать `npm run lint && npm run type-check`
### Миграции базы данных
- **НИКОГДА** не редактировать файлы в `prisma/migrations/` вручную
- **ВСЕГДА** спрашивать пользователя перед созданием миграции, меняющей или удаляющей существующие поля
- Называть миграции по-английски, snake_case: `add_lesson_progress`, `add_user_roles`
- Перед `prisma migrate deploy` на production — делать бэкап БД
### Файлы и контент
- Загружаемые файлы (ДЗ, PDF) — только через Hetzner Object Storage, никогда на диск VPS
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
### Код
- UI-строки (заголовки, кнопки, сообщения) — на **русском**
- Имена переменных, функций, файлов, комментарии в коде — на **английском**
- Компоненты shadcn/ui — добавлять через `npx shadcn@latest add <component>`, не копировать вручную
- Не добавлять абстракции "на будущее" — только то, что нужно для текущего этапа
- Server Actions — использовать для форм и мутаций (auth, прогресс, ДЗ)
### Маршруты и роли
- Защита маршрутов — через `middleware.ts` (Better Auth), не в каждом компоненте отдельно
- Роли: `student`, `curator`, `admin` — проверять через `auth()` или `authClient.useSession()`
- Admin-маршруты (`/admin/*`) доступны только роли `admin`
- Curator-маршруты (`/curator/*`) доступны ролям `curator` и `admin`
---
## Переменные окружения (`.env.example`)
```env
# База данных
DATABASE_URL="postgresql://lms_user:password@localhost:5432/lms_db"
# Better Auth
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
BETTER_AUTH_URL="http://localhost:3000"
# Email (Resend)
RESEND_API_KEY=""
EMAIL_FROM="noreply@school.second-brain.ru"
# Hetzner Object Storage (S3-совместимый)
S3_ENDPOINT="https://fsn1.your-objectstorage.com"
S3_BUCKET="lms-uploads"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
S3_REGION="eu-central"
# Kinescope (добавить при получении платного плана)
# KINESCOPE_API_KEY=""
```
---
## Чек-лист перед каждым коммитом
- [ ] `npm run lint` — нет ошибок ESLint
- [ ] `npm run type-check` — нет ошибок TypeScript
- [ ] Новые `.env` переменные добавлены в `.env.example` (без значений)
- [ ] Миграция БД одобрена пользователем (если есть)
- [ ] Нет `console.log` в production-коде (только `console.error` для реальных ошибок)
- [ ] Нет захардкоженных секретов, URL-ов, ID
---
## Модель данных (основные сущности)
```
User (id, email, name, role, emailVerified) — управляется Better Auth
Session (id, userId, expiresAt, ...) — управляется Better Auth
Course (id, slug, title, description, coverImage, published)
Module (id, courseId, title, order)
Lesson (id, moduleId, title, order, content, kinescopeId, published)
CourseEnrollment (userId, courseId, enrolledAt)
LessonProgress (userId, lessonId, completedAt)
Quiz (id, lessonId)
QuizQuestion (id, quizId, text, type)
QuizOption (id, questionId, text, isCorrect)
QuizAttempt (id, userId, quizId, score, completedAt)
Homework (id, lessonId, description)
HomeworkSubmission (id, homeworkId, userId, text, files[], submittedAt)
HomeworkFeedback (id, submissionId, curatorId, text, createdAt)
LessonComment (id, lessonId, userId, text, createdAt)
```
+165
View File
@@ -0,0 +1,165 @@
# LMS-проект: стартовый бриф для Claude Code
> Этот документ — отправная точка проекта. Положи его в корень репозитория как `PROJECT_BRIEF.md`. В первой сессии Claude Code попроси: **«Прочитай PROJECT_BRIEF.md, задай мне уточняющие вопросы по архитектуре, затем предложи план работ по этапам и создай CLAUDE.md с ключевыми правилами проекта»**.
---
## 1. Контекст и цель
Я — автор образовательных курсов (PKM, Obsidian). Сейчас размещаюсь на сторонней LMS-платформе ([emdesell.ru](https://docs.emdesell.ru/)), хочу переехать на собственное решение, которое буду развивать сам с помощью Claude Code.
**Цель:** собственная LMS, развёрнутая на моём сервере в Hetzner, с бесшовной интеграцией Kinescope, ролями, прогрессом и проверкой ДЗ.
**Масштаб:** ~1000 учётных записей, ~200 активных, до 10 курсов одновременно. Это камерный проект, не маркетплейс — простота и поддерживаемость важнее «масштабируемости на миллионы».
**Мой технический уровень:** уверенный пользователь — git, терминал, деплой знаю; код самостоятельно не пишу. Значит: стек должен быть популярным (много примеров), архитектура — простой и читаемой, шаги Claude Code — небольшими и обратимыми.
---
## 2. Роли пользователей
- **Ученик** — регистрируется сам или добавляется админом, проходит курсы, сдаёт ДЗ, оставляет комментарии.
- **Куратор** — проверяет ДЗ, оставляет обратную связь, видит прогресс группы.
- **Админ (я)** — всё вышеперечисленное + создание/редактирование курсов, управление пользователями, аналитика, импорт/экспорт.
---
## 3. Функционал MVP (обязательно)
### 3.1. Курсы и контент
- Курс → модули → уроки. До 10 курсов.
- Типы контента в уроке: **видео Kinescope** (через iframe-embed с защитой), **форматированный текст**, **PDF/файлы для скачивания**, **картинки**.
- Редактор уроков в админке: WYSIWYG с базовым форматированием (заголовки, списки, цитаты, код, ссылки, картинки).
- **Импорт урока из `.md`-файла** — желательно (frontmatter → метаданные урока, тело → контент). Это критично, потому что я работаю в Obsidian.
- Водяные знаки на загружаемых картинках и PDF — если просто, добавляем; иначе в бэклог.
### 3.2. Kinescope-интеграция
- Хранение видео — только Kinescope.
- Бесшовная вставка: в админке вставляю ID видео или ссылку, во фронте отрисовывается плеер.
- Защита от скачивания обеспечивается самим Kinescope (signed URLs / referrer-проверка).
- На этапе планирования: **Claude Code должен изучить актуальное API Kinescope** и предложить, как удобнее всего его подключить.
### 3.3. Прохождение и прогресс
- Линейное прохождение: следующий урок открывается только после завершения предыдущего.
- Урок засчитывается как пройденный по совокупности: просмотр видео + (если есть) сданный тест + (если есть) отправленное ДЗ.
- ДЗ принимается **автоматически** при отправке — ученик сразу идёт дальше. Куратор оставляет обратную связь асинхронно, она прилетает ученику уведомлением и появляется в карточке урока.
- Прогресс-бар по курсу и по модулю в личном кабинете.
### 3.4. Тесты и квизы
- Типы вопросов: одиночный выбор, множественный выбор, короткий текстовый ответ.
- Авто-проверка, показ правильных ответов после прохождения (настраивается).
### 3.5. Домашние задания
- Формат отправки: текст + прикреплённые файлы.
- Комментарии куратора к ДЗ — обязательны.
- История переписки по конкретному ДЗ.
### 3.6. Комментарии под уроками
- Тред обсуждения у каждого урока. Видны всем ученикам этого курса. Модерация — куратор/админ может удалять.
### 3.7. Уведомления (email)
- Учеников: регистрация, новый комментарий куратора к ДЗ, ответ в обсуждении урока.
- Админа/куратора: новое ДЗ на проверку, новый комментарий в обсуждении, регистрация нового ученика.
- Telegram-бот для уведомлений админа/куратора (новое ДЗ, ошибки) — добавить во вторую итерацию MVP.
### 3.8. Регистрация и доступы
- Самостоятельная регистрация по email + подтверждение.
- Админ может вручную создать аккаунт и выдать доступ к курсу.
- Доступ к курсу — на уровне «есть/нет» (продажи на платформе не делаем).
### 3.9. Миграция с emdesell
- **Перенос базы пользователей** (email, имя, прогресс если возможно) — критично.
- **Перенос контента курсов** (тексты уроков, ссылки на видео Kinescope, файлы) — критично.
- На этапе планирования: Claude Code должен изучить документацию emdesell и предложить стратегию (API / экспорт CSV / парсинг HTML).
---
## 4. В бэклог (после MVP)
- Геймификация (баллы, бейджи, рейтинги).
- Сертификаты по окончании курса.
- Промокоды, рассрочки, продажи через платформу (место в архитектуре заложить, реализацию — потом).
- Дедлайны и расписания.
- Мобильное приложение.
---
## 5. Технические требования и предпочтения
- **Хостинг:** собственный сервер в Hetzner.
- **Локальная разработка:** возможна, кроме видео (Kinescope доступен только онлайн).
- **Дизайн:** тёплый и дружелюбный, подходящий для образования. Не корпоративный, не «тёмный энтерпрайз». Качественная типографика, читабельность важнее «вау-эффектов».
- **Язык интерфейса:** русский (i18n не нужен).
- **Стек:** не зафиксирован — Claude Code должен предложить **на выбор 2–3 варианта** с обоснованием. Критерии выбора:
1. Простота поддержки в одиночку.
2. Большое сообщество и много примеров (чтобы Claude Code хорошо его знал).
3. Удобный деплой на VPS (Docker Compose как базовый сценарий).
4. Хороший Markdown-рендеринг и возможность подключить TipTap/аналог для WYSIWYG.
Мои ожидания (но я открыт к альтернативам): что-то вроде **Next.js + PostgreSQL + Prisma + NextAuth + Tailwind**, либо **Laravel + Inertia + Vue**, либо **Django + HTMX**.
---
## 6. Интеграции
- **Kinescope** — обязательно, MVP.
- **SMTP / email-сервис** (Postmark, Resend, или свой SMTP) — обязательно, MVP.
- **Telegram-бот** — для уведомлений, вторая итерация MVP.
- **Аналитика** — Yandex.Metrika и/или Plausible, в конце MVP.
- **Платежи** — место в архитектуре заложить, реализацию отложить.
---
## 7. Что НЕ нужно делать
- Продажи, корзина, промокоды, рассрочки.
- Многоязычность.
- Мобильное приложение.
- Сложные ролевые иерархии помимо трёх ролей.
- Дедлайны и жёсткие расписания.
---
## 8. Как я хочу работать с Claude Code
- Маленькими шагами, с git-коммитами после каждого осмысленного куска.
- В начале каждой большой фичи — короткий план в чате, потом реализация.
- README и комментарии в коде — на русском или английском, но **последовательно**.
- Перед любой миграцией БД или удалением файлов — спросить меня.
- Все секреты — только в `.env`, никаких токенов в коде.
---
## 9. Первые шаги для Claude Code (план первой сессии)
1. **Изучить документацию Kinescope API** (особенно: вставка плеера, защита от скачивания, получение метаданных видео).
2. **Изучить документацию emdesell** ([https://docs.emdesell.ru/](https://docs.emdesell.ru/)) — есть ли API для экспорта пользователей и контента; если нет — предложить альтернативы.
3. **Предложить 2–3 варианта стека** с плюсами/минусами под мои критерии.
4. После выбора стека вместе со мной — **создать `CLAUDE.md`** в корне проекта со следующими разделами:
- Стек и версии.
- Структура каталогов.
- Команды (dev, build, test, lint, db migrate).
- Правила: маленькие коммиты, не трогать миграции без подтверждения, секреты только в env, русские названия только в UI-строках.
- Чек-лист перед коммитом.
5. **Инициализировать репозиторий**, базовый каркас, Docker Compose с приложением + PostgreSQL, минимальную страницу «hello world» с auth.
6. **Создать `ROADMAP.md`** с разбивкой на этапы:
- Этап 0: каркас, auth, роли.
- Этап 1: курсы → модули → уроки (CRUD в админке).
- Этап 2: интеграция Kinescope, рендер уроков для ученика.
- Этап 3: прогресс и линейное открытие уроков.
- Этап 4: тесты и квизы.
- Этап 5: ДЗ и комментарии куратора.
- Этап 6: обсуждения под уроками.
- Этап 7: email-уведомления.
- Этап 8: импорт `.md`, водяные знаки.
- Этап 9: миграция с emdesell.
- Этап 10: Telegram-бот, аналитика.
- Этап 11: деплой на Hetzner.
---
## 10. Критерий «MVP готов»
Я могу: создать курс из админки, добавить модули и уроки с видео Kinescope и текстами, импортировать ученика из старой LMS, дать ему доступ, он зарегистрируется, пройдёт первый урок, сдаст тест, отправит ДЗ, получит автоматический допуск к следующему уроку, а позже — комментарий куратора по почте.
Когда этот сценарий работает end-to-end на production-сервере в Hetzner — MVP считается готовым.
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+197
View File
@@ -0,0 +1,197 @@
# ROADMAP — LMS Second Brain
**Стек:** Next.js 14 · PostgreSQL · Prisma · NextAuth v5 · Tailwind · shadcn/ui · TipTap · Kinescope · Resend · Hetzner Object Storage
**Принцип:** один этап — одна рабочая фича. Не переходим к следующему, пока текущий не работает end-to-end.
---
## Этап 0 — Каркас, auth, роли
**Цель:** запущен локально, можно войти в систему с тремя ролями.
- [ ] Инициализация Next.js 14 (App Router, TypeScript, Tailwind)
- [ ] Docker Compose: PostgreSQL 16 + Redis (для сессий)
- [ ] Prisma: подключение, начальная схема (User + роли)
- [ ] NextAuth v5: вход по email/password, хранение сессии
- [ ] Middleware: защита маршрутов по роли (STUDENT / CURATOR / ADMIN)
- [ ] Базовые layout-компоненты: Header, Sidebar, страница-заглушка для каждой роли
- [ ] Страница регистрации с подтверждением email (Resend)
- [ ] Страница входа, выхода, «забыл пароль»
- [ ] Seed-скрипт: создать тестового админа, куратора и ученика
- [ ] `.env.example` заполнен, README с инструкцией запуска
**Критерий готовности:** открываю localhost:3000, регистрируюсь, подтверждаю email, вхожу — вижу дашборд своей роли.
---
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке)
**Цель:** могу создать полную структуру курса из браузера.
- [ ] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
- [ ] Admin: список курсов, создать / редактировать / удалить курс
- [ ] Admin: список модулей внутри курса, drag-and-drop сортировка
- [ ] Admin: список уроков внутри модуля, drag-and-drop сортировка
- [ ] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
- [ ] Загрузка картинок в уроке → Hetzner Object Storage
- [ ] Поле для Kinescope ID в уроке (просто текстовое, без интеграции — это Этап 2)
- [ ] Публикация/скрытие курса и урока (черновик / опубликован)
- [ ] Управление доступом: выдать / забрать доступ к курсу для пользователя
**Критерий готовности:** создаю курс «Obsidian PKM», добавляю 2 модуля, в каждом по 3 урока с текстом и картинкой, публикую.
---
## Этап 2 — Интеграция Kinescope, рендер уроков для ученика
**Цель:** ученик видит урок с видео Kinescope и текстом.
- [ ] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
- [ ] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
- [ ] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
- [ ] Страница курса для ученика: список модулей и уроков, статус прохождения
- [ ] Навигация по урокам: предыдущий / следующий
- [ ] Блокировка доступа к курсу без enrollment (middleware или server component)
- [ ] Страница «Мои курсы» в личном кабинете ученика
**Кинескоп-нюанс:** пока без DRM (бесплатный план), просто передаём `videoId` в компонент. Когда появится платный план — добавим signed URLs в отдельной задаче.
**Критерий готовности:** ученик открывает урок, смотрит видео из Kinescope, читает текст, скачивает PDF.
---
## Этап 3 — Прогресс и линейное открытие уроков
**Цель:** ученик проходит курс последовательно, прогресс сохраняется.
- [ ] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
- [ ] Кнопка «Урок завершён» — создаёт запись в LessonProgress
- [ ] Логика блокировки: следующий урок открыт только если предыдущий завершён
- [ ] Прогресс-бар по курсу (% завершённых уроков)
- [ ] Прогресс-бар по модулю
- [ ] API: `POST /api/progress/complete` — принимает lessonId, создаёт прогресс
- [ ] Иконки статуса уроков в боковой панели: ✓ пройден / 🔒 заблокирован / → текущий
**Примечание:** если в уроке есть тест (Этап 4) или ДЗ (Этап 5) — кнопка «Завершить» появляется только после их прохождения/отправки. Эту логику дорабатываем на соответствующих этапах.
**Критерий готовности:** прохожу урок 1, нажимаю «Завершён» — открывается урок 2. Урок 3 заблокирован. Прогресс-бар показывает 33%.
---
## Этап 4 — Тесты и квизы
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt
- [ ] Admin: создание теста к уроку (добавить вопрос → варианты ответов → отметить правильный)
- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст
- [ ] Рендер теста в уроке для ученика
- [ ] Авто-проверка (single/multiple choice), результат сразу
- [ ] Настройка: показывать правильные ответы после прохождения (да/нет)
- [ ] Интеграция с прогрессом: урок с тестом засчитан только после прохождения теста
**Критерий готовности:** добавляю тест из 3 вопросов к уроку, ученик проходит, видит результат, урок засчитывается.
---
## Этап 5 — Домашние задания и обратная связь куратора
**Цель:** ученик сдаёт ДЗ, куратор оставляет комментарий.
- [ ] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
- [ ] Admin: добавить блок ДЗ к уроку (текст задания)
- [ ] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
- [ ] Ученик: ДЗ засчитывается **автоматически при отправке** (урок открывается сразу)
- [ ] Куратор: список ДЗ на проверку (все или по курсу), статус «новое / просмотрено»
- [ ] Куратор: просмотр ДЗ, оставить текстовый комментарий
- [ ] История обмена по ДЗ (ученик может ответить на комментарий куратора)
- [ ] Уведомление ученику когда куратор оставил комментарий (заглушка — реальные email на Этапе 7)
**Критерий готовности:** отправляю ДЗ — урок открывается. Куратор заходит в панель, видит ДЗ, оставляет комментарий. Ученик видит комментарий в карточке урока.
---
## Этап 6 — Обсуждения под уроками
**Цель:** ученики могут общаться под каждым уроком.
- [ ] Prisma: LessonComment (с поддержкой вложенных ответов — опционально)
- [ ] Рендер треда комментариев под уроком
- [ ] Форма отправки комментария (только для enrolled учеников)
- [ ] Модерация: куратор/админ может удалить комментарий
- [ ] Пагинация или infinite scroll для длинных тредов
**Критерий готовности:** ученик оставляет комментарий, другой ученик его видит, куратор может удалить.
---
## Этап 7 — Email-уведомления
**Цель:** все участники получают нужные письма через Resend.
- [ ] Базовый email-шаблон (HTML, фирменный стиль)
- [ ] Ученик: подтверждение регистрации (уже частично с Этапа 0, финализировать)
- [ ] Ученик: письмо когда куратор оставил комментарий к ДЗ
- [ ] Ученик: письмо когда ответили на его комментарий в уроке
- [ ] Куратор / Админ: новое ДЗ на проверку
- [ ] Куратор / Админ: новый комментарий в обсуждении
- [ ] Админ: зарегистрирован новый ученик
- [ ] Очередь отправки (edge case: Resend временно недоступен → retry)
**Критерий готовности:** отправляю ДЗ — куратор получает email. Куратор отвечает — ученик получает email.
---
## Этап 8 — Импорт уроков из Markdown
**Цель:** могу импортировать урок из .md-файла Obsidian одним действием.
- [ ] API: `POST /api/admin/lessons/import-md` — принимает .md-файл
- [ ] Парсинг frontmatter (title, order, kinescopeId и кастомные поля) → метаданные урока
- [ ] Конвертация Markdown-тела в TipTap JSON (через remark / rehype)
- [ ] UI в админке: кнопка «Импортировать из .md» на странице урока
- [ ] Обработка картинок в Markdown (локальные пути → Object Storage)
**Критерий готовности:** беру .md-файл из Obsidian с frontmatter и текстом → импортирую → урок создан с правильными метаданными и контентом.
---
## Этап 9 — Миграция с emdesell
**Цель:** все пользователи и контент перенесены в новую LMS.
- [ ] Скрипт импорта пользователей из CSV-экспорта emdesell (email, имя, курсы)
- [ ] Создание пользователей без пароля + письмо «установите пароль»
- [ ] Назначение доступов к курсам по данным из CSV
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
- [ ] QA: проверить 10 случайных аккаунтов после импорта
**Критерий готовности:** все ученики из emdesell могут войти в новую LMS и продолжить обучение.
---
## Этап 10 — Telegram-бот и аналитика
**Цель:** получаю уведомления в Telegram, вижу базовую аналитику.
- [ ] Telegram-бот: уведомление куратору/админу о новом ДЗ
- [ ] Telegram-бот: уведомление об ошибках (500-е, failed email и т.д.)
- [ ] Yandex.Metrika: базовое подключение (pageviews)
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
---
## Этап 11 — Деплой на Hetzner
**Цель:** LMS работает на production-сервере по своему домену с SSL.
- [ ] `docker-compose.prod.yml`: app + PostgreSQL + Redis + Nginx
- [ ] Nginx: SSL через Let's Encrypt (certbot), reverse proxy на Next.js
- [ ] GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
- [ ] Резервное копирование PostgreSQL (cron → Object Storage)
- [ ] Мониторинг uptime (UptimeRobot или аналог)
- [ ] `.env` на сервере через Hetzner Secrets Manager или vault-файл вне репозитория
- [ ] Smoke-тест: регистрация → урок → ДЗ → куратор → email
**Критерий MVP готов:** создаю курс из админки, добавляю уроки с Kinescope, импортирую ученика из emdesell, даю доступ — ученик регистрируется, проходит урок, сдаёт тест, отправляет ДЗ, получает автодоступ к следующему уроку, позже — комментарий куратора на email.
---
## Бэклог (после MVP)
- Сертификаты по окончании курса
- Геймификация (баллы, бейджи, рейтинги)
- Промокоды и интеграция с платёжными системами
- Дедлайны и расписания
- Kinescope DRM (signed URLs) — при переходе на платный план
- Водяные знаки на PDF и картинках
- Мобильное приложение
+15
View File
@@ -0,0 +1,15 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: lms_user
POSTGRES_PASSWORD: lms_password
POSTGRES_DB: lms_db
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
volumes:
postgres_data:
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+8402
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "lms-sb",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"type-check": "tsc --noEmit"
},
"prisma": {
"seed": "ts-node --project tsconfig.json prisma/seed.ts"
},
"dependencies": {
"@prisma/client": "^7.6.0",
"bcryptjs": "^3.0.3",
"better-auth": "^1.6.0",
"clsx": "^2.1.1",
"next": "16.2.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"resend": "^6.10.0",
"tailwind-merge": "^3.5.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"dotenv": "^17.4.1",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"prisma": "^7.6.0",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+14
View File
@@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});
+266
View File
@@ -0,0 +1,266 @@
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
datasource db {
provider = "postgresql"
}
// ─────────────────────────────────────────────
// Better Auth core tables
// ─────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String
email String @unique
emailVerified Boolean @default(false)
image String?
role String @default("student") // student | curator | admin
banned Boolean? @default(false)
banReason String?
banExpires DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
enrollments CourseEnrollment[]
progress LessonProgress[]
submissions HomeworkSubmission[]
comments LessonComment[]
feedbacks HomeworkFeedback[]
}
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
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Account {
id String @id @default(cuid())
userId String
accountId String
providerId String
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model Verification {
id String @id @default(cuid())
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// ─────────────────────────────────────────────
// LMS core tables
// ─────────────────────────────────────────────
model Course {
id String @id @default(cuid())
slug String @unique
title String
description String?
coverImage String?
published Boolean @default(false)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
modules Module[]
enrollments CourseEnrollment[]
}
model Module {
id String @id @default(cuid())
courseId String
title String
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lessons Lesson[]
}
model Lesson {
id String @id @default(cuid())
moduleId String
title String
content Json?
kinescopeId String?
order Int @default(0)
published Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
module Module @relation(fields: [moduleId], references: [id], onDelete: Cascade)
progress LessonProgress[]
quiz Quiz?
homework Homework?
comments LessonComment[]
files LessonFile[]
}
model LessonFile {
id String @id @default(cuid())
lessonId String
name String
url String
size Int
createdAt DateTime @default(now())
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
}
model CourseEnrollment {
userId String
courseId String
enrolledAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@id([userId, courseId])
}
model LessonProgress {
userId String
lessonId String
completedAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
@@id([userId, lessonId])
}
// ─────────────────────────────────────────────
// Quizzes
// ─────────────────────────────────────────────
model Quiz {
id String @id @default(cuid())
lessonId String @unique
showAnswers Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
questions QuizQuestion[]
attempts QuizAttempt[]
}
model QuizQuestion {
id String @id @default(cuid())
quizId String
text String
type QuizQuestionType
order Int @default(0)
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
options QuizOption[]
}
model QuizOption {
id String @id @default(cuid())
questionId String
text String
isCorrect Boolean @default(false)
order Int @default(0)
question QuizQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
}
model QuizAttempt {
id String @id @default(cuid())
userId String
quizId String
score Int
answers Json
completedAt DateTime @default(now())
quiz Quiz @relation(fields: [quizId], references: [id], onDelete: Cascade)
}
enum QuizQuestionType {
SINGLE
MULTIPLE
TEXT
}
// ─────────────────────────────────────────────
// Homework
// ─────────────────────────────────────────────
model Homework {
id String @id @default(cuid())
lessonId String @unique
description String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
submissions HomeworkSubmission[]
}
model HomeworkSubmission {
id String @id @default(cuid())
homeworkId String
userId String
text String?
files Json?
submittedAt DateTime @default(now())
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
feedbacks HomeworkFeedback[]
}
model HomeworkFeedback {
id String @id @default(cuid())
submissionId String
curatorId String
text String
createdAt DateTime @default(now())
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
curator User @relation(fields: [curatorId], references: [id], onDelete: Cascade)
}
// ─────────────────────────────────────────────
// Comments
// ─────────────────────────────────────────────
model LessonComment {
id String @id @default(cuid())
lessonId String
userId String
text String
deleted Boolean @default(false)
createdAt DateTime @default(now())
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
+79
View File
@@ -0,0 +1,79 @@
import "dotenv/config";
import { PrismaClient } from "../src/generated/prisma";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
async function main() {
console.log("Seeding database...");
const hashedPassword = await bcrypt.hash("Password123!", 10);
// Admin
const admin = await prisma.user.upsert({
where: { email: "admin@second-brain.ru" },
update: {},
create: {
email: "admin@second-brain.ru",
name: "Администратор",
emailVerified: true,
role: "admin",
accounts: {
create: {
accountId: "admin@second-brain.ru",
providerId: "credential",
password: hashedPassword,
},
},
},
});
// Curator
const curator = await prisma.user.upsert({
where: { email: "curator@second-brain.ru" },
update: {},
create: {
email: "curator@second-brain.ru",
name: "Куратор",
emailVerified: true,
role: "curator",
accounts: {
create: {
accountId: "curator@second-brain.ru",
providerId: "credential",
password: hashedPassword,
},
},
},
});
// Student
const student = await prisma.user.upsert({
where: { email: "student@second-brain.ru" },
update: {},
create: {
email: "student@second-brain.ru",
name: "Ученик",
emailVerified: true,
role: "student",
accounts: {
create: {
accountId: "student@second-brain.ru",
providerId: "credential",
password: hashedPassword,
},
},
},
});
console.log("Created users:");
console.log(` Admin: ${admin.email}`);
console.log(` Curator: ${curator.email}`);
console.log(` Student: ${student.email}`);
console.log(" Password for all: Password123!");
console.log("Done.");
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signIn } from "@/lib/auth-client";
export function LoginForm() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
const result = await signIn.email({ email, password });
if (result.error) {
setError("Неверный email или пароль");
setLoading(false);
return;
}
router.push("/dashboard");
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder="••••••••"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? "Вход..." : "Войти"}
</button>
<p className="text-center text-sm text-gray-500">
Нет аккаунта?{" "}
<Link href="/register" className="text-amber-600 hover:underline">
Зарегистрироваться
</Link>
</p>
</form>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { LoginForm } from "./login-form";
export default function LoginPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-amber-50">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
<p className="text-amber-700 mt-2">Войдите в свой аккаунт</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
<LoginForm />
</div>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { RegisterForm } from "./register-form";
export default function RegisterPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-amber-50">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1>
<p className="text-amber-700 mt-2">Создайте аккаунт</p>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8">
<RegisterForm />
</div>
</div>
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { signUp } from "@/lib/auth-client";
export function RegisterForm() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
const result = await signUp.email({ name, email, password });
if (result.error) {
setError(result.error.message ?? "Ошибка регистрации");
setLoading(false);
return;
}
setSuccess(true);
setLoading(false);
}
if (success) {
return (
<div className="text-center space-y-4">
<div className="text-4xl"></div>
<h2 className="text-xl font-semibold text-gray-800">
Проверьте почту
</h2>
<p className="text-gray-500">
Мы отправили письмо на <strong>{email}</strong> для подтверждения
аккаунта.
</p>
<Link href="/login" className="text-amber-600 hover:underline text-sm">
Вернуться к входу
</Link>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Имя
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder="Иван Иванов"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder="you@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400"
placeholder="Минимум 8 символов"
/>
</div>
{error && <p className="text-red-500 text-sm">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50"
>
{loading ? "Регистрация..." : "Зарегистрироваться"}
</button>
<p className="text-center text-sm text-gray-500">
Уже есть аккаунт?{" "}
<Link href="/login" className="text-amber-600 hover:underline">
Войти
</Link>
</p>
</form>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
export default function VerifyEmailPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-amber-50">
<div className="text-center max-w-md space-y-4">
<div className="text-5xl"></div>
<h1 className="text-2xl font-bold text-amber-900">Email подтверждён</h1>
<p className="text-gray-600">
Ваш аккаунт активирован. Теперь вы можете войти в систему.
</p>
<Link
href="/login"
className="inline-block bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-6 rounded-lg transition-colors"
>
Войти
</Link>
</div>
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
export default async function StudentDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
return (
<div className="min-h-screen bg-amber-50">
<header className="bg-white border-b border-amber-100 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-amber-900">Second Brain</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span>
<LogoutButton />
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Добро пожаловать, {session.user.name}!
</h2>
<p className="text-gray-500 mb-8">Ваши курсы появятся здесь.</p>
<div className="bg-white rounded-2xl border border-amber-100 p-8 text-center text-gray-400">
<p className="text-4xl mb-3">📚</p>
<p>Доступных курсов пока нет.</p>
<p className="text-sm mt-1">Обратитесь к администратору за доступом.</p>
</div>
</main>
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
export default async function AdminDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "admin") redirect("/dashboard");
return (
<div className="min-h-screen bg-slate-50">
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-slate-900">Second Brain Админ</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span>
<LogoutButton />
</div>
</header>
<main className="max-w-5xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Панель администратора
</h2>
<p className="text-gray-500 mb-8">Управление платформой Second Brain.</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<p className="text-3xl mb-2">📚</p>
<p className="font-medium text-gray-800">Курсы</p>
<p className="text-sm text-gray-400 mt-1">CRUD Этап 1</p>
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<p className="text-3xl mb-2">👥</p>
<p className="font-medium text-gray-800">Пользователи</p>
<p className="text-sm text-gray-400 mt-1">Управление Этап 1</p>
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<p className="text-3xl mb-2">📊</p>
<p className="font-medium text-gray-800">Аналитика</p>
<p className="text-sm text-gray-400 mt-1">Этап 10</p>
</div>
</div>
</main>
</div>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);
+43
View File
@@ -0,0 +1,43 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
export default async function CuratorDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") {
redirect("/dashboard");
}
return (
<div className="min-h-screen bg-green-50">
<header className="bg-white border-b border-green-100 px-6 py-4 flex items-center justify-between">
<h1 className="text-xl font-bold text-green-900">Second Brain Куратор</h1>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span>
<LogoutButton />
</div>
</header>
<main className="max-w-4xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Панель куратора
</h2>
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white rounded-2xl border border-green-100 p-6">
<p className="text-3xl mb-2">📝</p>
<p className="font-medium text-gray-800">Домашние задания</p>
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
</div>
<div className="bg-white rounded-2xl border border-green-100 p-6">
<p className="text-3xl mb-2">👥</p>
<p className="font-medium text-gray-800">Мои ученики</p>
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p>
</div>
</div>
</main>
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export default async function HomePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) {
redirect("/login");
}
const role = session.user.role;
if (role === "admin") redirect("/admin/dashboard");
if (role === "curator") redirect("/curator/dashboard");
redirect("/dashboard");
}
+23
View File
@@ -0,0 +1,23 @@
"use client";
import { useRouter } from "next/navigation";
import { signOut } from "@/lib/auth-client";
export function LogoutButton() {
const router = useRouter();
async function handleLogout() {
await signOut();
router.push("/login");
router.refresh();
}
return (
<button
onClick={handleLogout}
className="text-sm text-gray-500 hover:text-gray-700 transition-colors"
>
Выйти
</button>
);
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { createAuthClient } from "better-auth/react";
import { adminClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
plugins: [adminClient()],
});
export const { signIn, signOut, signUp, useSession } = authClient;
+36
View File
@@ -0,0 +1,36 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { admin } from "better-auth/plugins";
import { prisma } from "./prisma";
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
emailAndPassword: {
enabled: true,
requireEmailVerification: true,
},
plugins: [
admin({
defaultRole: "student",
adminRoles: ["admin"],
}),
],
trustedOrigins: [
process.env.BETTER_AUTH_URL ?? "http://localhost:3000",
"https://school.second-brain.ru",
],
user: {
additionalFields: {
role: {
type: "string",
defaultValue: "student",
input: false,
},
},
},
});
export type Session = typeof auth.$Infer.Session;
export type User = typeof auth.$Infer.Session.user;
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "../generated/prisma";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+6
View File
@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
const PUBLIC_ROUTES = [
"/login",
"/register",
"/verify-email",
"/api/auth",
];
const ROLE_ROUTES: Record<string, string[]> = {
admin: ["/admin"],
curator: ["/curator", "/admin"],
};
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public routes and static assets
if (
PUBLIC_ROUTES.some((route) => pathname.startsWith(route)) ||
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon")
) {
return NextResponse.next();
}
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}