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:
@@ -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
@@ -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
|
||||||
@@ -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 -->
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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 считается готовым.
|
||||||
@@ -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
@@ -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 и картинках
|
||||||
|
- Мобильное приложение
|
||||||
@@ -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:
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+8402
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { toNextJsHandler } from "better-auth/next-js";
|
||||||
|
|
||||||
|
export const { GET, POST } = toNextJsHandler(auth);
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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).*)"],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user