Compare commits

...

31 Commits

Author SHA1 Message Date
admins 768a38b9d3 Add course tree, lesson actions, and module description schema
- CourseTree: expandable module/lesson overview with Eye/Video icons
- SortableLessons: Kinescope ID in create form, published toggle, move-to-module dropdown
- Actions: toggleLessonPublished, moveLessonToModule, updateModule with description
- Schema: add description field to Module model + migration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:32:30 +05:00
admins f0024c4243 Add course management improvements: tree view, module descriptions, lesson toggles
- SortableModules: add description textarea in edit form, show description in row
- CourseDetailPage: fetch lessons per module, add CourseTree overview section
- ModulePage: fetch sibling modules, pass as otherModules to SortableLessons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:31:30 +05:00
admins d0ba4bf909 Polish: homework filters, users search/popup, admin comments
Homework (/curator/homework):
- Search by student name/email
- Filter by status (pending/reviewed) and course
- Server-side pagination (20 per page) with URL params

Users (/admin/users):
- Search by name/email, filter by role
- Hover popup on each row: enrolled courses + expiry dates + email
- Pagination (20 per page) with URL params

Comments (/admin/comments):
- New admin page with all active comments
- Search by author or text content
- One-click delete (soft-delete) from the table
- Pagination (30 per page)
- Added "Комментарии" link to admin nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:00:57 +05:00
admins dd46a10c20 Add CSV import/export for students (Stage 11)
Import wizard (4 steps):
- Upload CSV with UTF-8 or Windows-1251 (iconv-lite) decoding
- Auto-detect columns: Email, Имя, Фамилия, Телефон
- Preview table with per-row status: new / update / error
- Options: auto-verify email, assign course + access days, send welcome email
- Apply: creates users with bcrypt password + Account record, grants enrollments

Export:
- GET /api/admin/export-users with course filter + encoding selection
- UTF-8 with BOM (works in all apps) or Windows-1251 (legacy Excel)
- Fields: Email, Имя, Телефон, Дата регистрации, Курсы, Прогресс

Navigation: added "Импорт / Экспорт" link to admin sidebar

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:51:43 +05:00
admins 99c143d670 Add manual user creation in admin panel
- Server action createUser() with bcrypt password hash + Account record
- Form with name, email, password (show/hide + generate), role, emailVerified toggle
- Optional welcome email toggle (bypasses auto-hook for admin-created users)
- /admin/users/new page with breadcrumb navigation
- After creation, redirects to the new user's profile page
- "Добавить пользователя" button on the users list page

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:36:52 +05:00
admins 58a61d6f04 Fix settings: catch DB errors at build time, return defaults
getSettings() and getSetting() now fall back to defaults when the
database is unavailable (e.g., during Docker image build).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:23:26 +05:00
admins e77588deb8 Add platform settings (Stage 9)
- Settings key-value table in Prisma with migration
- getSettings() / getSetting() helpers in lib/settings.ts
- Admin UI at /admin/settings with 6 sections: General, Notifications,
  Student profile, Legal docs, Curator permissions, Code injection
- saveSettings() server action with admin-only guard
- Maintenance mode: non-admin users redirected to /maintenance page
- schoolName propagated to page metadata and all email templates
- headCode / bodyCode injected into root layout <head> and <body>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:18:37 +05:00
admins 093e403f5f Enhance lesson editor: prev/next nav + richer toolbar
- page.tsx: fetch sibling lessons, pass prevLesson/nextLesson props
- LessonEditor: ChevronLeft/Right nav buttons with lesson title tooltip
- Toolbar: added Underline, Strikethrough, inline Code, H1, Horizontal rule,
  Link dialog (prompt), labeled buttons for better discoverability
- Install @tiptap/extension-underline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 10:29:39 +05:00
admins 66b311f17e Polish email template: white outer bg, beige card, Arial font
- Outer background: #FFFFFF
- Card background: #F5F5F0 (beige)
- All text/buttons: Arial/Helvetica (renders consistently on mobile)
- Shadow via table wrapper technique (works in Gmail Android)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 18:05:47 +05:00
admins 32b0fa9d6f Rewrite email template with inline styles for mobile compatibility
- All styles moved to inline attributes (no <style> block)
- Table-based layout for Gmail/Outlook/mobile client compatibility
- Aubade card effect via border-right/border-bottom:4px
- Monospace font stack with web-safe fallbacks
- btn() and quote() helper functions rewritten as <table> elements

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 17:35:30 +05:00
admins c647b29712 Add Markdown import from Obsidian (Stage 8)
- md-to-tiptap.ts: remark-based converter (headings, lists, blockquotes,
  code blocks, bold/italic/strike, links, images, hr)
- Obsidian ![[wikilink]] stripped, [[link|alias]] → plain text
- POST /api/admin/import-md: parses frontmatter (gray-matter) + converts content
- LessonEditor: "Импорт .md" button populates editor without auto-save
- ROADMAP: marked Stages 2, 3, 5, 6, 7, 8 as complete, fixed numbering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:44:42 +05:00
admins 6d93a7b406 Add lesson comments (Stage 6)
- LessonComment CRUD: addComment / deleteComment server actions
- LessonComments client component with form, avatar, delete
- Comments section at bottom of lesson page (enrolled users only)
- Soft-delete support, moderation for curator/admin
- ROADMAP: moved Квизы to end (Stage 11), marked Stage 7 done

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 15:33:47 +05:00
admins 97f4c1ec24 Fix admin sidebar missing on /curator/* routes
- Extract AdminShell component (sidebar + wrapper)
- admin/layout.tsx uses AdminShell
- curator/layout.tsx uses AdminShell for admin role (was rendering children only)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:59:51 +05:00
admins ec51dd34bb Replace admin dashboard stub with real stats
- 4 stat cards: students (+monthly), courses (published), active enrollments (expiring alert), homework pending
- Recent enrollments list (last 8)
- Top courses by enrollment count
- Activity counters: total lessons completed, total homework submitted
- All cards link to relevant admin pages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:58:46 +05:00
admins b40d518b74 Fix Resend lazy init to avoid build-time API key error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:48:14 +05:00
admins 6975a9f97e Add email notifications via Resend
- src/lib/email.ts: HTML templates for 4 email types (Second Brain design)
- Welcome email on user registration (Better Auth databaseHooks)
- Course access email when admin grants enrollment
- Homework submitted email to all admins/curators (first submission only)
- Feedback received email to student with feedback text and lesson link
- Update TECHNICAL.md: Resend domain, from-address, email vars, Stage 3 summary

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:46:46 +05:00
admins 9bc18247df Add homework review link to admin sidebar
- Admin sidebar now has "ДЗ на проверку" link → /curator/homework
- Curator layout renders children-only for admin (no double sidebar)
- Active state highlights correctly when on /curator/* routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:18:56 +05:00
admins 543d5b2d5e Add homework system (admin, student, curator)
Admin:
- HomeworkEditor in lesson page: create/update/delete assignment description

Student:
- HomeworkSection in lesson page: view assignment, submit text + files
- Resubmission allowed until curator gives feedback
- Shows feedback from curator with date and name

Curator:
- New layout with Second Brain dark sidebar (replaces green theme)
- /curator/dashboard: stats cards (pending, total, reviewed this week)
- /curator/homework: list of all submissions, pending highlighted
- /curator/homework/[id]: review submission, write feedback, redirect after send

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:13:24 +05:00
admins d0c8c6dd53 Add lesson progress tracking
- Toggle lesson completion via server action (LessonProgress table)
- "Отметить как пройденный" button on lesson page, turns accent when done
- Course sidebar: progress bar, checkmarks on completed lessons, X/Y counter per module
- Dashboard: progress bar on each course card with completion percentage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 13:16:28 +05:00
admins c88b5d2004 Allow admin to preview unpublished lessons without enrollment
- Course layout skips enrollment and published checks for admin role
- Lesson page skips published filter for admin role
- Enables admin preview button to work for any lesson/course state

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:57:18 +05:00
admins 4183a912e4 Add save and preview icons to lesson editor
- Save button now shows floppy disk icon (lucide Save)
- New Preview button with eye icon opens lesson in student view (new tab)
- Pass courseSlug through to LessonEditor for preview URL construction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:45:30 +05:00
admins 07b9a6d261 Polish UX: auto-redirect on create, fix design consistency
- createModule now redirects to module page after creation
- createLesson now redirects to lesson editor after creation
- Regenerate Prisma client to fix missing types (category, accessLog, expiresAt)
- Rewrite sortable-modules/lessons with Second Brain design tokens (remove amber/slate)
- Rewrite lesson-editor toolbar and toggle with design tokens
- Fix register page/form: replace amber theme with card-aubade design

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:38:46 +05:00
admins 05dd4d1df2 Stage 2: student lesson viewer, Kinescope player, PDF files, prev/next nav, My Courses dashboard 2026-04-07 12:13:12 +05:00
admins 03e3972388 Add TECHNICAL.md: infrastructure, design tokens, media specs, DB schema, done stages 2026-04-07 12:08:44 +05:00
admins 8fdc67b4a5 Mark Stage 1.5 complete in ROADMAP 2026-04-07 12:01:19 +05:00
admins e9eff5bae5 Stage 1.5: categories, enrollment expiry, access log, bulk grant, user page 2026-04-07 11:59:13 +05:00
admins 992763aeb9 Apply Second Brain design: Fira Mono, Aubade cards, brand palette 2026-04-07 11:51:20 +05:00
admins 09325187f9 Fix createLesson return type: void instead of string 2026-04-07 11:39:51 +05:00
admins 01a9ef482c Fix DialogTrigger: remove asChild (base-ui doesn't support it) 2026-04-07 11:38:33 +05:00
admins d356dddc96 Stage 1: Course/Module/Lesson CRUD admin UI with TipTap editor 2026-04-07 11:36:27 +05:00
admins 9d82b73e58 feat: initial commit 2026-04-07 11:30:38 +05:00
102 changed files with 16003 additions and 429 deletions
+265 -142
View File
@@ -6,13 +6,12 @@
--- ---
## Этап 0 — Каркас, auth, роли ✅ ЗАВЕРШЁН (07.04.2026) ## Этап 0 — Каркас, auth, роли ✅ ЗАВЕРШЁН (07.04.2026)
**Задеплоено на:** https://school.second-brain.ru
- [x] Next.js 16.2.2 (App Router, TypeScript, Tailwind v4) - [x] Next.js 16.2.2 (App Router, TypeScript, Tailwind v4)
- [x] Docker Compose: PostgreSQL 16 (прод на Hetzner) - [x] Docker Compose: PostgreSQL 16 (прод на Hetzner)
- [x] Prisma 7: схема User, Session, Account + полная LMS-модель - [x] Prisma 7: схема User, Session, Account + полная LMS-модель
- [x] Better Auth: вход по email/password, роли student/curator/admin - [x] Better Auth: вход по email/password, роли student/curator/admin
- [x] proxy.ts: защита маршрутов по сессии - [x] Middleware: защита маршрутов по сессии
- [x] Дашборды для трёх ролей - [x] Дашборды для трёх ролей
- [x] Страница входа, регистрации, подтверждения email - [x] Страница входа, регистрации, подтверждения email
- [x] Seed: admin/curator/student (пароль: Password123!) - [x] Seed: admin/curator/student (пароль: Password123!)
@@ -21,62 +20,249 @@
--- ---
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке) ## Этап 1 — Курсы → Модули → Уроки (CRUD в админке) ✅ ЗАВЕРШЁН (07.04.2026)
**Цель:** могу создать полную структуру курса из браузера.
- [ ] Prisma-схема: Course, Module, Lesson (с порядком, статусом published) - [x] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
- [ ] Admin: список курсов, создать / редактировать / удалить курс - [x] Admin: список курсов, создать / редактировать / удалить курс
- [ ] Admin: список модулей внутри курса, drag-and-drop сортировка - [x] Admin: список модулей внутри курса, drag-and-drop сортировка
- [ ] Admin: список уроков внутри модуля, drag-and-drop сортировка - [x] Admin: список уроков внутри модуля, drag-and-drop сортировка
- [ ] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки) - [x] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
- [ ] Загрузка картинок в уроке → Hetzner Object Storage - [x] Загрузка картинок в уроке → Hetzner Object Storage
- [ ] Поле для Kinescope ID в уроке (просто текстовое, без интеграции — это Этап 2) - [x] Поле для Kinescope ID в уроке
- [ ] Публикация/скрытие курса и урока (черновик / опубликован) - [x] Публикация/скрытие курса и урока (черновик / опубликован)
- [ ] Управление доступом: выдать / забрать доступ к курсу для пользователя - [x] Управление доступом: выдать / забрать доступ к курсу для пользователя
- [x] Дизайн: Fira Mono, #F5F5F0, Aubade-карточки
**Критерий готовности:** создаю курс «Obsidian PKM», добавляю 2 модуля, в каждом по 3 урока с текстом и картинкой, публикую. - [x] Admin: таблица пользователей (/admin/users)
--- ---
## Этап 2 — Интеграция Kinescope, рендер уроков для ученика **Доработки таблицы пользователей (добавить в рамках Этапа 9):**
**Цель:** ученик видит урок с видео Kinescope и текстом. - [ ] Фильтры: по роли (Ученик / Куратор / Администратор), по статусу email (подтверждён/нет)
- [ ] Поиск по имени / email
- [ ] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player` - [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
- [ ] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы - [ ] Ховер-попап на строке: список курсов пользователя со сроками доступа + email + телефон
- [ ] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания - [ ] Быстрые действия в строке: кнопка «Выдать доступ» без перехода на страницу пользователя
- [ ] Страница курса для ученика: список модулей и уроков, статус прохождения
- [ ] Навигация по урокам: предыдущий / следующий
- [ ] Блокировка доступа к курсу без enrollment (middleware или server component)
- [ ] Страница «Мои курсы» в личном кабинете ученика
**Кинескоп-нюанс:** пока без DRM (бесплатный план), просто передаём `videoId` в компонент. Когда появится платный план — добавим signed URLs в отдельной задаче.
**Критерий готовности:** ученик открывает урок, смотрит видео из Kinescope, читает текст, скачивает PDF.
--- ---
## Этап 3 — Прогресс и линейное открытие уроков ## Этап 1.5 — Расширенное управление доступом ✅ ЗАВЕРШЁН (07.04.2026)
**Цель:** ученик проходит курс последовательно, прогресс сохраняется.
- [ ] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt) - [x] Срок доступа: `expiresAt` в `CourseEnrollment`, просроченный подсвечивается красным
- [ ] Кнопка «Урок завершён» — создаёт запись в LessonProgress - [x] Категории курсов: таблица `Category`, `/admin/categories`, привязка к курсу
- [ ] Логика блокировки: следующий урок открыт только если предыдущий завершён - [x] Расширенный энролл: `/admin/users/[userId]` — выбор нескольких курсов + срок одной операцией
- [ ] Прогресс-бар по курсу (% завершённых уроков) - [x] История доступа: `AccessLog` — каждая операция логируется (кто, когда, метод, примечание)
- [ ] Прогресс-бар по модулю
- [ ] API: `POST /api/progress/complete` — принимает lessonId, создаёт прогресс
- [ ] Иконки статуса уроков в боковой панели: ✓ пройден / 🔒 заблокирован / → текущий
**Примечание:** если в уроке есть тест (Этап 4) или ДЗ (Этап 5) — кнопка «Завершить» появляется только после их прохождения/отправки. Эту логику дорабатываем на соответствующих этапах.
**Критерий готовности:** прохожу урок 1, нажимаю «Завершён» — открывается урок 2. Урок 3 заблокирован. Прогресс-бар показывает 33%.
--- ---
## Этап 4Тесты и квизы ## Этап 2Интеграция Kinescope, рендер уроков для ученика ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
- [x] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
- [x] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
- [x] Страница курса для ученика: список модулей и уроков, статус прохождения
- [x] Навигация по урокам: предыдущий / следующий
- [x] Блокировка доступа к курсу без enrollment (layout server component)
- [x] Страница «Мои курсы» в личном кабинете ученика (dashboard)
- [x] Кнопки Сохранить / Просмотр в редакторе урока
- [x] Иконка-статус уроков в боковой панели курса (✓ пройден)
---
## Этап 3 — Прогресс ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
- [x] Кнопка «Урок завершён» — создаёт/удаляет запись в LessonProgress
- [x] Прогресс-бар по курсу в боковой панели (% завершённых уроков)
- [x] Прогресс-бар по курсу на дашборде студента
- [x] Admin bypass: администратор видит все уроки без отметок о прогрессе
---
## Этап 5 — Домашние задания и обратная связь куратора ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
- [x] Admin: добавить / редактировать / удалить блок ДЗ к уроку (HomeworkEditor)
- [x] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
- [x] Ученик: видит статус ДЗ и фидбек куратора в карточке урока
- [x] Куратор / Admin: список ДЗ на проверку (`/curator/homework`)
- [x] Куратор / Admin: просмотр работы, текстовый комментарий с обратной связью
- [x] Admin: AdminShell на `/curator/*` маршрутах (сайдбар не пропадает)
- [x] Admin: реальная статистика на дашборде (студенты, курсы, ДЗ, прогресс)
**Доработки (добавить в рамках Этапа 9):**
- [ ] Фильтры в списке ДЗ: по имени/email ученика, по уроку, по курсу, по статусу
- [ ] Поиск по имени/email ученика
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
- [ ] Расширенные статусы: «Новое» / «Просмотрено» / «Прокомментировано»
- [ ] Шаблоны ответов куратора — готовые тексты фидбека, выбираемые из списка
---
## Этап 6 — Обсуждения под уроками ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Prisma: LessonComment (soft-delete через поле `deleted`)
- [x] Рендер списка комментариев под уроком (server-fetched, client-rendered)
- [x] Форма отправки комментария (только для enrolled учеников и admin)
- [x] Модерация: автор, куратор или admin может удалить комментарий
- [x] Счётчик активных комментариев в заголовке секции
**Не реализовано (добавить в Этап 9 или отдельно):**
- [ ] `/admin/comments` — сводная таблица всех комментариев по всем урокам
- Колонки: №, Имя ученика, Урок (ссылка), Время, кол-во ответов, превью текста
- Удалить комментарий прямо из списка
- Пагинация
- Ссылка в сайдбаре AdminNav
---
## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026)
- [x] Базовый HTML email-шаблон (фирменный стиль Second Brain)
- [x] Приветственное письмо при регистрации (`databaseHooks.user.create.after`)
- [x] Письмо ученику об открытии доступа к курсу
- [x] Куратор / Admin: уведомление о новом ДЗ на проверку
- [x] Ученик: уведомление о полученном фидбеке
- [x] Resend domain: mailsend.second-brain.ru (verified)
---
## Этап 8 — Импорт уроков из Markdown (Obsidian) ✅ ЗАВЕРШЁН (07.04.2026)
- [x] API: `POST /api/admin/import-md` — принимает .md-файл
- [x] Парсинг frontmatter (title, kinescopeId, order, published) через `gray-matter`
- [x] Конвертация Markdown → TipTap JSON через `unified` + `remark-parse`
- [x] Поддержка: заголовки, параграфы, жирный/курсив/зачёркнутый, инлайн-код, блоки кода, цитаты, списки, ссылки, изображения (HTTP), горизонтальные разделители
- [x] Очистка Obsidian-синтаксиса: `![[image]]` удаляется, `[[link|alias]]` → текст
- [x] UI: кнопка «Импорт .md» в редакторе урока — заполняет форму без автосохранения
---
## Этап 9 — Настройки платформы (Admin Settings) ✅ ЗАВЕРШЁН (08.04.2026)
**Цель:** администратор управляет ключевыми параметрами платформы без правки кода.
### Основное
- [ ] Название школы (используется в заголовке сайта, подписи писем)
- [ ] Описание школы (мета-тег description)
- [ ] Ключевые слова (мета-тег keywords)
- [ ] Режим тех. работ: вкл/выкл (показывает заглушку всем кроме admin)
- [ ] Регистрация учеников: вкл/выкл
### Оформление
- [ ] Логотип школы (загрузка → Object Storage, отображается в шапке)
- [ ] Фавикон (загрузка → Object Storage)
- [ ] Показывать логотип: да/нет
### Уведомления
- [ ] Email(ы) для системных уведомлений (кому слать письма о ДЗ, вопросах, регистрациях)
- [ ] Уведомление куратору/админу о новом ДЗ: вкл/выкл
- [ ] Уведомление куратору/админу о новом вопросе ученика: вкл/выкл
- [ ] Уведомление админу о новой регистрации: вкл/выкл
- [ ] Уведомление ученику при ответе на ДЗ/вопрос: вкл/выкл
### Данные ученика
- [ ] Требовать подтверждение email перед доступом к курсам: да/нет
- [ ] Фамилия при регистрации: обязательная / необязательная / выключена
- [ ] Телефон при регистрации: обязательный / необязательный / выключен
### Защита
- [ ] Одна активная сессия на аккаунт: вкл/выкл
- [ ] CAPTCHA на форме регистрации: вкл/выкл (reCAPTCHA v3)
### Права куратора
- [ ] Куратор видит ДЗ: по всем курсам / только по назначенным курсам
- [ ] Куратор может отвечать на вопросы учеников: да/нет
- [ ] Куратор видит список всех студентов: да/нет
### Вставка кода
- [ ] Произвольный код в `<head>` (Yandex.Metrika, Google Analytics, пиксели)
- [ ] Произвольный код в `<body>` (виджеты, чаты поддержки)
### Юридические документы
- [ ] URL Политики конфиденциальности (ссылка на внешний документ)
- [ ] URL Согласия на обработку персональных данных
- [ ] URL Договора-оферты
- [ ] Показывать чекбокс «Я принимаю условия» при регистрации: да/нет
- [ ] Реквизиты организации (текстовое поле, отображается в подвале)
### Соц. сети
- [ ] YouTube: одна ссылка
- [ ] VK: несколько ссылок (название + URL), например «Основная группа» и «Канал»
- [ ] Telegram: несколько ссылок (название + URL), например «Основной канал» и «Канал курса»
(отображаются в подвале личного кабинета ученика; хранятся как JSON-массив в Settings)
### Вопросы учеников
- [ ] Система вопросов глобально: вкл/выкл
- [ ] Куратор/админ может написать ученику первым: да/нет
- [ ] Вопросы только по курсам ученика: да/нет
- [ ] Включать вопросы для новых курсов автоматически: да/нет
**Хранение:** таблица `Settings` (key-value), доступна через `getSettings()` в server components.
**Критерий готовности:** меняю название школы → оно появляется в заголовке. Включаю тех. работы → ученики видят заглушку. Куратор привязан к курсу — видит только его ДЗ.
---
## Этап 11 — Импорт/Экспорт учеников и миграция с emdesell
**Цель:** все пользователи и контент перенесены в новую LMS. Раздел `/admin/import-export`.
### Импорт учеников (CSV)
- [ ] Скачать файл-шаблон CSV (Email, Имя, Фамилия, Телефон)
- [ ] Загрузка CSV, поддержка кодировок Windows-1251 и UTF-8
- [ ] Опция: подтверждать email автоматически (да/нет)
- [ ] Опция: обновлять уже существующие аккаунты (да/нет)
- [ ] Присвоение доступов к курсам при импорте (выбор курса + срок в днях, 0 = бессрочно)
- [ ] Опция: отправить письмо-уведомление ученику (со ссылкой для установки пароля)
- [ ] Предпросмотр перед применением (таблица: кто создаётся, кто обновляется, кому даётся доступ)
- [ ] Применить импорт — создать пользователей, выдать доступы, отправить письма
### Экспорт учеников (CSV)
- [ ] Все ученики или фильтр по конкретному курсу/доступу
- [ ] Фильтр по просмотрам уроков (экспортировать только тех кто смотрел)
- [ ] Выбор кодировки: Windows-1251 (для Excel) / UTF-8
- [ ] Поля: Email, Имя, Фамилия, Телефон, Дата регистрации, Курсы, Прогресс
### Миграция контента
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
- [ ] QA: проверить 10 случайных аккаунтов после импорта
**Критерий готовности:** загружаю CSV из emdesell → предпросмотр показывает корректные данные → применяю → ученики получают письма → могут войти и продолжить обучение.
---
## Этап 12 — Telegram-бот и аналитика
**Цель:** уведомления в Telegram для всех участников, базовая аналитика.
**Настройки (в разделе Настройки → Telegram):**
- Токен бота (вводится в админке, хранится в Settings)
- Интеграция вкл/выкл глобально
- Показывать кнопку «Подключить Telegram» в кабинете ученика: да/нет
**Уведомления куратору/админу:**
- [ ] Новое ДЗ на проверку
- [ ] Новый вопрос от ученика
- [ ] Новая регистрация студента
- [ ] Ошибки платформы (500-е, failed email и т.д.)
**Уведомления ученику:**
- [ ] Получен фидбек по ДЗ
- [ ] Ответ куратора на вопрос
- [ ] Открыт доступ к новому курсу
**Реализация:**
- [ ] Ученик привязывает Telegram через `/start` в боте (сохраняется `telegramChatId` в User)
- [ ] Кнопка «Подключить Telegram» в личном кабинете ученика
- [ ] Админ/куратор вводит свой `chatId` в профиле или через `/start`
- [ ] Настройки бота в разделе Настройки → Telegram
- [ ] Yandex.Metrika: базовое подключение (pageviews)
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
---
## Этап 13 — Тесты и квизы
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат. **Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt - [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть)
- [ ] Admin: создание теста к уроку (добавить вопрос → варианты ответов → отметить правильный) - [ ] Admin: создание теста к уроку (вопрос → варианты → отметить правильный)
- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст - [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст
- [ ] Рендер теста в уроке для ученика - [ ] Рендер теста в уроке для ученика
- [ ] Авто-проверка (single/multiple choice), результат сразу - [ ] Авто-проверка (single/multiple choice), результат сразу
@@ -87,105 +273,10 @@
--- ---
## Этап 5 — Домашние задания и обратная связь куратора
**Цель:** ученик сдаёт ДЗ, куратор оставляет комментарий.
- [ ] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
- [ ] Admin: добавить блок ДЗ к уроку (текст задания)
- [ ] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
- [ ] Ученик: ДЗ засчитывается **автоматически при отправке** (урок открывается сразу)
- [ ] Куратор: список ДЗ на проверку (все или по курсу), статус «новое / просмотрено»
- [ ] Куратор: просмотр ДЗ, оставить текстовый комментарий
- [ ] История обмена по ДЗ (ученик может ответить на комментарий куратора)
- [ ] Уведомление ученику когда куратор оставил комментарий (заглушка — реальные email на Этапе 7)
**Критерий готовности:** отправляю ДЗ — урок открывается. Куратор заходит в панель, видит ДЗ, оставляет комментарий. Ученик видит комментарий в карточке урока.
---
## Этап 6 — Обсуждения под уроками
**Цель:** ученики могут общаться под каждым уроком.
- [ ] Prisma: LessonComment (с поддержкой вложенных ответов — опционально)
- [ ] Рендер треда комментариев под уроком
- [ ] Форма отправки комментария (только для enrolled учеников)
- [ ] Модерация: куратор/админ может удалить комментарий
- [ ] Пагинация или infinite scroll для длинных тредов
**Критерий готовности:** ученик оставляет комментарий, другой ученик его видит, куратор может удалить.
---
## Этап 7 — Email-уведомления
**Цель:** все участники получают нужные письма через Resend.
- [ ] Базовый email-шаблон (HTML, фирменный стиль)
- [ ] Ученик: подтверждение регистрации (уже частично с Этапа 0, финализировать)
- [ ] Ученик: письмо когда куратор оставил комментарий к ДЗ
- [ ] Ученик: письмо когда ответили на его комментарий в уроке
- [ ] Куратор / Админ: новое ДЗ на проверку
- [ ] Куратор / Админ: новый комментарий в обсуждении
- [ ] Админ: зарегистрирован новый ученик
- [ ] Очередь отправки (edge case: Resend временно недоступен → retry)
**Критерий готовности:** отправляю ДЗ — куратор получает email. Куратор отвечает — ученик получает email.
---
## Этап 8 — Импорт уроков из Markdown
**Цель:** могу импортировать урок из .md-файла Obsidian одним действием.
- [ ] API: `POST /api/admin/lessons/import-md` — принимает .md-файл
- [ ] Парсинг frontmatter (title, order, kinescopeId и кастомные поля) → метаданные урока
- [ ] Конвертация Markdown-тела в TipTap JSON (через remark / rehype)
- [ ] UI в админке: кнопка «Импортировать из .md» на странице урока
- [ ] Обработка картинок в Markdown (локальные пути → Object Storage)
**Критерий готовности:** беру .md-файл из Obsidian с frontmatter и текстом → импортирую → урок создан с правильными метаданными и контентом.
---
## Этап 9 — Миграция с emdesell
**Цель:** все пользователи и контент перенесены в новую LMS.
- [ ] Скрипт импорта пользователей из CSV-экспорта emdesell (email, имя, курсы)
- [ ] Создание пользователей без пароля + письмо «установите пароль»
- [ ] Назначение доступов к курсам по данным из CSV
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
- [ ] QA: проверить 10 случайных аккаунтов после импорта
**Критерий готовности:** все ученики из emdesell могут войти в новую LMS и продолжить обучение.
---
## Этап 10 — Telegram-бот и аналитика
**Цель:** получаю уведомления в Telegram, вижу базовую аналитику.
- [ ] Telegram-бот: уведомление куратору/админу о новом ДЗ
- [ ] Telegram-бот: уведомление об ошибках (500-е, failed email и т.д.)
- [ ] Yandex.Metrika: базовое подключение (pageviews)
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
---
## Этап 11 — Деплой на Hetzner
**Цель:** LMS работает на production-сервере по своему домену с SSL.
- [ ] `docker-compose.prod.yml`: app + PostgreSQL + Redis + Nginx
- [ ] Nginx: SSL через Let's Encrypt (certbot), reverse proxy на Next.js
- [ ] GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
- [ ] Резервное копирование PostgreSQL (cron → Object Storage)
- [ ] Мониторинг uptime (UptimeRobot или аналог)
- [ ] `.env` на сервере через Hetzner Secrets Manager или vault-файл вне репозитория
- [ ] Smoke-тест: регистрация → урок → ДЗ → куратор → email
**Критерий MVP готов:** создаю курс из админки, добавляю уроки с Kinescope, импортирую ученика из emdesell, даю доступ — ученик регистрируется, проходит урок, сдаёт тест, отправляет ДЗ, получает автодоступ к следующему уроку, позже — комментарий куратора на email.
---
## Бэклог (после MVP) ## Бэклог (после MVP)
- Резервное копирование PostgreSQL (cron → Object Storage)
- GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
- Сертификаты по окончании курса - Сертификаты по окончании курса
- Геймификация (баллы, бейджи, рейтинги) - Геймификация (баллы, бейджи, рейтинги)
- Промокоды и интеграция с платёжными системами - Промокоды и интеграция с платёжными системами
@@ -193,3 +284,35 @@
- Kinescope DRM (signed URLs) — при переходе на платный план - Kinescope DRM (signed URLs) — при переходе на платный план
- Водяные знаки на PDF и картинках - Водяные знаки на PDF и картинках
- Мобильное приложение - Мобильное приложение
- **Вопросы учеников** — система тикетов `/admin/questions` и `/questions` для ученика:
- Таблица в админке: №, Имя, Курс, Тема, Статус (Ожидает / Отвечено), Дата
- Статусы отсортированы: сначала «Ожидает ответа»
- Куратор/Admin может создать обращение первым (написать ученику)
- Внутри тикета: история переписки, смена статуса
- **База знаний** — FAQ, который ученик видит до отправки вопроса
- **Шаблоны ответов** — куратор выбирает готовый ответ из списка
- Email + Telegram уведомления обеим сторонам
- **Главная страница ученика** — кастомизируемый экран после входа:
- Приветственный баннер с описанием школы (редактируется в настройках)
- Список курсов ученика с прогрессом
- Блок бесплатных/открытых материалов (статьи, PDF, видео)
- Анонсы ближайших событий и новых курсов
- **Медиатека (Файлы)** — централизованное файловое хранилище `/admin/files`:
- Prisma: `MediaFolder` (id, name, courseId?, createdAt) + `MediaFile` (id, folderId?, name, url, size, mimeType, uploadedById, createdAt)
- Папки автоматически создаются по курсам + «Common» для общих файлов
- Вид: грид (карточки с иконкой типа) или список — переключатель
- Breadcrumb-навигация: Все файлы / Название папки
- Загрузка файлов (PDF, изображения, любые) → Object Storage
- Создание папки вручную
- Клик на файл → диалог: имя (редактируемое), дата загрузки, размер, автор
- Действия в диалоге: скопировать ссылку, скачать, удалить
- Вставка файлов из медиатеки в урок (вместо повторной загрузки)
- **Цифровой сад** — публичный раздел платформы для сообщества:
- Методические материалы и статьи (PKM, Obsidian, Second Brain)
- Рекомендованная литература с аннотациями
- Записи открытых встреч и вебинаров
- Календарь: предстоящие открытые уроки, запуски курсов, события
- Возможно: публичный Obsidian-like граф знаний
+267
View File
@@ -0,0 +1,267 @@
# TECHNICAL — LMS Second Brain
Живая документация проекта. Обновляется по мере разработки.
Роадмап и планирование — в `ROADMAP.md`. Здесь — факты о том, как всё устроено.
---
## Инфраструктура
| Компонент | Значение |
|---|---|
| **Сервер** | Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe |
| **IP** | 178.104.27.196 |
| **Домен LMS** | https://school.second-brain.ru |
| **Reverse proxy** | Caddy (auto HTTPS через Let's Encrypt) |
| **Порт приложения** | 3010 (внутри контейнера — 3000) |
| **БД** | PostgreSQL 16 (контейнер `lms-sb-db-1`) |
| **Object Storage** | Hetzner Object Storage, регион Nuremberg |
| **Бакет** | `second-brain-lms` (публичный, read-only) |
| **Endpoint S3** | https://nbg1.your-objectstorage.com |
| **Git-репозиторий** | https://git.second-brain.ru/admins/lms-sb |
| **Email-сервис** | Resend, домен `mailsend.second-brain.ru` (verified) |
| **From-адрес** | noreply@mailsend.second-brain.ru |
### Деплой
```bash
# На сервере: /root/digital-household/lms-sb/
git pull ...
docker compose -f docker-compose.prod.yml up -d --build
```
При старте контейнер автоматически запускает `prisma migrate deploy`, затем `node server.js`.
### .env на сервере
Файл `/root/digital-household/lms-sb/.env`:
```
DB_PASSWORD=lms_cd5041e961a3050db359aa15
BETTER_AUTH_SECRET=<secret>
RESEND_API_KEY=re_VX7TCnjs_N2geRqvveHjVfbsSyr4VbmzL
EMAIL_FROM=noreply@mailsend.second-brain.ru
S3_ENDPOINT=https://nbg1.your-objectstorage.com
S3_BUCKET=second-brain-lms
S3_ACCESS_KEY=<ключ>
S3_SECRET_KEY=<секрет>
S3_REGION=eu-central
```
---
## Стек
| Слой | Технология | Версия |
|---|---|---|
| Фреймворк | Next.js (App Router) | 16.2.2 |
| Язык | TypeScript | 5.x |
| UI | React | 19 |
| Стили | Tailwind CSS (CSS-based config) | 4.x |
| UI-компоненты | shadcn/ui (базируется на Base UI, **не Radix**) | latest |
| ORM | Prisma | 7.x |
| Auth | Better Auth | 1.6.0 |
| WYSIWYG | TipTap | 2.x |
| Drag-and-drop | @dnd-kit | latest |
| Шрифт | Fira Mono (400/500/700, Latin + Cyrillic) | Google Fonts |
| Email | Resend | latest |
| S3 | @aws-sdk/client-s3 | 3.x |
| БД | PostgreSQL | 16 |
### Важные нюансы стека
- **shadcn/ui v4** использует `@base-ui/react`, а не Radix. Нет `asChild`. Триггеры — обычные элементы.
- **Prisma 7** не генерирует `index.ts`. Импорт: `from "@/generated/prisma/client"`, не `from "@/generated/prisma"`.
- **Prisma 7** требует адаптер: `new PrismaPg({ connectionString })` — иначе `PrismaClient()` бросает ошибку.
- **Better Auth** использует `scrypt` по умолчанию. В этом проекте **переключён на bcrypt**`auth.ts` настроены `password.hash` / `password.verify`).
- **`NEXT_PUBLIC_*`** переменные запекаются при сборке. `auth-client.ts` не использует `baseURL` — клиент сам берёт `window.location.origin`.
- **Next.js 16** использует `proxy.ts` вместо `middleware.ts` (и экспортируемая функция называется `proxy`, не `middleware`).
- **Tailwind v4**: конфиг только в CSS через `@import "tailwindcss"` и `@theme`. Нет `tailwind.config.ts`.
---
## Дизайн-система
Стиль: **Second Brain Aubade** — типографский, монохромный, с газетным характером.
| Токен | Значение |
|---|---|
| Шрифт | Fira Mono (весь UI) |
| Фон страницы | `#F5F5F0` (тёплый off-white) |
| Текст основной | `#323232` (тёмный уголь) |
| Текст вторичный | `#666666` |
| Поверхность / surface | `#E8E8E0` |
| Акцент / highlight | `#E8F0D8` (зелёный) |
| Divider / border | `#AAAAAA` |
| Hover | `#D8D8D0` |
| Фон сайдбара (тёмный) | `#2A2A28` |
| Активный пункт сайдбара | `#E8F0D8` (зелёный) |
**Aubade-эффект** — фирменный стиль карточек и кнопок:
- Border: `2px solid #AAAAAA`
- Box-shadow: `4px 4px 0 0 #AAAAAA` (смещение без размытия)
- Hover: `transform: translate(-2px, -2px)` + shadow `6px 6px`
- Active (кнопка): `transform: translate(2px, 2px)` + shadow убирается
CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`
---
## Требования к медиафайлам
### Обложка курса (`Course.coverImage`)
| Параметр | Требование |
|---|---|
| **Соотношение сторон** | **16 : 9** (горизонтальный прямоугольник) |
| **Рекомендуемое разрешение** | 1280 × 720 px (HD) или 1920 × 1080 px (Full HD) |
| **Минимальное разрешение** | 800 × 450 px |
| **Максимальный размер файла** | 5 MB |
| **Форматы** | JPG, PNG, WebP |
| **Цветовое пространство** | sRGB |
| **Где хранится** | Hetzner Object Storage, бакет `second-brain-lms`, путь `uploads/<uuid>.ext` |
| **Доступ** | Публичный URL (прямая ссылка на файл) |
> Пример URL: `https://nbg1.your-objectstorage.com/second-brain-lms/uploads/abc123.jpg`
### Изображения в уроках (TipTap)
| Параметр | Требование |
|---|---|
| **Соотношение сторон** | Любое — TipTap встраивает как `<img>` с `max-width: 100%` |
| **Рекомендуемая ширина** | 1200 px (контент-зона урока) |
| **Максимальный размер файла** | 10 MB |
| **Форматы** | JPG, PNG, GIF, WebP |
| **Где хранится** | Hetzner Object Storage, путь `uploads/<uuid>.ext` |
### PDF и файлы к уроку (Этап 2+)
| Параметр | Требование |
|---|---|
| **Форматы** | PDF, ZIP, DOCX, XLSX, PPTX |
| **Максимальный размер** | 100 MB |
| **Где хранится** | Hetzner Object Storage, путь `lessons/<lessonId>/files/<uuid>.ext` |
### Аватары пользователей (если добавим)
| Параметр | Требование |
|---|---|
| **Соотношение сторон** | 1 : 1 (квадрат) |
| **Рекомендуемый размер** | 256 × 256 px |
| **Максимальный размер файла** | 2 MB |
| **Форматы** | JPG, PNG, WebP |
---
## Роли и доступ
| Роль | Маршруты | Описание |
|---|---|---|
| `admin` | `/admin/*`, `/curator/*`, `/dashboard` | Полный доступ |
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
Защита маршрутов — в `src/proxy.ts` + проверка сессии в каждом layout/page.
---
## API-маршруты
| Метод | Путь | Описание | Кто может |
|---|---|---|---|
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
---
## Структура БД (ключевые таблицы)
```
User — id, email, name, role, emailVerified
Session — Better Auth sessions
Account — Better Auth credentials (bcrypt password)
Verification — Better Auth email verification tokens
Category — id, title, slug, order
Course — id, slug, title, description, coverImage, published, order, categoryId
Module — id, courseId, title, order
Lesson — id, moduleId, title, content (JSON), kinescopeId, published, order
LessonFile — id, lessonId, name, url, size
CourseEnrollment — userId + courseId (PK), enrolledAt, expiresAt
AccessLog — id, courseId, userId, action, method, grantedById, note, createdAt
LessonProgress — userId + lessonId (PK), completedAt
Quiz — id, lessonId, showAnswers
QuizQuestion — id, quizId, text, type (SINGLE/MULTIPLE/TEXT), order
QuizOption — id, questionId, text, isCorrect, order
QuizAttempt — id, userId, quizId, score, answers (JSON), completedAt
Homework — id, lessonId, description
HomeworkSubmission — id, homeworkId, userId, text, files (JSON), submittedAt
HomeworkFeedback — id, submissionId, curatorId, text, createdAt
LessonComment — id, lessonId, userId, text, deleted, createdAt
```
Миграции: `prisma/migrations/`**никогда не редактировать вручную**.
---
## Тестовые аккаунты (seed)
| Email | Пароль | Роль |
|---|---|---|
| admin@second-brain.ru | Password123! | admin |
| curator@second-brain.ru | Password123! | curator |
| student@second-brain.ru | Password123! | student |
---
## Что сделано (по этапам)
### Этап 0 — Каркас + Auth ✅
- Next.js 16.2.2 + TypeScript + Tailwind v4
- PostgreSQL 16 + Prisma 7 + полная LMS-схема
- Better Auth: email/password, роли, сессии
- proxy.ts: защита маршрутов
- Дашборды для 3 ролей (admin / curator / student)
- Dockerfile multi-stage + docker-compose.prod.yml
- Caddy: school.second-brain.ru → порт 3010
### Этап 1 — CRUD курсов в админке ✅
- Список курсов: `/admin/courses`
- Создание курса (диалог), редактирование, удаление
- Обложка курса: загрузка в S3, требования — см. раздел «Медиафайлы»
- Модули: drag-and-drop сортировка, CRUD
- Уроки: drag-and-drop сортировка, CRUD
- Редактор урока: TipTap (Bold, Italic, H2/H3, списки, цитата, код, ссылки, изображения)
- Загрузка изображений в урок → S3
- Поле Kinescope ID (текстовое)
- Публикация / скрытие курса и урока
- Управление доступом к курсу (выдать / отозвать)
- Страница пользователей: `/admin/users`
- Дизайн Second Brain Aubade (Fira Mono, #F5F5F0, карточки с тенью)
### Этап 1.5 — Расширенное управление доступом ✅
- Категории курсов: `/admin/categories`, CRUD, привязка к курсу
- Срок доступа: поле `expiresAt` при энролле, просроченный подсвечивается красным
- Страница ученика `/admin/users/[userId]`: мультиэнролл (несколько курсов + срок)
- История доступа: таблица `AccessLog`, отображается на странице курса и ученика
- Hetzner Object Storage подключён: бакет `second-brain-lms`, Nuremberg
### Этап 3 — Прогресс, ДЗ, Email ✅
- Прогресс студента: кнопка «Отметить как пройденный», галочки и прогресс-бар в сайдбаре, прогресс на дашборде
- Домашние задания: редактор ДЗ в уроке (admin), сдача текстом + файлами (student), проверка и фидбек (curator/admin)
- Куратор-панель: `/curator/dashboard` со статистикой, `/curator/homework` список, страница проверки
- Админ имеет доступ к куратор-маршрутам через пункт «ДЗ на проверку» в сайдбаре
- Email уведомления через Resend: доступ к курсу, новая работа, фидбек получен, приветствие
---
## Известные ограничения / технический долг
- `requireEmailVerification: true` в Better Auth — seed-пользователи вставлены напрямую через SQL с `emailVerified = true`
- Загрузка файлов через `/api/admin/upload` — нет ограничения по размеру на уровне Next.js (только S3). При необходимости добавить middleware с проверкой `Content-Length`
- Drag-and-drop обновляет порядок через Server Actions — при быстрых последовательных перетаскиваниях возможны race conditions (некритично для MVP)
- `expiresAt` проверяется только в UI (красная подсветка). Блокировка доступа по сроку на уровне middleware — в рамках Этапа 2
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+1
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
transpilePackages: ["unified", "remark-parse"],
}; };
export default nextConfig; export default nextConfig;
+6526 -109
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -13,17 +13,42 @@
"seed": "ts-node --project tsconfig.json prisma/seed.ts" "seed": "ts-node --project tsconfig.json prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1025.0",
"@aws-sdk/s3-request-presigner": "^3.1025.0",
"@base-ui/react": "^1.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@kinescope/react-kinescope-player": "^0.5.4",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^3.22.2",
"@tiptap/extension-link": "^3.22.2",
"@tiptap/extension-placeholder": "^3.22.2",
"@tiptap/extension-underline": "^3.22.2",
"@tiptap/pm": "^3.22.2",
"@tiptap/react": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"better-auth": "^1.6.0", "better-auth": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gray-matter": "^4.0.3",
"iconv-lite": "^0.7.2",
"lucide-react": "^1.7.0",
"next": "16.2.2", "next": "16.2.2",
"next-themes": "^0.4.6",
"pg": "^8.20.0", "pg": "^8.20.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"remark-parse": "^11.0.0",
"resend": "^6.10.0", "resend": "^6.10.0",
"shadcn": "^4.1.2",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.5",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,38 @@
-- CreateTable: Category
CREATE TABLE "Category" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
-- Add categoryId to Course
ALTER TABLE "Course" ADD COLUMN "categoryId" TEXT;
ALTER TABLE "Course" ADD CONSTRAINT "Course_categoryId_fkey"
FOREIGN KEY ("categoryId") REFERENCES "Category"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
-- Add expiresAt to CourseEnrollment
ALTER TABLE "CourseEnrollment" ADD COLUMN "expiresAt" TIMESTAMP(3);
-- CreateTable: AccessLog
CREATE TABLE "AccessLog" (
"id" TEXT NOT NULL,
"courseId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"method" TEXT NOT NULL DEFAULT 'manual',
"grantedById" TEXT,
"note" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AccessLog_pkey" PRIMARY KEY ("id")
);
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_courseId_fkey"
FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AccessLog" ADD CONSTRAINT "AccessLog_grantedById_fkey"
FOREIGN KEY ("grantedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "Settings" (
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Settings_pkey" PRIMARY KEY ("key")
);
@@ -0,0 +1 @@
ALTER TABLE "Module" ADD COLUMN "description" TEXT;
+42
View File
@@ -31,6 +31,8 @@ model User {
submissions HomeworkSubmission[] submissions HomeworkSubmission[]
comments LessonComment[] comments LessonComment[]
feedbacks HomeworkFeedback[] feedbacks HomeworkFeedback[]
accessLogs AccessLog[] @relation("AccessLogUser")
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
} }
model Session { model Session {
@@ -77,6 +79,16 @@ model Verification {
// LMS core tables // LMS core tables
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
model Category {
id String @id @default(cuid())
title String
slug String @unique
order Int @default(0)
createdAt DateTime @default(now())
courses Course[]
}
model Course { model Course {
id String @id @default(cuid()) id String @id @default(cuid())
slug String @unique slug String @unique
@@ -85,17 +97,21 @@ model Course {
coverImage String? coverImage String?
published Boolean @default(false) published Boolean @default(false)
order Int @default(0) order Int @default(0)
categoryId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
modules Module[] modules Module[]
enrollments CourseEnrollment[] enrollments CourseEnrollment[]
accessLogs AccessLog[]
} }
model Module { model Module {
id String @id @default(cuid()) id String @id @default(cuid())
courseId String courseId String
title String title String
description String?
order Int @default(0) order Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -138,6 +154,7 @@ model CourseEnrollment {
userId String userId String
courseId String courseId String
enrolledAt DateTime @default(now()) enrolledAt DateTime @default(now())
expiresAt DateTime?
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@ -145,6 +162,21 @@ model CourseEnrollment {
@@id([userId, courseId]) @@id([userId, courseId])
} }
model AccessLog {
id String @id @default(cuid())
courseId String
userId String
action String // "granted" | "revoked"
method String @default("manual")
grantedById String?
note String?
createdAt DateTime @default(now())
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation("AccessLogUser", fields: [userId], references: [id], onDelete: Cascade)
grantedBy User? @relation("AccessLogAdmin", fields: [grantedById], references: [id], onDelete: SetNull)
}
model LessonProgress { model LessonProgress {
userId String userId String
lessonId String lessonId String
@@ -264,3 +296,13 @@ model LessonComment {
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
// ─────────────────────────────────────────────
// Platform Settings (key-value store)
// ─────────────────────────────────────────────
model Settings {
key String @id
value String @db.Text
updatedAt DateTime @updatedAt
}
+32 -11
View File
@@ -39,9 +39,9 @@ export function LoginForm() {
} }
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-5">
<div> <div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Email Email
</label> </label>
<input <input
@@ -49,12 +49,20 @@ export function LoginForm() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400" className="w-full px-3 py-2 text-sm bg-transparent"
style={{
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="you@example.com" placeholder="you@example.com"
/> />
</div> </div>
<div> <div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Пароль Пароль
</label> </label>
<input <input
@@ -62,21 +70,34 @@ export function LoginForm() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400" className="w-full px-3 py-2 text-sm bg-transparent"
style={{
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••" placeholder="••••••••"
/> />
</div> </div>
{error && <p className="text-red-500 text-sm">{error}</p>} {error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>
{error}
</p>
)}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50" className="btn-aubade w-full justify-center"
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
> >
{loading ? "Вход..." : "Войти"} {loading ? "Вход..." : "Войти"}
</button> </button>
<p className="text-center text-sm text-gray-500"> <p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
Нет аккаунта?{" "} Нет аккаунта?{" "}
<Link href="/register" className="text-amber-600 hover:underline"> <Link href="/register" className="underline" style={{ color: "var(--foreground)" }}>
Зарегистрироваться Зарегистрироваться
</Link> </Link>
</p> </p>
+9 -5
View File
@@ -2,13 +2,17 @@ import { LoginForm } from "./login-form";
export default function LoginPage() { export default function LoginPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-amber-50"> <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-md"> <div className="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
<p className="text-amber-700 mt-2">Войдите в свой аккаунт</p> Second Brain
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}>
Образовательная платформа
</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8"> <div className="card-aubade p-8">
<LoginForm /> <LoginForm />
</div> </div>
</div> </div>
+9 -5
View File
@@ -2,13 +2,17 @@ import { RegisterForm } from "./register-form";
export default function RegisterPage() { export default function RegisterPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-amber-50"> <div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-md"> <div className="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-3xl font-bold text-amber-900">Second Brain</h1> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
<p className="text-amber-700 mt-2">Создайте аккаунт</p> Second Brain
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}>
Образовательная платформа
</p>
</div> </div>
<div className="bg-white rounded-2xl shadow-sm border border-amber-100 p-8"> <div className="card-aubade p-8">
<RegisterForm /> <RegisterForm />
</div> </div>
</div> </div>
+38 -22
View File
@@ -1,12 +1,10 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { signUp } from "@/lib/auth-client"; import { signUp } from "@/lib/auth-client";
export function RegisterForm() { export function RegisterForm() {
const router = useRouter();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
@@ -14,6 +12,16 @@ export function RegisterForm() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
} as React.CSSProperties;
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
@@ -35,14 +43,11 @@ export function RegisterForm() {
return ( return (
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<div className="text-4xl"></div> <div className="text-4xl"></div>
<h2 className="text-xl font-semibold text-gray-800"> <p className="font-bold">Проверьте почту</p>
Проверьте почту <p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
</h2> Мы отправили письмо на <strong>{email}</strong> для подтверждения аккаунта.
<p className="text-gray-500">
Мы отправили письмо на <strong>{email}</strong> для подтверждения
аккаунта.
</p> </p>
<Link href="/login" className="text-amber-600 hover:underline text-sm"> <Link href="/login" className="text-sm underline" style={{ color: "var(--foreground)" }}>
Вернуться к входу Вернуться к входу
</Link> </Link>
</div> </div>
@@ -51,8 +56,8 @@ export function RegisterForm() {
return ( return (
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Имя Имя
</label> </label>
<input <input
@@ -60,12 +65,14 @@ export function RegisterForm() {
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
required required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400" style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="Иван Иванов" placeholder="Иван Иванов"
/> />
</div> </div>
<div> <div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Email Email
</label> </label>
<input <input
@@ -73,12 +80,14 @@ export function RegisterForm() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required required
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400" style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="you@example.com" placeholder="you@example.com"
/> />
</div> </div>
<div> <div className="space-y-1">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Пароль Пароль
</label> </label>
<input <input
@@ -87,21 +96,28 @@ export function RegisterForm() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required required
minLength={8} minLength={8}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400" style={inputStyle}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
placeholder="Минимум 8 символов" placeholder="Минимум 8 символов"
/> />
</div> </div>
{error && <p className="text-red-500 text-sm">{error}</p>} {error && (
<p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error}
</p>
)}
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full bg-amber-500 hover:bg-amber-600 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50" className="btn-aubade btn-aubade-accent w-full py-2 text-sm"
style={{ opacity: loading ? 0.6 : 1 }}
> >
{loading ? "Регистрация..." : "Зарегистрироваться"} {loading ? "Регистрация..." : "Зарегистрироваться"}
</button> </button>
<p className="text-center text-sm text-gray-500"> <p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
Уже есть аккаунт?{" "} Уже есть аккаунт?{" "}
<Link href="/login" className="text-amber-600 hover:underline"> <Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>
Войти Войти
</Link> </Link>
</p> </p>
@@ -0,0 +1,69 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { CourseSidebar } from "@/components/student/course-sidebar";
interface Props {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}
export default async function CourseLayout({ children, params }: Props) {
const { slug } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const isAdmin = session.user.role === "admin";
const course = await prisma.course.findUnique({
where: { slug, ...(isAdmin ? {} : { published: true }) },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { order: "asc" },
select: { id: true, title: true },
},
},
},
},
});
if (!course) notFound();
if (!isAdmin) {
const enrollment = await prisma.courseEnrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
});
if (!enrollment) redirect("/dashboard");
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) {
redirect("/dashboard?expired=1");
}
}
// Fetch completed lesson IDs for this user
const allLessonIds = course.modules.flatMap((m) => m.lessons.map((l) => l.id));
const progressRecords = isAdmin
? []
: await prisma.lessonProgress.findMany({
where: { userId: session.user.id, lessonId: { in: allLessonIds } },
select: { lessonId: true },
});
const completedLessonIds = new Set(progressRecords.map((p) => p.lessonId));
return (
<div className="flex flex-1">
<CourseSidebar course={course} completedLessonIds={completedLessonIds} />
<main className="flex-1 min-w-0 overflow-y-auto">
{children}
</main>
</div>
);
}
@@ -0,0 +1,29 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
export async function toggleLessonProgress(lessonId: string, slug: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const existing = await prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
});
if (existing) {
await prisma.lessonProgress.delete({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
});
} else {
await prisma.lessonProgress.create({
data: { userId: session.user.id, lessonId },
});
}
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
revalidatePath(`/courses/${slug}`);
revalidatePath("/dashboard");
}
@@ -0,0 +1,62 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function addComment(lessonId: string, slug: string, text: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const trimmed = text.trim();
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
// Verify user has access to this lesson's course
const lesson = await prisma.lesson.findUnique({
where: { id: lessonId },
select: { module: { select: { course: { select: { id: true } } } } },
});
if (!lesson) throw new Error("Lesson not found");
const isAdmin = session.user.role === "admin";
if (!isAdmin) {
const enrollment = await prisma.courseEnrollment.findUnique({
where: {
userId_courseId: {
userId: session.user.id,
courseId: lesson.module.course.id,
},
},
});
if (!enrollment) throw new Error("Not enrolled");
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
}
await prisma.lessonComment.create({
data: { lessonId, userId: session.user.id, text: trimmed },
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
export async function deleteComment(commentId: string, lessonId: string, slug: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
if (!comment) throw new Error("Not found");
const canDelete =
comment.userId === session.user.id ||
session.user.role === "curator" ||
session.user.role === "admin";
if (!canDelete) throw new Error("Forbidden");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
@@ -0,0 +1,69 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { sendHomeworkSubmittedEmail } from "@/lib/email";
interface HomeworkFile {
name: string;
url: string;
size: number;
}
export async function submitHomework(
homeworkId: string,
slug: string,
lessonId: string,
text: string,
files: HomeworkFile[]
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized");
const existing = await prisma.homeworkSubmission.findFirst({
where: { homeworkId, userId: session.user.id },
include: { feedbacks: true },
});
// Don't allow resubmission if feedback already given
if (existing?.feedbacks && existing.feedbacks.length > 0) {
throw new Error("Работа уже проверена");
}
let submissionId: string;
if (existing) {
const updated = await prisma.homeworkSubmission.update({
where: { id: existing.id },
data: { text, files: files as object[], submittedAt: new Date() },
});
submissionId = updated.id;
} else {
const created = await prisma.homeworkSubmission.create({
data: { homeworkId, userId: session.user.id, text, files: files as object[] },
});
submissionId = created.id;
// Notify admins/curators on first submission only
const [lesson, admins] = await Promise.all([
prisma.homework.findUnique({
where: { id: homeworkId },
include: { lesson: { select: { title: true } } },
}),
prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
}),
]);
if (lesson) {
await Promise.all(
admins.map((a) =>
sendHomeworkSubmittedEmail(a.email, a.name, session.user.name, lesson.lesson.title, submissionId)
)
);
}
}
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
}
@@ -0,0 +1,209 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { KinescopePlayer } from "@/components/player/kinescope-player";
import { LessonContent } from "@/components/student/lesson-content";
import { LessonCompleteButton } from "@/components/student/lesson-complete-button";
import { HomeworkSection } from "@/components/student/homework-section";
import { LessonComments } from "@/components/student/lesson-comments";
interface Props {
params: Promise<{ slug: string; lessonId: string }>;
}
export default async function LessonPage({ params }: Props) {
const { slug, lessonId } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const isAdmin = session?.user.role === "admin";
const [lesson, progress, comments] = await Promise.all([
prisma.lesson.findUnique({
where: { id: lessonId, ...(isAdmin ? {} : { published: true }) },
include: {
files: { orderBy: { createdAt: "asc" } },
homework: true,
module: {
include: {
course: {
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { order: "asc" },
select: { id: true, title: true },
},
},
},
},
},
},
},
},
}),
session && !isAdmin
? prisma.lessonProgress.findUnique({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
})
: null,
prisma.lessonComment.findMany({
where: { lessonId },
orderBy: { createdAt: "asc" },
include: { user: { select: { id: true, name: true } } },
}),
]);
// Fetch homework submission for this student
const homeworkSubmission = lesson?.homework && session && !isAdmin
? await prisma.homeworkSubmission.findFirst({
where: { homeworkId: lesson.homework.id, userId: session.user.id },
include: { feedbacks: { include: { curator: { select: { name: true } } }, orderBy: { createdAt: "desc" } } },
})
: null;
if (!lesson || lesson.module.course.slug !== slug) notFound();
const isCompleted = !!progress;
// Build ordered flat list of all lessons for prev/next
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
const idx = allLessons.findIndex((l) => l.id === lessonId);
const prevLesson = idx > 0 ? allLessons[idx - 1] : null;
const nextLesson = idx < allLessons.length - 1 ? allLessons[idx + 1] : null;
const hasContent = lesson.content && Object.keys(lesson.content as object).length > 0;
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
return (
<article className="max-w-3xl mx-auto px-6 py-8">
{/* Title */}
<h1 className="text-2xl font-bold mb-6 leading-snug">{lesson.title}</h1>
{/* Video */}
{lesson.kinescopeId && (
<div className="mb-8">
<KinescopePlayer videoId={lesson.kinescopeId} />
</div>
)}
{/* Text content */}
{hasContent && (
<div className="mb-8">
<LessonContent content={lesson.content as object} />
</div>
)}
{/* Files */}
{lesson.files.length > 0 && (
<div className="mb-8">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Материалы урока
</p>
<div className="space-y-2">
{lesson.files.map((file) => (
<a
key={file.id}
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors"
style={{ border: "2px solid var(--border)", display: "flex" }}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<span className="text-lg">📎</span>
<span className="flex-1 font-medium">{file.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(file.size)}
</span>
<span className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Скачать
</span>
</a>
))}
</div>
</div>
)}
{/* Homework */}
{lesson.homework && !isAdmin && (
<div className="mb-8">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Домашнее задание
</p>
<HomeworkSection
homework={lesson.homework}
submission={homeworkSubmission ? {
...homeworkSubmission,
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
} : null}
slug={slug}
lessonId={lessonId}
/>
</div>
)}
{/* Complete button + Prev/Next navigation */}
<div
className="flex items-center justify-between pt-6 mt-6"
style={{ borderTop: "2px solid var(--border)" }}
>
{prevLesson ? (
<Link
href={`/courses/${slug}/lessons/${prevLesson.id}`}
className="btn-aubade text-sm max-w-[40%]"
>
{prevLesson.title}
</Link>
) : (
<div />
)}
{!isAdmin && (
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} />
)}
{nextLesson ? (
<Link
href={`/courses/${slug}/lessons/${nextLesson.id}`}
className="btn-aubade btn-aubade-accent text-sm max-w-[40%] text-right"
>
{nextLesson.title}
</Link>
) : (
<div className="text-sm" style={{ color: "var(--muted-foreground)" }}>
{isAdmin ? "Последний урок курса" : ""}
</div>
)}
</div>
{/* Comments */}
{session && (
<div
className="mt-10 pt-8"
style={{ borderTop: "2px solid var(--border)" }}
>
<p className="text-xs font-bold uppercase tracking-widest mb-6" style={{ color: "var(--muted-foreground)" }}>
Обсуждение ({comments.filter((c) => !c.deleted).length})
</p>
<LessonComments
lessonId={lessonId}
slug={slug}
comments={comments}
currentUserId={session.user.id}
currentUserRole={session.user.role ?? "student"}
/>
</div>
)}
</article>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ slug: string }>;
}
export default async function CoursePage({ params }: Props) {
const { slug } = await params;
const course = await prisma.course.findUnique({
where: { slug, published: true },
include: {
modules: {
orderBy: { order: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { order: "asc" },
select: { id: true },
take: 1,
},
},
},
},
});
if (!course) notFound();
// Redirect to the first published lesson
for (const mod of course.modules) {
if (mod.lessons.length > 0) {
redirect(`/courses/${slug}/lessons/${mod.lessons[0].id}`);
}
}
// No lessons yet — show placeholder
return (
<div className="p-10 text-center">
<p className="text-4xl mb-4">📭</p>
<p className="font-bold text-lg">Уроков пока нет</p>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
Курс в разработке. Загляните позже.
</p>
</div>
);
}
+139 -19
View File
@@ -1,33 +1,153 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button"; import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function StudentDashboard() { export default async function StudentDashboard() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login"); if (!session) redirect("/login");
const enrollments = await prisma.courseEnrollment.findMany({
where: { userId: session.user.id },
include: {
course: {
include: {
modules: {
include: {
lessons: {
where: { published: true },
select: { id: true },
},
},
},
_count: { select: { modules: true } },
},
},
},
orderBy: { enrolledAt: "desc" },
});
const now = new Date();
const active = enrollments.filter((e) => !e.expiresAt || e.expiresAt > now);
const expired = enrollments.filter((e) => e.expiresAt && e.expiresAt <= now);
// Fetch progress for all lessons in active courses
const allLessonIds = active.flatMap((e) =>
e.course.modules.flatMap((m) => m.lessons.map((l) => l.id))
);
const progressRecords = allLessonIds.length > 0
? await prisma.lessonProgress.findMany({
where: { userId: session.user.id, lessonId: { in: allLessonIds } },
select: { lessonId: true },
})
: [];
const completedSet = new Set(progressRecords.map((p) => p.lessonId));
return ( return (
<div className="min-h-screen bg-amber-50"> <main className="max-w-4xl mx-auto px-6 py-10 w-full">
<header className="bg-white border-b border-amber-100 px-6 py-4 flex items-center justify-between"> <h1 className="text-2xl font-bold mb-1">Мои курсы</h1>
<h1 className="text-xl font-bold text-amber-900">Second Brain</h1> <p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
<div className="flex items-center gap-4"> {active.length} активных курсов
<span className="text-sm text-gray-600">{session.user.name}</span> </p>
<LogoutButton />
</div> {active.length === 0 ? (
</header> <div className="card-aubade p-12 text-center">
<main className="max-w-4xl mx-auto px-6 py-10">
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
Добро пожаловать, {session.user.name}!
</h2>
<p className="text-gray-500 mb-8">Ваши курсы появятся здесь.</p>
<div className="bg-white rounded-2xl border border-amber-100 p-8 text-center text-gray-400">
<p className="text-4xl mb-3">📚</p> <p className="text-4xl mb-3">📚</p>
<p>Доступных курсов пока нет.</p> <p className="font-medium">Доступных курсов пока нет</p>
<p className="text-sm mt-1">Обратитесь к администратору за доступом.</p> <p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
Обратитесь к администратору за доступом
</p>
</div> </div>
) : (
<div className="grid gap-4 sm:grid-cols-2">
{active.map(({ course, expiresAt }) => {
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
const completedLessons = course.modules
.flatMap((m) => m.lessons)
.filter((l) => completedSet.has(l.id)).length;
const progressPct = totalLessons > 0
? Math.round((completedLessons / totalLessons) * 100)
: 0;
return (
<Link
key={course.id}
href={`/courses/${course.slug}`}
className="card-aubade p-0 overflow-hidden flex flex-col"
>
{course.coverImage ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={course.coverImage} alt={course.title} className="w-full aspect-video object-cover" />
) : (
<div className="w-full aspect-video flex items-center justify-center text-4xl" style={{ backgroundColor: "var(--color-surface)" }}>
📚
</div>
)}
<div className="p-4 flex-1 flex flex-col gap-2">
<h2 className="font-bold text-base leading-tight">{course.title}</h2>
{course.description && (
<p className="text-xs line-clamp-2" style={{ color: "var(--muted-foreground)" }}>
{course.description}
</p>
)}
{/* Progress bar */}
{totalLessons > 0 && (
<div className="mt-auto">
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{completedLessons} из {totalLessons} уроков
</span>
<span
className="text-xs font-bold"
style={{ color: progressPct === 100 ? "var(--foreground)" : "var(--muted-foreground)" }}
>
{progressPct === 100 ? "✓ Завершён" : `${progressPct}%`}
</span>
</div>
<div className="h-1.5 w-full" style={{ background: "var(--border)" }}>
<div
className="h-full transition-all"
style={{
width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
}}
/>
</div>
</div>
)}
{expiresAt && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Доступ до {new Date(expiresAt).toLocaleDateString("ru-RU")}
</p>
)}
</div>
</Link>
);
})}
</div>
)}
{expired.length > 0 && (
<div className="mt-10">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Доступ истёк
</p>
<div className="space-y-2">
{expired.map(({ course, expiresAt }) => (
<div key={course.id} className="flex items-center justify-between px-4 py-3 opacity-50" style={{ border: "2px solid var(--border)" }}>
<span className="text-sm font-medium">{course.title}</span>
<span className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Истёк {new Date(expiresAt!).toLocaleDateString("ru-RU")}
</span>
</div>
))}
</div>
</div>
)}
</main> </main>
</div>
); );
} }
+35
View File
@@ -0,0 +1,35 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
import { getSetting } from "@/lib/settings";
export default async function StudentLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
// Maintenance mode: non-admin users see the maintenance page
if (session.user.role !== "admin") {
const maintenance = await getSetting("maintenanceMode");
if (maintenance === "true") redirect("/maintenance");
}
return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
<header
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3"
style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }}
>
<Link href="/dashboard" className="font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
Second Brain
</Link>
<div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--muted-foreground)" }}>{session.user.name}</span>
<LogoutButton />
</div>
</header>
<div className="flex-1 flex flex-col">{children}</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
}
function slugify(str: string) {
const map: Record<string, string> = {
а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"y",
к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f",
х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"shch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya",
};
return str.toLowerCase()
.replace(/[а-яё]/g, (c) => map[c] ?? c)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export async function createCategory(formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = (formData.get("slug") as string).trim() || slugify(title);
const count = await prisma.category.count();
await prisma.category.create({ data: { title, slug, order: count } });
revalidatePath("/admin/categories");
}
export async function updateCategory(id: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = formData.get("slug") as string;
await prisma.category.update({ where: { id }, data: { title, slug } });
revalidatePath("/admin/categories");
}
export async function deleteCategory(id: string) {
await requireAdmin();
await prisma.category.delete({ where: { id } });
revalidatePath("/admin/categories");
revalidatePath("/admin/courses");
}
+60
View File
@@ -0,0 +1,60 @@
import { prisma } from "@/lib/prisma";
import { CategoryRow } from "@/components/admin/category-row";
import { createCategory } from "./actions";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export default async function CategoriesPage() {
const categories = await prisma.category.findMany({
orderBy: { order: "asc" },
include: { _count: { select: { courses: true } } },
});
return (
<div className="p-8 max-w-2xl">
<div className="mb-6">
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>Категории</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>
{categories.length} категорий
</p>
</div>
<div className="space-y-2 mb-8">
{categories.length === 0 ? (
<p className="text-sm py-4" style={{ color: "var(--muted-foreground)" }}>
Категорий пока нет. Создайте первую.
</p>
) : (
categories.map((cat) => (
<CategoryRow key={cat.id} category={cat} courseCount={cat._count.courses} />
))
)}
</div>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Новая категория
</p>
<form action={createCategory} className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
Название
</label>
<Input name="title" placeholder="Obsidian PKM" required />
</div>
<div>
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
Slug
</label>
<Input name="slug" placeholder="obsidian-pkm (авто)" />
</div>
</div>
<div className="flex justify-end">
<Button type="submit">+ Создать</Button>
</div>
</form>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function adminDeleteComment(commentId: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath("/admin/comments");
}
+114
View File
@@ -0,0 +1,114 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { Suspense } from "react";
import { CommentsTable } from "@/components/admin/comments-table";
export const metadata = { title: "Комментарии" };
const PAGE_SIZE = 30;
interface Props {
searchParams: Promise<{ page?: string; search?: string }>;
}
export default async function AdminCommentsPage({ searchParams }: Props) {
const { page = "1", search = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
const where = {
deleted: false,
...(search
? {
OR: [
{ user: { name: { contains: search, mode: "insensitive" as const } } },
{ user: { email: { contains: search, mode: "insensitive" as const } } },
{ text: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
};
const [comments, total] = await Promise.all([
prisma.lessonComment.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: PAGE_SIZE,
include: {
user: { select: { id: true, name: true, email: true } },
lesson: {
select: {
id: true,
title: true,
module: {
select: {
course: { select: { slug: true, title: true } },
},
},
},
},
},
}),
prisma.lessonComment.count({ where }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
params.set("page", String(p));
return `/admin/comments?${params.toString()}`;
}
return (
<div className="p-8">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Комментарии
</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{total} активных комментариев
</p>
</div>
<Suspense>
<CommentsTable comments={comments} search={search} />
</Suspense>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center gap-1 mt-4">
{currentPage > 1 && (
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}></span>
) : (
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
{p}
</Link>
)
)}
{currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span>
</div>
)}
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { sendCourseAccessEmail } from "@/lib/email";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
// ── Modules ──────────────────────────────────────────────────────────────────
export async function createModule(courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const count = await prisma.module.count({ where: { courseId } });
const mod = await prisma.module.create({ data: { courseId, title, order: count } });
revalidatePath(`/admin/courses/${courseId}`);
redirect(`/admin/courses/${courseId}/modules/${mod.id}`);
}
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const description = (formData.get("description") as string | null)?.trim() || null;
await prisma.module.update({ where: { id: moduleId }, data: { title, description } });
revalidatePath(`/admin/courses/${courseId}`);
}
export async function deleteModule(moduleId: string, courseId: string) {
await requireAdmin();
await prisma.module.delete({ where: { id: moduleId } });
revalidatePath(`/admin/courses/${courseId}`);
}
export async function reorderModules(courseId: string, orderedIds: string[]) {
await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
prisma.module.update({ where: { id }, data: { order: index } })
)
);
revalidatePath(`/admin/courses/${courseId}`);
}
// ── Enrollment ───────────────────────────────────────────────────────────────
export async function grantAccess(
courseId: string,
userId: string,
expiresAt?: string | null,
note?: string
) {
const session = await requireAdmin();
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt: expiresAt ? new Date(expiresAt) : null },
create: { userId, courseId, expiresAt: expiresAt ? new Date(expiresAt) : null },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "manual",
grantedById: session.user.id,
note: note || null,
},
});
// Send email notification
const [user, course] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
]);
if (user && course) {
await sendCourseAccessEmail(user.email, user.name, course.title);
}
revalidatePath(`/admin/courses/${courseId}`);
}
export async function revokeAccess(courseId: string, userId: string, note?: string) {
const session = await requireAdmin();
await prisma.courseEnrollment.delete({
where: { userId_courseId: { userId, courseId } },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "revoked",
method: "manual",
grantedById: session.user.id,
note: note || null,
},
});
revalidatePath(`/admin/courses/${courseId}`);
}
@@ -0,0 +1,90 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
}
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const kinescopeId = (formData.get("kinescopeId") as string | null)?.trim() || null;
const count = await prisma.lesson.count({ where: { moduleId } });
const lesson = await prisma.lesson.create({
data: { moduleId, title, kinescopeId, order: count },
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
redirect(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`);
}
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
await prisma.lesson.update({ where: { id: lessonId }, data: { title } });
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function deleteLesson(lessonId: string, courseId: string, moduleId: string) {
await requireAdmin();
await prisma.lesson.delete({ where: { id: lessonId } });
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function reorderLessons(moduleId: string, courseId: string, orderedIds: string[]) {
await requireAdmin();
await Promise.all(
orderedIds.map((id, index) =>
prisma.lesson.update({ where: { id }, data: { order: index } })
)
);
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
}
export async function toggleLessonPublished(
lessonId: string,
courseId: string,
moduleId: string,
currentValue: boolean
) {
await requireAdmin();
await prisma.lesson.update({
where: { id: lessonId },
data: { published: !currentValue },
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
revalidatePath(`/admin/courses/${courseId}`);
}
export async function moveLessonToModule(
lessonId: string,
targetModuleId: string,
courseId: string,
sourceModuleId: string
) {
await requireAdmin();
// verify target module belongs to same course
const target = await prisma.module.findFirst({
where: { id: targetModuleId, courseId },
});
if (!target) throw new Error("Module not found");
const maxOrder = await prisma.lesson.aggregate({
where: { moduleId: targetModuleId },
_max: { order: true },
});
await prisma.lesson.update({
where: { id: lessonId },
data: { moduleId: targetModuleId, order: (maxOrder._max.order ?? -1) + 1 },
});
revalidatePath(`/admin/courses/${courseId}/modules/${sourceModuleId}`);
revalidatePath(`/admin/courses/${courseId}/modules/${targetModuleId}`);
revalidatePath(`/admin/courses/${courseId}`);
}
@@ -0,0 +1,36 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
}
export async function saveLesson(
lessonId: string,
courseId: string,
moduleId: string,
data: {
title: string;
kinescopeId: string;
content: object;
published: boolean;
}
) {
await requireAdmin();
await prisma.lesson.update({
where: { id: lessonId },
data: {
title: data.title,
kinescopeId: data.kinescopeId || null,
content: data.content,
published: data.published,
},
});
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`);
}
@@ -0,0 +1,27 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
}
export async function saveHomework(lessonId: string, description: string) {
await requireAdmin();
await prisma.homework.upsert({
where: { lessonId },
update: { description },
create: { lessonId, description },
});
revalidatePath(`/admin/courses`);
}
export async function deleteHomework(lessonId: string) {
await requireAdmin();
await prisma.homework.delete({ where: { lessonId } });
revalidatePath(`/admin/courses`);
}
@@ -0,0 +1,86 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { LessonEditor } from "@/components/admin/lesson-editor";
import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
import { HomeworkEditor } from "@/components/admin/homework-editor";
interface Props {
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
}
export default async function LessonEditorPage({ params }: Props) {
const { courseId, moduleId, lessonId } = await params;
const [lesson, siblings] = await Promise.all([
prisma.lesson.findUnique({
where: { id: lessonId },
include: {
files: { orderBy: { createdAt: "asc" } },
homework: true,
module: {
include: { course: { select: { title: true, slug: true } } },
},
},
}),
prisma.lesson.findMany({
where: { moduleId },
orderBy: { order: "asc" },
select: { id: true, title: true },
}),
]);
if (!lesson || lesson.moduleId !== moduleId) notFound();
const idx = siblings.findIndex((l) => l.id === lessonId);
const prevLesson = idx > 0 ? siblings[idx - 1] : null;
const nextLesson = idx < siblings.length - 1 ? siblings[idx + 1] : null;
return (
<div className="p-8 max-w-4xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span>
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{lesson.module.course.title}</Link>
<span className="mx-2">/</span>
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:underline">{lesson.module.title}</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{lesson.title}</span>
</nav>
{/* Lesson editor */}
<div className="card-aubade p-6 mb-6">
<LessonEditor
lesson={{
id: lesson.id,
title: lesson.title,
kinescopeId: lesson.kinescopeId ?? "",
content: (lesson.content as object) ?? {},
published: lesson.published,
}}
courseId={courseId}
moduleId={moduleId}
courseSlug={lesson.module.course.slug}
prevLesson={prevLesson}
nextLesson={nextLesson}
/>
</div>
{/* Files section */}
<div className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Файлы и материалы
</p>
<LessonFilesManager lessonId={lessonId} initialFiles={lesson.files} />
</div>
{/* Homework section */}
<div className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Домашнее задание
</p>
<HomeworkEditor lessonId={lessonId} initial={lesson.homework} />
</div>
</div>
);
}
@@ -0,0 +1,60 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { SortableLessons } from "@/components/admin/sortable-lessons";
interface Props {
params: Promise<{ courseId: string; moduleId: string }>;
}
export default async function ModulePage({ params }: Props) {
const { courseId, moduleId } = await params;
const [module, allModules] = await Promise.all([
prisma.module.findUnique({
where: { id: moduleId },
include: {
course: { select: { title: true } },
lessons: { orderBy: { order: "asc" } },
},
}),
prisma.module.findMany({
where: { courseId, NOT: { id: moduleId } },
select: { id: true, title: true },
orderBy: { order: "asc" },
}),
]);
if (!module || module.courseId !== courseId) notFound();
return (
<div className="p-8 max-w-3xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span>
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{module.course.title}</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{module.title}</span>
</nav>
<div className="mb-6">
<h1 className="text-2xl font-bold">{module.title}</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{module.lessons.length} {module.lessons.length === 1 ? "урок" : module.lessons.length < 5 ? "урока" : "уроков"}
</p>
</div>
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Уроки модуля
</p>
<SortableLessons
courseId={courseId}
moduleId={moduleId}
lessons={module.lessons}
otherModules={allModules}
/>
</section>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { CourseEditForm } from "@/components/admin/course-edit-form";
import { SortableModules } from "@/components/admin/sortable-modules";
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
import { CourseTree } from "@/components/admin/course-tree";
interface Props {
params: Promise<{ courseId: string }>;
}
export default async function CourseDetailPage({ params }: Props) {
const { courseId } = await params;
const [course, allStudents, categories] = await Promise.all([
prisma.course.findUnique({
where: { id: courseId },
include: {
modules: {
orderBy: { order: "asc" },
include: {
_count: { select: { lessons: true } },
lessons: {
orderBy: { order: "asc" },
select: { id: true, title: true, published: true, kinescopeId: true },
},
},
},
enrollments: {
select: { userId: true, expiresAt: true },
},
accessLogs: {
orderBy: { createdAt: "desc" },
take: 50,
include: {
user: { select: { name: true } },
grantedBy: { select: { name: true } },
},
},
},
}),
prisma.user.findMany({
where: { role: "student" },
select: { id: true, name: true, email: true },
orderBy: { name: "asc" },
}),
prisma.category.findMany({ orderBy: { order: "asc" } }),
]);
if (!course) notFound();
return (
<div className="p-8 max-w-4xl">
{/* Breadcrumb */}
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{course.title}</span>
</nav>
{/* Course metadata */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Основная информация
</p>
<CourseEditForm course={course} categories={categories} />
</section>
{/* Modules */}
<section className="card-aubade p-6 mb-6">
<div className="flex items-center justify-between mb-5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Модули
</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{course.modules.length} модулей
</span>
</div>
<SortableModules courseId={courseId} modules={course.modules} />
</section>
{/* Course tree overview */}
{course.modules.length > 0 && (
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Структура курса
</p>
<CourseTree courseId={courseId} modules={course.modules} />
</section>
)}
{/* Access management */}
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Управление доступом
</p>
<EnrollmentManager
courseId={courseId}
allStudents={allStudents}
enrollments={course.enrollments}
accessLogs={course.accessLogs}
/>
</section>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { revalidatePath } from "next/cache";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
}
function slugify(str: string): string {
const map: Record<string, string> = {
а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"y",
к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f",
х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"shch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya",
};
return str.toLowerCase()
.replace(/[а-яё]/g, (c) => map[c] ?? c)
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
export async function createCourse(formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = (formData.get("slug") as string).trim() || slugify(title);
const description = (formData.get("description") as string) || null;
const course = await prisma.course.create({
data: { title, slug, description },
});
revalidatePath("/admin/courses");
redirect(`/admin/courses/${course.id}`);
}
export async function updateCourse(courseId: string, formData: FormData) {
await requireAdmin();
const title = formData.get("title") as string;
const slug = formData.get("slug") as string;
const description = (formData.get("description") as string) || null;
const published = formData.get("published") === "true";
const coverImage = (formData.get("coverImage") as string) || null;
const categoryId = (formData.get("categoryId") as string) || null;
await prisma.course.update({
where: { id: courseId },
data: { title, slug, description, published, coverImage, categoryId },
});
revalidatePath("/admin/courses");
revalidatePath(`/admin/courses/${courseId}`);
}
export async function deleteCourse(courseId: string) {
await requireAdmin();
await prisma.course.delete({ where: { id: courseId } });
revalidatePath("/admin/courses");
redirect("/admin/courses");
}
+56
View File
@@ -0,0 +1,56 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { CreateCourseDialog } from "@/components/admin/create-course-dialog";
export default async function CoursesPage() {
const courses = await prisma.course.findMany({
orderBy: { order: "asc" },
include: { _count: { select: { modules: true, enrollments: true } } },
});
return (
<div className="p-8">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-slate-800">Курсы</h1>
<p className="text-slate-500 text-sm mt-0.5">{courses.length} курсов</p>
</div>
<CreateCourseDialog />
</div>
{courses.length === 0 ? (
<div className="bg-white border border-slate-200 rounded-2xl p-12 text-center">
<p className="text-4xl mb-3">📚</p>
<p className="text-slate-600 font-medium">Курсов пока нет</p>
<p className="text-slate-400 text-sm mt-1">Создайте первый курс</p>
</div>
) : (
<div className="space-y-2">
{courses.map((course) => (
<Link
key={course.id}
href={`/admin/courses/${course.id}`}
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4 hover:border-amber-300 transition-colors group"
>
<div className="flex items-center gap-3">
<div>
<p className="font-medium text-slate-800 group-hover:text-amber-700">{course.title}</p>
<p className="text-xs text-slate-400 mt-0.5">/{course.slug}</p>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-slate-400">{course._count.modules} модулей</span>
<span className="text-sm text-slate-400">{course._count.enrollments} учеников</span>
<Badge variant={course.published ? "default" : "secondary"}>
{course.published ? "Опубликован" : "Черновик"}
</Badge>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+189 -33
View File
@@ -1,46 +1,202 @@
import { headers } from "next/headers"; import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth"; import Link from "next/link";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button";
export default async function AdminDashboard() { export default async function AdminDashboard() {
const session = await auth.api.getSession({ headers: await headers() }); const now = new Date();
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
if (!session) redirect("/login"); const [
if (session.user.role !== "admin") redirect("/dashboard"); totalStudents,
newStudentsMonth,
totalCourses,
publishedCourses,
activeEnrollments,
expiringWeek,
homeworkPending,
homeworkTotal,
progressTotal,
] = await Promise.all([
prisma.user.count({ where: { role: "student" } }),
prisma.user.count({ where: { role: "student", createdAt: { gte: monthAgo } } }),
prisma.course.count(),
prisma.course.count({ where: { published: true } }),
prisma.courseEnrollment.count({
where: { OR: [{ expiresAt: null }, { expiresAt: { gt: now } }] },
}),
prisma.courseEnrollment.count({
where: { expiresAt: { gt: now, lte: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) } },
}),
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
prisma.homeworkSubmission.count(),
prisma.lessonProgress.count(),
]);
// Recent enrollments
const recentEnrollments = await prisma.courseEnrollment.findMany({
orderBy: { enrolledAt: "desc" },
take: 8,
include: {
user: { select: { name: true, email: true } },
course: { select: { title: true } },
},
});
// Most active courses (by enrollment count)
const topCourses = await prisma.course.findMany({
where: { published: true },
include: { _count: { select: { enrollments: true, modules: true } } },
orderBy: { enrollments: { _count: "desc" } },
take: 5,
});
return ( return (
<div className="min-h-screen bg-slate-50"> <div className="p-8 max-w-5xl">
<header className="bg-white border-b border-slate-200 px-6 py-4 flex items-center justify-between"> <h1 className="text-2xl font-bold mb-1">Обзор</h1>
<h1 className="text-xl font-bold text-slate-900">Second Brain Админ</h1> <p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
<div className="flex items-center gap-4"> Платформа Second Brain · {now.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })}
<span className="text-sm text-gray-600">{session.user.name}</span> </p>
<LogoutButton />
{/* Stats grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<StatCard
label="Студентов"
value={totalStudents}
sub={`+${newStudentsMonth} за месяц`}
href="/admin/users"
/>
<StatCard
label="Курсов"
value={totalCourses}
sub={`${publishedCourses} опубликовано`}
href="/admin/courses"
/>
<StatCard
label="Активных доступов"
value={activeEnrollments}
sub={expiringWeek > 0 ? `${expiringWeek} истекает на неделе` : "нет истекающих"}
subAccent={expiringWeek > 0}
href="/admin/courses"
/>
<StatCard
label="ДЗ на проверку"
value={homeworkPending}
sub={`${homeworkTotal} всего сдано`}
subAccent={homeworkPending > 0}
href="/curator/homework"
/>
</div>
<div className="grid md:grid-cols-2 gap-6">
{/* Recent enrollments */}
<div className="card-aubade p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Последние зачисления
</p>
<Link href="/admin/users" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Все
</Link>
</div>
{recentEnrollments.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет зачислений</p>
) : (
<div className="space-y-2">
{recentEnrollments.map((e) => (
<div key={`${e.userId}-${e.courseId}`} className="flex items-center gap-3 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{e.user.name}</p>
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>{e.course.title}</p>
</div>
<span className="text-xs shrink-0" style={{ color: "var(--muted-foreground)" }}>
{new Date(e.enrolledAt).toLocaleDateString("ru-RU")}
</span>
</div>
))}
</div>
)}
</div>
{/* Top courses + progress stat */}
<div className="space-y-6">
<div className="card-aubade p-5">
<div className="flex items-center justify-between mb-4">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Популярные курсы
</p>
<Link href="/admin/courses" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Все
</Link>
</div>
{topCourses.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет курсов</p>
) : (
<div className="space-y-3">
{topCourses.map((c) => (
<div key={c.id} className="flex items-center gap-3 text-sm">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{c.title}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{c._count.modules} модулей
</p>
</div>
<div className="text-right shrink-0">
<p className="font-bold">{c._count.enrollments}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>студентов</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Активность
</p>
<div className="flex items-end gap-6">
<div>
<p className="text-3xl font-bold">{progressTotal}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уроков пройдено</p>
</div>
<div>
<p className="text-3xl font-bold">{homeworkTotal}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>работ сдано</p>
</div> </div>
</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>
<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>
<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>
</div> </div>
</main>
</div> </div>
); );
} }
function StatCard({
label,
value,
sub,
subAccent,
href,
}: {
label: string;
value: number;
sub?: string;
subAccent?: boolean;
href?: string;
}) {
const content = (
<div className="card-aubade p-4">
<p className="text-3xl font-bold">{value}</p>
<p className="text-xs font-bold uppercase tracking-widest mt-1" style={{ color: "var(--muted-foreground)" }}>
{label}
</p>
{sub && (
<p className="text-xs mt-1.5" style={{ color: subAccent ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}>
{sub}
</p>
)}
</div>
);
return href ? <Link href={href}>{content}</Link> : content;
}
@@ -0,0 +1,260 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import iconv from "iconv-lite";
import { sendWelcomeEmail } from "@/lib/email";
// ── Types ─────────────────────────────────────────────────────────────────────
export type ParsedRow = {
index: number;
email: string;
name: string;
lastName: string;
phone: string;
// resolved during preview
status: "new" | "update" | "error";
errorMsg?: string;
existingId?: string;
};
export type PreviewResult = {
rows: ParsedRow[];
countNew: number;
countUpdate: number;
countError: number;
};
export type ImportOptions = {
updateExisting: boolean;
autoVerifyEmail: boolean;
courseId?: string;
accessDays: number; // 0 = unlimited
sendWelcome: boolean;
encoding: "utf8" | "win1251";
};
export type ApplyResult = {
created: number;
updated: number;
errors: string[];
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else {
inQuotes = !inQuotes;
}
} else if ((ch === "," || ch === ";") && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += ch;
}
}
result.push(current.trim());
return result;
}
function normalizeHeaders(headers: string[]): Record<string, number> {
const map: Record<string, number> = {};
const aliases: Record<string, string[]> = {
email: ["email", "e-mail", "почта", "login", "логин"],
name: ["имя", "name", "firstname", "first_name", "имя пользователя"],
lastName: ["фамилия", "lastname", "last_name", "surname"],
phone: ["телефон", "phone", "tel", "мобильный"],
};
headers.forEach((h, i) => {
const lower = h.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "");
for (const [field, aliasList] of Object.entries(aliases)) {
if (aliasList.some((a) => lower.includes(a.toLowerCase().replace(/[^a-zа-яё0-9]/gi, "")))) {
if (!(field in map)) map[field] = i;
}
}
});
return map;
}
async function assertAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
return session;
}
// ── Parse action ──────────────────────────────────────────────────────────────
export async function parseCSV(
base64: string,
encoding: "utf8" | "win1251",
updateExisting: boolean
): Promise<PreviewResult> {
await assertAdmin();
// Decode bytes
const buffer = Buffer.from(base64, "base64");
const text = encoding === "win1251"
? iconv.decode(buffer, "win1251")
: buffer.toString("utf8");
// Split lines (handle \r\n and \n)
const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
const nonEmpty = lines.filter((l) => l.trim().length > 0);
if (nonEmpty.length < 2) {
return { rows: [], countNew: 0, countUpdate: 0, countError: 0 };
}
const headerLine = parseCSVLine(nonEmpty[0]);
const colMap = normalizeHeaders(headerLine);
if (colMap.email === undefined) {
throw new Error("Не найдена колонка Email. Проверьте заголовки CSV-файла.");
}
// Load existing emails for fast lookup
const existingUsers = await prisma.user.findMany({
select: { id: true, email: true },
});
const existingByEmail = new Map(existingUsers.map((u) => [u.email.toLowerCase(), u.id]));
const rows: ParsedRow[] = [];
let countNew = 0, countUpdate = 0, countError = 0;
for (let i = 1; i < nonEmpty.length; i++) {
const cols = parseCSVLine(nonEmpty[i]);
const email = (cols[colMap.email] ?? "").trim().toLowerCase();
const name = (cols[colMap.name ?? -1] ?? "").trim();
const lastName = (cols[colMap.lastName ?? -1] ?? "").trim();
const phone = (cols[colMap.phone ?? -1] ?? "").trim();
const row: ParsedRow = {
index: i,
email,
name: [name, lastName].filter(Boolean).join(" ") || email.split("@")[0],
lastName,
phone,
status: "new",
};
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
row.status = "error";
row.errorMsg = "Некорректный email";
countError++;
} else if (existingByEmail.has(email)) {
row.existingId = existingByEmail.get(email);
if (updateExisting) {
row.status = "update";
countUpdate++;
} else {
row.status = "error";
row.errorMsg = "Уже существует (обновление отключено)";
countError++;
}
} else {
row.status = "new";
countNew++;
}
rows.push(row);
}
return { rows, countNew, countUpdate, countError };
}
// ── Apply action ──────────────────────────────────────────────────────────────
export async function applyImport(
rows: ParsedRow[],
options: ImportOptions
): Promise<ApplyResult> {
await assertAdmin();
let created = 0, updated = 0;
const errors: string[] = [];
const validRows = rows.filter((r) => r.status !== "error");
for (const row of validRows) {
try {
if (row.status === "new") {
// Generate a random password
const rawPassword = Math.random().toString(36).slice(-10) + "A1!";
const hashedPassword = await bcrypt.hash(rawPassword, 10);
const user = await prisma.user.create({
data: {
name: row.name,
email: row.email,
emailVerified: options.autoVerifyEmail,
role: "student",
},
});
await prisma.account.create({
data: {
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
},
});
if (options.courseId) {
const expiresAt = options.accessDays > 0
? new Date(Date.now() + options.accessDays * 86_400_000)
: null;
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId: user.id, courseId: options.courseId } },
update: { expiresAt },
create: { userId: user.id, courseId: options.courseId, expiresAt },
});
}
if (options.sendWelcome) {
await sendWelcomeEmail(user.email, user.name).catch(() => {});
}
created++;
} else if (row.status === "update" && row.existingId) {
await prisma.user.update({
where: { id: row.existingId },
data: {
name: row.name || undefined,
},
});
if (options.courseId) {
const expiresAt = options.accessDays > 0
? new Date(Date.now() + options.accessDays * 86_400_000)
: null;
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId: row.existingId, courseId: options.courseId } },
update: { expiresAt },
create: { userId: row.existingId, courseId: options.courseId, expiresAt },
});
}
updated++;
}
} catch (e) {
errors.push(`${row.email}: ${e instanceof Error ? e.message : "Ошибка"}`);
}
}
return { created, updated, errors };
}
+49
View File
@@ -0,0 +1,49 @@
import { prisma } from "@/lib/prisma";
import { CsvImporter } from "@/components/admin/csv-importer";
import { CsvExporter } from "@/components/admin/csv-exporter";
export const metadata = { title: "Импорт и экспорт" };
export default async function ImportExportPage() {
const courses = await prisma.course.findMany({
orderBy: { title: "asc" },
select: { id: true, title: true },
});
return (
<div className="p-8 max-w-3xl">
<div className="mb-8">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Импорт и экспорт
</h1>
</div>
<div className="space-y-6">
{/* Import */}
<div className="card-aubade p-6">
<p
className="text-xs font-bold uppercase tracking-widest mb-5"
style={{ color: "var(--muted-foreground)" }}
>
Импорт учеников из CSV
</p>
<CsvImporter courses={courses} />
</div>
{/* Export */}
<div className="card-aubade p-6">
<p
className="text-xs font-bold uppercase tracking-widest mb-5"
style={{ color: "var(--muted-foreground)" }}
>
Экспорт учеников в CSV
</p>
<CsvExporter courses={courses} />
</div>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { AdminShell } from "@/components/admin/admin-shell";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "admin") redirect("/dashboard");
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
}
+27
View File
@@ -0,0 +1,27 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { SETTINGS_DEFAULTS, type SettingsKey } from "@/lib/settings";
export async function saveSettings(data: Record<string, string>) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
const validKeys = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
const ops = validKeys
.filter((key) => key in data)
.map((key) =>
prisma.settings.upsert({
where: { key },
update: { value: data[key] },
create: { key, value: data[key] },
})
);
await Promise.all(ops);
revalidatePath("/admin/settings");
revalidatePath("/", "layout");
}
+22
View File
@@ -0,0 +1,22 @@
import { getSettings } from "@/lib/settings";
import { SettingsForm } from "@/components/admin/settings-form";
export const metadata = { title: "Настройки платформы" };
export default async function SettingsPage() {
const settings = await getSettings();
return (
<div className="p-8 max-w-2xl">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Настройки платформы
</h1>
</div>
<SettingsForm initial={settings} />
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
export async function bulkGrantAccess(
userId: string,
courseIds: string[],
expiresAt?: string | null
) {
const session = await requireAdmin();
const expiry = expiresAt ? new Date(expiresAt) : null;
await Promise.all(
courseIds.map(async (courseId) => {
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt: expiry },
create: { userId, courseId, expiresAt: expiry },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "bulk",
grantedById: session.user.id,
},
});
})
);
revalidatePath(`/admin/users/${userId}`);
}
export async function revokeUserAccess(userId: string, courseId: string) {
const session = await requireAdmin();
await prisma.courseEnrollment.delete({
where: { userId_courseId: { userId, courseId } },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "revoked",
method: "manual",
grantedById: session.user.id,
},
});
revalidatePath(`/admin/users/${userId}`);
}
+105
View File
@@ -0,0 +1,105 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
interface Props {
params: Promise<{ userId: string }>;
}
export default async function UserPage({ params }: Props) {
const { userId } = await params;
const [user, allCourses] = await Promise.all([
prisma.user.findUnique({
where: { id: userId },
include: {
enrollments: {
include: { course: { select: { id: true, title: true, published: true } } },
orderBy: { enrolledAt: "desc" },
},
accessLogs: {
orderBy: { createdAt: "desc" },
take: 30,
include: {
course: { select: { title: true } },
grantedBy: { select: { name: true } },
},
},
},
}),
prisma.course.findMany({
orderBy: { title: "asc" },
select: { id: true, title: true, published: true },
}),
]);
if (!user) notFound();
const roleLabel: Record<string, string> = { admin: "Администратор", curator: "Куратор", student: "Ученик" };
return (
<div className="p-8 max-w-3xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/users" className="hover:underline">Пользователи</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{user.name}</span>
</nav>
{/* User info */}
<section className="card-aubade p-6 mb-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-bold">{user.name}</h1>
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</div>
<div className="flex flex-col items-end gap-1">
<span className="tag-aubade">{roleLabel[user.role] ?? user.role}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
с {new Date(user.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
</div>
</section>
{/* Enrollments + bulk grant */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Доступ к курсам
</p>
<UserEnrollmentManager
userId={userId}
allCourses={allCourses}
enrollments={user.enrollments.map((e) => ({
courseId: e.courseId,
expiresAt: e.expiresAt,
courseTitle: e.course.title,
}))}
/>
</section>
{/* Access log */}
{user.accessLogs.length > 0 && (
<section className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
История доступа
</p>
<div className="space-y-1.5 max-h-72 overflow-y-auto">
{user.accessLogs.map((log) => (
<div key={log.id} className="flex items-center gap-3 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
<span style={{ color: log.action === "granted" ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", fontWeight: 700, minWidth: 70 }}>
{log.action === "granted" ? "▲ Выдан" : "▼ Отозван"}
</span>
<span className="flex-1">{log.course.title}</span>
<span style={{ color: "var(--muted-foreground)" }}>{log.grantedBy?.name ?? "—"}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{new Date(log.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
</div>
</section>
)}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
import { sendWelcomeEmail } from "@/lib/email";
export async function createUser(data: {
name: string;
email: string;
password: string;
role: string;
emailVerified: boolean;
sendWelcome: boolean;
}): Promise<{ success: true; userId: string } | { success: false; error: string }> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return { success: false, error: "Нет доступа" };
}
const { name, email, password, role, emailVerified, sendWelcome } = data;
if (!name.trim() || !email.trim() || !password.trim()) {
return { success: false, error: "Заполните все обязательные поля" };
}
const existing = await prisma.user.findUnique({ where: { email } });
if (existing) {
return { success: false, error: "Пользователь с таким email уже существует" };
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { name: name.trim(), email: email.trim().toLowerCase(), role, emailVerified },
});
// Create credential account (Better Auth's internal structure)
await prisma.account.create({
data: {
userId: user.id,
accountId: user.id,
providerId: "credential",
password: hashedPassword,
},
});
if (sendWelcome) {
await sendWelcomeEmail(user.email, user.name).catch(() => {});
}
return { success: true, userId: user.id };
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link";
import { CreateUserForm } from "@/components/admin/create-user-form";
export const metadata = { title: "Новый пользователь" };
export default function NewUserPage() {
return (
<div className="p-8">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/users" className="hover:underline">Пользователи</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>Новый пользователь</span>
</nav>
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Создание пользователя
</h1>
</div>
<div className="card-aubade p-6">
<CreateUserForm />
</div>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { UserPlus } from "lucide-react";
import { UsersTable } from "@/components/admin/users-table";
import { Suspense } from "react";
import { UsersSearch } from "@/components/admin/users-search";
const PAGE_SIZE = 20;
interface Props {
searchParams: Promise<{ search?: string; role?: string; page?: string }>;
}
export default async function UsersPage({ searchParams }: Props) {
const { search = "", role = "", page = "1" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
const where = {
...(search
? {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
}
: {}),
...(role ? { role } : {}),
};
const [users, total] = await Promise.all([
prisma.user.findMany({
where,
orderBy: { createdAt: "desc" },
skip,
take: PAGE_SIZE,
include: {
_count: { select: { enrollments: true } },
enrollments: {
include: { course: { select: { title: true } } },
orderBy: { enrolledAt: "desc" },
},
},
}),
prisma.user.count({ where }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
const tableUsers = users.map((u) => ({
id: u.id,
name: u.name,
email: u.email,
role: u.role,
emailVerified: u.emailVerified,
createdAt: u.createdAt,
enrollmentCount: u._count.enrollments,
enrollments: u.enrollments.map((e) => ({
courseId: e.courseId,
courseTitle: e.course.title,
expiresAt: e.expiresAt,
})),
}));
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (role) params.set("role", role);
params.set("page", String(p));
return `/admin/users?${params.toString()}`;
}
return (
<div className="p-8">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
<p className="text-slate-500 text-sm mt-0.5">{total} пользователей</p>
</div>
<Link
href="/admin/users/new"
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
>
<UserPlus size={14} />
Добавить пользователя
</Link>
</div>
{/* Filters */}
<Suspense>
<UsersSearch initialSearch={search} initialRole={role} />
</Suspense>
<UsersTable users={tableUsers} />
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center gap-1 mt-4">
{currentPage > 1 && (
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}></span>
) : (
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
{p}
</Link>
)
)}
{currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span>
</div>
)}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import iconv from "iconv-lite";
export async function GET(request: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Нет доступа" }, { status: 403 });
}
const { searchParams } = request.nextUrl;
const courseId = searchParams.get("courseId") || undefined;
const encoding = (searchParams.get("encoding") as "utf8" | "win1251") ?? "utf8";
// Fetch users
const users = await prisma.user.findMany({
where: courseId
? { enrollments: { some: { courseId } } }
: { role: "student" },
orderBy: { createdAt: "desc" },
include: {
enrollments: {
include: { course: { select: { title: true } } },
},
progress: { select: { lessonId: true } },
},
});
// Build CSV rows
const csvHeaders = ["Email", "Имя", "Телефон", "Дата регистрации", "Курсы", "Прогресс (уроков)"];
const rows = users.map((u) => {
const courses = u.enrollments.map((e) => e.course.title).join(" | ");
const progress = u.progress.length;
const registeredAt = new Date(u.createdAt).toLocaleDateString("ru-RU");
return [u.email, u.name, "", registeredAt, courses, String(progress)];
});
const allRows = [csvHeaders, ...rows];
const csvText = allRows
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(";"))
.join("\r\n");
// Encode
let body: Buffer;
let charset: string;
if (encoding === "win1251") {
body = iconv.encode(csvText, "win1251");
charset = "windows-1251";
} else {
body = Buffer.from("\uFEFF" + csvText, "utf8"); // BOM for Excel
charset = "utf-8";
}
const filename = `students_${new Date().toISOString().slice(0, 10)}.csv`;
return new NextResponse(body as unknown as BodyInit, {
headers: {
"Content-Type": `text/csv; charset=${charset}`,
"Content-Disposition": `attachment; filename="${filename}"`,
},
});
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import matter from "gray-matter";
import { mdToTiptap } from "@/lib/md-to-tiptap";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const formData = await req.formData();
const file = formData.get("file") as File | null;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
if (!file.name.endsWith(".md")) {
return NextResponse.json({ error: "Only .md files are supported" }, { status: 400 });
}
const raw = await file.text();
const { data: fm, content } = matter(raw);
// Extract known frontmatter fields (Obsidian-compatible naming)
const title =
typeof fm.title === "string" ? fm.title.trim() : null;
const kinescopeId =
(fm.kinescopeId ?? fm.kinescope_id ?? fm.videoId ?? fm.video_id ?? "") as string;
const order =
typeof fm.order === "number" ? fm.order : null;
const published =
typeof fm.published === "boolean" ? fm.published : null;
const tiptapContent = mdToTiptap(content);
return NextResponse.json({ title, kinescopeId, order, published, content: tiptapContent });
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { uploadFile, deleteFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
const lessonId = form.get("lessonId") as string | null;
if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `lessons/${lessonId}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
const lessonFile = await prisma.lessonFile.create({
data: { lessonId, name: file.name, url, size: file.size },
});
return NextResponse.json(lessonFile);
}
export async function DELETE(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { fileId, key } = await req.json();
if (key) await deleteFile(key).catch(() => {});
await prisma.lessonFile.delete({ where: { id: fileId } });
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `uploads/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ url, key });
}
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `homework/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ name: file.name, url, size: file.size });
}
+43 -29
View File
@@ -1,43 +1,57 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/layout/logout-button"; import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function CuratorDashboard() { export default async function CuratorDashboard() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login"); if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") {
redirect("/dashboard"); const [pending, total, recentFeedbacks] = await Promise.all([
} prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
prisma.homeworkSubmission.count(),
prisma.homeworkFeedback.count({
where: {
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
curatorId: session.user.id,
},
}),
]);
return ( return (
<div className="min-h-screen bg-green-50"> <div className="p-8 max-w-3xl">
<header className="bg-white border-b border-green-100 px-6 py-4 flex items-center justify-between"> <h1 className="text-2xl font-bold mb-1">Обзор</h1>
<h1 className="text-xl font-bold text-green-900">Second Brain Куратор</h1> <p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>Панель куратора</p>
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">{session.user.name}</span> <div className="grid grid-cols-3 gap-4 mb-8">
<LogoutButton /> <StatCard label="Ожидают проверки" value={pending} accent={pending > 0} />
<StatCard label="Всего сдано" value={total} />
<StatCard label="Проверено за 7 дней" value={recentFeedbacks} />
</div> </div>
</header>
<main className="max-w-4xl mx-auto px-6 py-10"> {pending > 0 ? (
<h2 className="text-2xl font-semibold text-gray-800 mb-2"> <Link href="/curator/homework" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
Панель куратора Перейти к проверке ({pending})
</h2> </Link>
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p> ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="card-aubade p-8 text-center">
<div className="bg-white rounded-2xl border border-green-100 p-6"> <p className="text-3xl mb-2"></p>
<p className="text-3xl mb-2">📝</p> <p className="font-bold">Все работы проверены</p>
<p className="font-medium text-gray-800">Домашние задания</p> <p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>Новых заданий нет</p>
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
</div> </div>
<div className="bg-white rounded-2xl border border-green-100 p-6"> )}
<p className="text-3xl mb-2">👥</p> </div>
<p className="font-medium text-gray-800">Мои ученики</p> );
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p> }
</div>
</div> function StatCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
</main> return (
<div className="card-aubade p-4">
<p className="text-3xl font-bold" style={{ color: accent ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
{value}
</p>
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{label}</p>
</div> </div>
); );
} }
@@ -0,0 +1,51 @@
"use server";
import { prisma } from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { sendFeedbackReceivedEmail } from "@/lib/email";
export async function submitFeedback(submissionId: string, text: string) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
throw new Error("Forbidden");
}
await prisma.homeworkFeedback.create({
data: { submissionId, curatorId: session.user.id, text },
});
// Send email to student
const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
user: { select: { email: true, name: true } },
homework: {
include: {
lesson: {
select: {
title: true,
module: { select: { course: { select: { slug: true } } } },
},
},
},
},
},
});
if (submission) {
const { lesson } = submission.homework;
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${submission.homework.lessonId}`;
await sendFeedbackReceivedEmail(
submission.user.email,
submission.user.name,
lesson.title,
text,
lessonUrl
);
}
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
}
@@ -0,0 +1,55 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { submitFeedback } from "./actions";
export function FeedbackForm({ submissionId }: { submissionId: string }) {
const [text, setText] = useState("");
const [pending, startTransition] = useTransition();
const router = useRouter();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!text.trim()) return;
startTransition(async () => {
await submitFeedback(submissionId, text.trim());
router.push("/curator/homework");
});
}
return (
<form onSubmit={handleSubmit} className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Написать фидбек
</p>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
required
placeholder="Напишите обратную связь студенту..."
style={{
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
resize: "vertical",
minHeight: "120px",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button
type="submit"
disabled={pending || !text.trim()}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }}
>
{pending ? "Отправка..." : "Отправить фидбек"}
</button>
</form>
);
}
@@ -0,0 +1,132 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { FeedbackForm } from "./feedback-form";
interface Props {
params: Promise<{ submissionId: string }>;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export default async function SubmissionPage({ params }: Props) {
const { submissionId } = await params;
const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
user: { select: { name: true, email: true } },
feedbacks: {
include: { curator: { select: { name: true } } },
orderBy: { createdAt: "desc" },
},
homework: {
include: {
lesson: {
select: {
title: true,
module: { select: { title: true, course: { select: { title: true } } } },
},
},
},
},
},
});
if (!submission) notFound();
const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
const isReviewed = submission.feedbacks.length > 0;
return (
<div className="p-8 max-w-2xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/curator/homework" className="hover:underline">ДЗ на проверку</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{submission.user.name}</span>
</nav>
{/* Meta */}
<div className="mb-6">
<h1 className="text-xl font-bold">{submission.homework.lesson.title}</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.module.title}
</p>
</div>
{/* Student info */}
<div className="flex items-center justify-between px-4 py-3 mb-6" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<div>
<p className="font-medium text-sm">{submission.user.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{submission.user.email}</p>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
</p>
</div>
{/* Homework description */}
<div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Задание</p>
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{submission.homework.description}
</div>
</div>
{/* Student answer */}
<div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Ответ студента</p>
{submission.text ? (
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
{submission.text}
</div>
) : (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>Текст не добавлен</p>
)}
</div>
{/* Files */}
{files.length > 0 && (
<div className="mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Прикреплённые файлы</p>
<div className="space-y-1">
{files.map((f) => (
<a
key={f.url}
href={f.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2 text-sm"
style={{ border: "2px solid var(--border)" }}
>
<span>📎</span>
<span className="flex-1 underline">{f.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
</a>
))}
</div>
</div>
)}
{/* Existing feedback */}
{submission.feedbacks.map((fb) => (
<div key={fb.id} className="mb-4 px-4 py-3" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-bold uppercase tracking-widest">Фидбек</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
</div>
))}
{/* Feedback form */}
{!isReviewed && <FeedbackForm submissionId={submissionId} />}
</div>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { HomeworkFilters } from "@/components/admin/homework-filters";
import { Suspense } from "react";
const PAGE_SIZE = 20;
interface Props {
searchParams: Promise<{
search?: string;
status?: string;
courseId?: string;
page?: string;
}>;
}
export default async function HomeworkListPage({ searchParams }: Props) {
const { search = "", status = "", courseId = "", page = "1" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE;
// Build where clause
const where = {
...(search
? {
user: {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ email: { contains: search, mode: "insensitive" as const } },
],
},
}
: {}),
...(courseId
? {
homework: {
lesson: { module: { courseId } },
},
}
: {}),
...(status === "pending" ? { feedbacks: { none: {} } } : {}),
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
};
const [submissions, total, courses] = await Promise.all([
prisma.homeworkSubmission.findMany({
where,
orderBy: { submittedAt: "desc" },
skip,
take: PAGE_SIZE,
include: {
user: { select: { name: true, email: true } },
feedbacks: { select: { id: true } },
homework: {
include: {
lesson: {
select: {
title: true,
module: { select: { title: true, course: { select: { id: true, title: true } } } },
},
},
},
},
},
}),
prisma.homeworkSubmission.count({ where }),
prisma.course.findMany({ orderBy: { title: "asc" }, select: { id: true, title: true } }),
]);
const totalPages = Math.ceil(total / PAGE_SIZE);
const pendingCount = submissions.filter((s) => s.feedbacks.length === 0).length;
function pageUrl(p: number) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (status) params.set("status", status);
if (courseId) params.set("courseId", courseId);
params.set("page", String(p));
return `/curator/homework?${params.toString()}`;
}
return (
<div className="p-8 max-w-3xl">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Домашние задания
</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{total} {total === 1 ? "работа" : "работ"} · {pendingCount} ожидают проверки
</p>
</div>
<Suspense>
<HomeworkFilters courses={courses} />
</Suspense>
{submissions.length === 0 ? (
<div className="card-aubade p-10 text-center">
<p className="text-3xl mb-2">📭</p>
<p className="font-bold">Ничего не найдено</p>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
Попробуйте изменить фильтры
</p>
</div>
) : (
<div className="space-y-1.5">
{submissions.map((s) => {
const isPending = s.feedbacks.length === 0;
return (
<Link
key={s.id}
href={`/curator/homework/${s.id}`}
className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80"
style={{
border: `2px solid ${isPending ? "var(--foreground)" : "var(--border)"}`,
display: "flex",
}}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{s.user.name}</p>
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
{s.homework.lesson.module.course.title} · {s.homework.lesson.title}
</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{s.user.email}
</p>
</div>
<div className="text-right shrink-0">
<span
className="text-xs px-2 py-0.5"
style={{
border: "1px solid var(--border)",
background: isPending ? "var(--foreground)" : "transparent",
color: isPending ? "var(--background)" : "var(--muted-foreground)",
}}
>
{isPending ? "Новое" : "Проверено"}
</span>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
</p>
</div>
</Link>
);
})}
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center gap-1 mt-5">
{currentPage > 1 && (
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}></span>
) : (
<Link
key={p}
href={pageUrl(p as number)}
className="px-3 py-1 text-xs"
style={{
border: "2px solid var(--border)",
background: p === currentPage ? "var(--foreground)" : "transparent",
color: p === currentPage ? "var(--background)" : "var(--foreground)",
}}
>
{p}
</Link>
)
)}
{currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>
стр. {currentPage} из {totalPages}
</span>
</div>
)}
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button";
import { AdminShell } from "@/components/admin/admin-shell";
import { getSetting } from "@/lib/settings";
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "curator" && session.user.role !== "admin") redirect("/dashboard");
// Maintenance mode: curators (non-admin) see the maintenance page
if (session.user.role === "curator") {
const maintenance = await getSetting("maintenanceMode");
if (maintenance === "true") redirect("/maintenance");
}
// Admin uses the admin shell with sidebar
if (session.user.role === "admin") {
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
}
return (
<div className="min-h-screen flex" style={{ backgroundColor: "var(--background)" }}>
{/* Sidebar */}
<aside className="w-52 shrink-0 flex flex-col min-h-screen" style={{ backgroundColor: "var(--sidebar-bg)" }}>
<div className="px-5 py-5" style={{ borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
<p className="text-sm font-bold tracking-wide" style={{ color: "#F5F5F0" }}>Second Brain</p>
<p className="text-xs mt-0.5 uppercase tracking-widest" style={{ color: "#888" }}>Куратор</p>
</div>
<nav className="flex-1 py-3 space-y-0.5 px-2">
<NavLink href="/curator/dashboard">Обзор</NavLink>
<NavLink href="/curator/homework">ДЗ на проверку</NavLink>
</nav>
<div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
<p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p>
<LogoutButton />
</div>
</aside>
{/* Content */}
<main className="flex-1 overflow-auto">
{children}
</main>
</div>
);
}
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link
href={href}
className="block px-3 py-2 text-sm rounded-sm transition-colors"
style={{ color: "#CCCCCC" }}
>
{children}
</Link>
);
}
+160 -13
View File
@@ -1,26 +1,173 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
:root { @custom-variant dark (&:is(.dark *));
--background: #ffffff;
--foreground: #171717;
}
/* ── Second Brain brand tokens ─────────────────────────────────────── */
@theme inline { @theme inline {
--font-sans: var(--font-fira), ui-monospace, monospace;
--font-mono: var(--font-fira), ui-monospace, monospace;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans); --color-card: var(--card);
--font-mono: var(--font-geist-mono); --color-card-foreground: var(--card-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
} }
@media (prefers-color-scheme: dark) { /* ── Light mode: Second Brain palette ──────────────────────────────── */
:root { :root {
--background: #0a0a0a; --background: #F5F5F0;
--foreground: #ededed; --foreground: #323232;
--card: #F5F5F0;
--card-foreground: #323232;
--popover: #F5F5F0;
--popover-foreground: #323232;
--primary: #323232;
--primary-foreground: #F5F5F0;
--secondary: #E8E8E0;
--secondary-foreground: #323232;
--muted: #E8E8E0;
--muted-foreground: #666666;
--accent: #E8F0D8;
--accent-foreground: #323232;
--destructive: oklch(0.577 0.245 27.325);
--border: #AAAAAA;
--input: #AAAAAA;
--ring: #323232;
--radius: 2px;
/* Aubade */
--aubade-thickness: 2px;
--aubade-shadow-offset: 4px;
--color-divider: #AAAAAA;
--color-hover: #D8D8D0;
--color-surface: #E8E8E0;
--color-highlight: #E8F0D8;
/* Admin sidebar */
--sidebar-bg: #2A2A28;
--sidebar-surface: #1E1E1C;
--sidebar-text: #b3b3b3;
--sidebar-border: #4A4A48;
--sidebar-highlight: #2A3A2A;
}
/* ── Base ────────────────────────────────────────────────────────────── */
@layer base {
* {
@apply border-border outline-ring/50;
box-sizing: border-box;
}
html {
font-family: var(--font-sans);
font-size: 16px;
}
body {
background-color: var(--background);
color: var(--foreground);
} }
} }
body { /* ── Aubade utility classes ─────────────────────────────────────────── */
background: var(--background); .card-aubade {
color: var(--foreground); border: var(--aubade-thickness) solid var(--color-divider);
font-family: Arial, Helvetica, sans-serif; box-shadow: var(--aubade-shadow-offset) var(--aubade-shadow-offset) 0 0 var(--color-divider);
background-color: var(--background);
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.card-aubade:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 0 var(--color-divider);
}
.btn-aubade {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 16px;
font-family: var(--font-sans);
font-size: 0.875rem;
font-weight: 500;
border: var(--aubade-thickness) solid var(--foreground);
box-shadow: var(--aubade-shadow-offset) var(--aubade-shadow-offset) 0 0 var(--foreground);
background-color: var(--background);
color: var(--foreground);
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
text-decoration: none;
}
.btn-aubade:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 0 var(--foreground);
}
.btn-aubade:active {
transform: translate(2px, 2px);
box-shadow: none;
}
.btn-aubade-accent {
background-color: var(--color-highlight);
border-color: var(--foreground);
box-shadow: var(--aubade-shadow-offset) var(--aubade-shadow-offset) 0 0 var(--foreground);
}
.tag-aubade {
background-color: var(--color-surface);
border: var(--aubade-thickness) solid transparent;
padding: 2px 8px;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.1em;
font-weight: 700;
transition: border-color 0.15s ease;
font-family: var(--font-sans);
}
.tag-aubade:hover {
border-color: var(--foreground);
}
/* Admin sidebar (dark) */
.admin-sidebar {
background-color: var(--sidebar-bg);
color: var(--sidebar-text);
}
.admin-sidebar-nav-link {
display: block;
padding: 8px 12px;
font-size: 0.875rem;
color: var(--sidebar-text);
text-decoration: none;
border-left: 2px solid transparent;
transition: color 0.15s, border-color 0.15s, background-color 0.15s;
}
.admin-sidebar-nav-link:hover {
color: #F5F5F0;
background-color: var(--sidebar-surface);
}
.admin-sidebar-nav-link.active {
color: #E8F0D8;
border-left-color: #E8F0D8;
background-color: var(--sidebar-surface);
} }
+27 -18
View File
@@ -1,33 +1,42 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Fira_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { getSettings } from "@/lib/settings";
const geistSans = Geist({ const firaMono = Fira_Mono({
variable: "--font-geist-sans", weight: ["400", "500", "700"],
subsets: ["latin"], subsets: ["latin", "cyrillic"],
variable: "--font-fira",
display: "swap",
}); });
const geistMono = Geist_Mono({ export async function generateMetadata(): Promise<Metadata> {
variable: "--font-geist-mono", const settings = await getSettings();
subsets: ["latin"], return {
}); title: `${settings.schoolName} — Обучение`,
description: settings.schoolDescription || "Образовательная платформа",
export const metadata: Metadata = { keywords: settings.schoolKeywords || undefined,
title: "Create Next App",
description: "Generated by create next app",
}; };
}
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const settings = await getSettings();
return ( return (
<html <html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
lang="en" {settings.headCode ? (
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} <head dangerouslySetInnerHTML={{ __html: settings.headCode }} />
> ) : null}
<body className="min-h-full flex flex-col">{children}</body> <body className="min-h-full flex flex-col">
{children}
{settings.bodyCode ? (
<div dangerouslySetInnerHTML={{ __html: settings.bodyCode }} />
) : null}
</body>
</html> </html>
); );
} }
+23
View File
@@ -0,0 +1,23 @@
export default function MaintenancePage() {
return (
<div
className="min-h-screen flex items-center justify-center p-8"
style={{ backgroundColor: "var(--background)" }}
>
<div className="card-aubade p-10 max-w-md w-full text-center space-y-4">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Технические работы
</p>
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>
Скоро вернёмся
</h1>
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Платформа временно недоступна. Мы проводим обновление пожалуйста, зайдите позже.
</p>
</div>
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
const links = [
{ href: "/admin/dashboard", label: "Обзор" },
{ href: "/admin/courses", label: "Курсы" },
{ href: "/admin/categories", label: "Категории" },
{ href: "/admin/users", label: "Пользователи" },
{ href: "/curator/homework", label: "ДЗ на проверку" },
{ href: "/admin/comments", label: "Комментарии" },
{ href: "/admin/import-export", label: "Импорт / Экспорт" },
{ href: "/admin/settings", label: "Настройки" },
];
export function AdminNav() {
const pathname = usePathname();
return (
<>
{links.map(({ href, label }) => {
const active =
pathname === href ||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
(href === "/curator/homework" && pathname.startsWith("/curator"));
return (
<Link
key={href}
href={href}
className="admin-sidebar-nav-link"
style={
active
? {
color: "#E8F0D8",
borderLeftColor: "#E8F0D8",
backgroundColor: "var(--sidebar-surface)",
}
: undefined
}
>
{label}
</Link>
);
})}
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { AdminNav } from "@/components/admin/admin-nav";
import { LogoutButton } from "@/components/layout/logout-button";
export function AdminShell({
children,
userName,
}: {
children: React.ReactNode;
userName: string;
}) {
return (
<div className="min-h-screen flex">
<aside
className="w-52 flex flex-col shrink-0 fixed h-full z-10"
style={{ backgroundColor: "var(--sidebar-bg)", color: "var(--sidebar-text)" }}
>
<div className="px-5 py-5" style={{ borderBottom: "2px solid var(--sidebar-border)" }}>
<p className="font-bold text-base tracking-wide" style={{ color: "#E8F0D8" }}>
Second Brain
</p>
<p className="text-xs mt-0.5 uppercase tracking-widest" style={{ color: "var(--sidebar-text)", fontSize: "0.6rem" }}>
Администратор
</p>
</div>
<nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto">
<AdminNav />
</nav>
<div className="px-4 py-4" style={{ borderTop: "2px solid var(--sidebar-border)" }}>
<p className="text-xs mb-3 truncate" style={{ color: "var(--sidebar-text)" }}>
{userName}
</p>
<LogoutButton />
</div>
</aside>
<div className="ml-52 flex-1 min-h-screen" style={{ backgroundColor: "var(--background)" }}>
{children}
</div>
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
"use client";
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { updateCategory, deleteCategory } from "@/app/admin/categories/actions";
interface Props {
category: { id: string; title: string; slug: string };
courseCount: number;
}
export function CategoryRow({ category, courseCount }: Props) {
const [editing, setEditing] = useState(false);
const [pending, startTransition] = useTransition();
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startTransition(() => updateCategory(category.id, fd));
setEditing(false);
}
function handleDelete() {
if (courseCount > 0) {
alert(`Нельзя удалить: к категории привязано ${courseCount} курсов`);
return;
}
if (!confirm(`Удалить категорию «${category.title}»?`)) return;
startTransition(() => deleteCategory(category.id));
}
return (
<div className="flex items-center gap-3 px-4 py-3" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
{editing ? (
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
<Input name="title" defaultValue={category.title} required className="h-8 text-sm" />
<Input name="slug" defaultValue={category.slug} className="h-8 text-sm w-36" />
<Button type="submit" size="sm" disabled={pending}>Сохранить</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}></Button>
</form>
) : (
<>
<span className="flex-1 font-medium" style={{ color: "var(--foreground)" }}>{category.title}</span>
<span className="text-xs font-mono" style={{ color: "var(--muted-foreground)" }}>/{category.slug}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{courseCount} курсов</span>
<button onClick={() => setEditing(true)} className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Редакт.
</button>
<button onClick={handleDelete} disabled={pending} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Удалить
</button>
</>
)}
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { useTransition } from "react";
import { Trash2, Search } from "lucide-react";
import Link from "next/link";
import { adminDeleteComment } from "@/app/admin/comments/actions";
type Comment = {
id: string;
text: string;
createdAt: Date;
user: { id: string; name: string; email: string };
lesson: {
id: string;
title: string;
module: { course: { slug: string; title: string } };
};
};
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
};
export function CommentsTable({ comments, search }: { comments: Comment[]; search: string }) {
const router = useRouter();
const pathname = usePathname();
const [pending, startTransition] = useTransition();
function updateSearch(value: string) {
const params = new URLSearchParams();
if (value) params.set("search", value);
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
function handleDelete(id: string) {
if (!confirm("Удалить комментарий?")) return;
startTransition(async () => {
await adminDeleteComment(id);
});
}
return (
<div>
{/* Search */}
<div className="flex gap-2 mb-4">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
<input
defaultValue={search}
placeholder="Поиск по автору или тексту"
style={{ ...inputStyle, paddingLeft: "2rem", width: 260 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
updateSearch(e.currentTarget.value.trim());
}}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/>
</div>
{search && (
<button type="button" onClick={() => startTransition(() => router.push(pathname))}
className="text-xs px-3" style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}>
Сбросить
</button>
)}
</div>
{comments.length === 0 ? (
<div className="card-aubade p-10 text-center">
<p className="font-bold">Комментариев нет</p>
</div>
) : (
<div style={{ border: "2px solid var(--border)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "2px solid var(--border)", background: "var(--background)" }}>
{["Автор", "Урок", "Комментарий", "Дата", ""].map((h) => (
<th key={h} className="text-left px-4 py-2.5 text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{comments.map((c) => {
const lessonUrl = `/courses/${c.lesson.module.course.slug}/lessons/${c.lesson.id}`;
return (
<tr key={c.id} style={{ borderBottom: "1px solid var(--border)", opacity: pending ? 0.6 : 1 }}>
<td className="px-4 py-3 whitespace-nowrap">
<Link href={`/admin/users/${c.user.id}`} className="font-medium hover:underline text-sm">
{c.user.name}
</Link>
<p className="text-xs font-mono" style={{ color: "var(--muted-foreground)" }}>{c.user.email}</p>
</td>
<td className="px-4 py-3">
<Link href={lessonUrl} className="text-xs hover:underline" target="_blank">
{c.lesson.title}
</Link>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{c.lesson.module.course.title}
</p>
</td>
<td className="px-4 py-3 max-w-xs">
<p className="text-xs line-clamp-2" style={{ color: "var(--foreground)" }}>{c.text}</p>
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs" style={{ color: "var(--muted-foreground)" }}>
{new Date(c.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</td>
<td className="px-4 py-3">
<button
type="button"
onClick={() => handleDelete(c.id)}
title="Удалить"
className="p-1.5 transition-opacity hover:opacity-60"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
<Trash2 size={14} />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
+124
View File
@@ -0,0 +1,124 @@
"use client";
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { updateCourse, deleteCourse } from "@/app/admin/courses/actions";
interface Course {
id: string;
title: string;
slug: string;
description: string | null;
coverImage: string | null;
published: boolean;
categoryId: string | null;
}
interface Category {
id: string;
title: string;
}
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
const [published, setPublished] = useState(course.published);
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
async function handleImageUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/upload", { method: "POST", body: fd });
const data = await res.json();
if (data.url) setCoverImage(data.url);
setUploading(false);
}
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
fd.set("published", String(published));
fd.set("coverImage", coverImage);
fd.set("categoryId", categoryId);
startTransition(() => updateCourse(course.id, fd));
}
function handleDelete() {
if (!confirm("Удалить курс? Это действие нельзя отменить.")) return;
startTransition(() => deleteCourse(course.id));
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="title">Название</Label>
<Input id="title" name="title" defaultValue={course.title} required />
</div>
<div className="space-y-1.5">
<Label htmlFor="slug">Slug</Label>
<Input id="slug" name="slug" defaultValue={course.slug} required />
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Описание</Label>
<Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} />
</div>
{categories.length > 0 && (
<div className="space-y-1.5">
<Label htmlFor="categoryId">Категория</Label>
<select
id="categoryId"
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full px-3 py-2 text-sm bg-transparent"
style={{ border: "2px solid var(--border)", color: "var(--foreground)", fontFamily: "var(--font-sans)" }}
>
<option value="">Без категории</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
</div>
)}
<div className="space-y-1.5">
<Label>Обложка</Label>
<div className="flex items-center gap-3">
{coverImage && (
// eslint-disable-next-line @next/next/no-img-element
<img src={coverImage} alt="cover" className="w-16 h-10 object-cover rounded-md border" />
)}
<Input type="file" accept="image/*" onChange={handleImageUpload} disabled={uploading} className="max-w-xs" />
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={published}
onClick={() => setPublished(!published)}
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
>
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
</button>
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
</div>
<div className="flex justify-between pt-2">
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
Удалить курс
</Button>
<Button type="submit" disabled={pending || uploading}>
{pending ? "Сохранение..." : "Сохранить"}
</Button>
</div>
</form>
);
}
+173
View File
@@ -0,0 +1,173 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { ChevronDown, ChevronRight, Video, FileText, Eye, EyeOff } from "lucide-react";
type Lesson = {
id: string;
title: string;
published: boolean;
kinescopeId: string | null;
};
type Module = {
id: string;
title: string;
description: string | null;
lessons: Lesson[];
};
export function CourseTree({
courseId,
modules,
}: {
courseId: string;
modules: Module[];
}) {
// All modules expanded by default
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(modules.map((m) => m.id))
);
function toggle(id: string) {
setExpanded((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
const totalLessons = modules.reduce((s, m) => s + m.lessons.length, 0);
const publishedLessons = modules.reduce(
(s, m) => s + m.lessons.filter((l) => l.published).length,
0
);
return (
<div>
{/* Summary bar */}
<div className="flex items-center gap-4 mb-4 text-xs" style={{ color: "var(--muted-foreground)" }}>
<span>{modules.length} модулей · {totalLessons} уроков</span>
<span
className="px-2 py-0.5"
style={{ border: "1px solid var(--border)" }}
>
<Eye size={10} className="inline mr-1" />
{publishedLessons} / {totalLessons} опубликовано
</span>
<button
type="button"
className="hover:underline"
onClick={() => setExpanded(new Set(modules.map((m) => m.id)))}
>
Развернуть все
</button>
<button
type="button"
className="hover:underline"
onClick={() => setExpanded(new Set())}
>
Свернуть все
</button>
</div>
<div className="space-y-1">
{modules.map((mod, mi) => {
const isOpen = expanded.has(mod.id);
const modPublished = mod.lessons.filter((l) => l.published).length;
return (
<div key={mod.id} style={{ border: "2px solid var(--border)" }}>
{/* Module header */}
<button
type="button"
onClick={() => toggle(mod.id)}
className="w-full flex items-center gap-2 px-4 py-2.5 text-left"
style={{ background: "var(--background)" }}
>
<span style={{ color: "var(--muted-foreground)", width: 16, flexShrink: 0 }}>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
<span className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)", minWidth: 20 }}>
{mi + 1}
</span>
<span className="flex-1 font-medium text-sm">{mod.title}</span>
{mod.description && (
<span className="text-xs hidden sm:block max-w-xs truncate" style={{ color: "var(--muted-foreground)" }}>
{mod.description}
</span>
)}
<span className="text-xs shrink-0" style={{ color: "var(--muted-foreground)" }}>
{modPublished}/{mod.lessons.length} уроков
</span>
<Link
href={`/admin/courses/${courseId}/modules/${mod.id}`}
onClick={(e) => e.stopPropagation()}
className="text-xs shrink-0 hover:underline ml-2"
style={{ color: "var(--muted-foreground)" }}
>
Редактировать
</Link>
</button>
{/* Lessons list */}
{isOpen && (
<div style={{ borderTop: "1px solid var(--border)" }}>
{mod.lessons.length === 0 ? (
<p className="px-10 py-2 text-xs" style={{ color: "var(--muted-foreground)" }}>
Уроков нет
</p>
) : (
mod.lessons.map((lesson, li) => (
<div
key={lesson.id}
className="flex items-center gap-2 px-4 py-1.5"
style={{
borderTop: li > 0 ? "1px solid var(--border)" : undefined,
background: "var(--background)",
}}
>
{/* Indent */}
<span className="w-5 shrink-0" />
{/* Index */}
<span className="text-xs w-6 shrink-0 text-right" style={{ color: "var(--muted-foreground)" }}>
{li + 1}
</span>
{/* Published indicator */}
<span
className="shrink-0"
title={lesson.published ? "Опубликован" : "Черновик"}
style={{ color: lesson.published ? "#3A6A3A" : "var(--muted-foreground)" }}
>
{lesson.published ? <Eye size={13} /> : <EyeOff size={13} />}
</span>
{/* Kinescope indicator */}
<span
className="shrink-0"
title={lesson.kinescopeId ? "Видео прикреплено" : "Без видео"}
style={{ color: lesson.kinescopeId ? "#3A6A3A" : "var(--border)" }}
>
{lesson.kinescopeId ? <Video size={13} /> : <FileText size={13} />}
</span>
{/* Title */}
<span className="flex-1 text-sm truncate">{lesson.title}</span>
{/* Edit link */}
<Link
href={`/admin/courses/${courseId}/modules/${mod.id}/lessons/${lesson.id}`}
className="text-xs shrink-0 hover:underline"
style={{ color: "var(--muted-foreground)" }}
>
Ред.
</Link>
</div>
))
)}
</div>
)}
</div>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { createCourse } from "@/app/admin/courses/actions";
export function CreateCourseDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 h-9 px-4 py-2 cursor-pointer">
+ Создать курс
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Новый курс</DialogTitle>
</DialogHeader>
<form action={createCourse} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="title">Название</Label>
<Input id="title" name="title" placeholder="Obsidian PKM" required />
</div>
<div className="space-y-1.5">
<Label htmlFor="slug">Slug (URL)</Label>
<Input id="slug" name="slug" placeholder="obsidian-pkm (авто если пусто)" />
</div>
<div className="space-y-1.5">
<Label htmlFor="description">Описание</Label>
<Textarea id="description" name="description" placeholder="Краткое описание курса" rows={3} />
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Отмена
</Button>
<Button type="submit">Создать</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
+226
View File
@@ -0,0 +1,226 @@
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Eye, EyeOff, RefreshCw } from "lucide-react";
import { createUser } from "@/app/admin/users/actions";
const ROLES = [
{ value: "student", label: "Ученик" },
{ value: "curator", label: "Куратор" },
{ value: "admin", label: "Администратор" },
];
function generatePassword() {
const chars = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789!@#$";
return Array.from({ length: 12 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
}
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
};
const focusHandlers = {
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
{label}
{required && <span style={{ color: "oklch(0.577 0.245 27.325)" }}> *</span>}
</label>
{children}
</div>
);
}
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex items-start gap-3">
<button type="button" onClick={() => onChange(!checked)} className="mt-0.5 flex-shrink-0">
<span
className="relative inline-block w-10 h-6 transition-colors"
style={{ background: checked ? "var(--accent)" : "var(--border)", border: "2px solid var(--foreground)" }}
>
<span
className="absolute top-0.5 w-4 h-4 transition-transform"
style={{ background: "var(--foreground)", left: "2px", transform: checked ? "translateX(16px)" : "translateX(0)" }}
/>
</span>
</button>
<div>
<p className="text-sm font-medium" style={{ color: "var(--foreground)" }}>{label}</p>
{hint && <p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>{hint}</p>}
</div>
</div>
);
}
export function CreateUserForm() {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [role, setRole] = useState("student");
const [emailVerified, setEmailVerified] = useState(true);
const [sendWelcome, setSendWelcome] = useState(true);
function handleGenerate() {
setPassword(generatePassword());
setShowPassword(true);
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
startTransition(async () => {
const result = await createUser({ name, email, password, role, emailVerified, sendWelcome });
if (!result.success) {
setError(result.error);
return;
}
router.push(`/admin/users/${result.userId}`);
router.refresh();
});
}
return (
<form onSubmit={handleSubmit} className="space-y-5 max-w-lg">
{/* Name */}
<Field label="Имя" required>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Иван Иванов"
required
style={inputStyle}
{...focusHandlers}
/>
</Field>
{/* Email */}
<Field label="Email (логин)" required>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
required
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
{/* Password */}
<Field label="Пароль" required>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Минимум 8 символов"
required
minLength={8}
style={{ ...inputStyle, paddingRight: "2.5rem", fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2"
style={{ color: "var(--muted-foreground)" }}
>
{showPassword ? <EyeOff size={15} /> : <Eye size={15} />}
</button>
</div>
<button
type="button"
onClick={handleGenerate}
title="Сгенерировать пароль"
className="btn-aubade px-3 flex items-center gap-1.5 text-xs whitespace-nowrap"
>
<RefreshCw size={13} />
Сгенерировать
</button>
</div>
{password && showPassword && (
<p className="text-xs mt-1 font-mono" style={{ color: "var(--muted-foreground)" }}>
{password}
</p>
)}
</Field>
{/* Role */}
<Field label="Роль">
<select
value={role}
onChange={(e) => setRole(e.target.value)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
{...focusHandlers}
>
{ROLES.map((r) => (
<option key={r.value} value={r.value}>{r.label}</option>
))}
</select>
</Field>
{/* Toggles */}
<div className="space-y-3 pt-1">
<Toggle
label="Email подтверждён"
hint="Пользователь сможет войти сразу, без подтверждения почты."
checked={emailVerified}
onChange={setEmailVerified}
/>
<Toggle
label="Отправить приветственное письмо"
hint="Письмо будет отправлено на указанный email."
checked={sendWelcome}
onChange={setSendWelcome}
/>
</div>
{/* Error */}
{error && (
<p className="text-sm px-3 py-2" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error}
</p>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={pending}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
style={{ opacity: pending ? 0.6 : 1 }}
>
{pending ? "Создание..." : "Создать пользователя"}
</button>
<button
type="button"
onClick={() => router.back()}
className="btn-aubade px-4 py-2 text-sm"
>
Отмена
</button>
</div>
</form>
);
}
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useState } from "react";
import { Download } from "lucide-react";
type Course = { id: string; title: string };
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
};
const focusHandlers = {
onFocus: (e: React.FocusEvent<HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
export function CsvExporter({ courses }: { courses: Course[] }) {
const [courseId, setCourseId] = useState("");
const [encoding, setEncoding] = useState<"utf8" | "win1251">("utf8");
const [loading, setLoading] = useState(false);
async function handleExport() {
setLoading(true);
try {
const params = new URLSearchParams({ encoding });
if (courseId) params.set("courseId", courseId);
const res = await fetch(`/api/admin/export-users?${params}`);
if (!res.ok) throw new Error("Ошибка сервера");
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const cd = res.headers.get("content-disposition") ?? "";
const match = cd.match(/filename="([^"]+)"/);
a.download = match?.[1] ?? "students.csv";
a.click();
URL.revokeObjectURL(url);
} finally {
setLoading(false);
}
}
return (
<div className="space-y-5 max-w-md">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Фильтр по курсу
</label>
<select value={courseId} onChange={(e) => setCourseId(e.target.value)} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
<option value="">Все ученики</option>
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
</select>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Кодировка файла
</label>
<select value={encoding} onChange={(e) => setEncoding(e.target.value as "utf8" | "win1251")} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
<option value="utf8">UTF-8 (универсальная)</option>
<option value="win1251">Windows-1251 (для Excel на Windows)</option>
</select>
</div>
<div className="p-4 space-y-1" style={{ border: "2px solid var(--border)" }}>
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Поля в файле</p>
<p className="text-sm">Email · Имя · Телефон · Дата регистрации · Курсы · Прогресс (уроков)</p>
</div>
<button
type="button"
onClick={handleExport}
disabled={loading}
className="btn-aubade btn-aubade-accent flex items-center gap-2 px-5 py-2 text-sm"
style={{ opacity: loading ? 0.6 : 1 }}
>
<Download size={14} />
{loading ? "Формирую файл..." : "Скачать CSV"}
</button>
</div>
);
}
+384
View File
@@ -0,0 +1,384 @@
"use client";
import { useState, useTransition, useRef } from "react";
import { Upload, FileText, CheckCircle, AlertCircle, Loader } from "lucide-react";
import {
parseCSV,
applyImport,
type PreviewResult,
type ImportOptions,
type ApplyResult,
} from "@/app/admin/import-export/import-actions";
type Course = { id: string; title: string };
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
};
const focusHandlers = {
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLSelectElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
function Toggle({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex items-start gap-3">
<button type="button" onClick={() => onChange(!checked)} className="mt-0.5 shrink-0">
<span className="relative inline-block w-10 h-6" style={{ background: checked ? "var(--accent)" : "var(--border)", border: "2px solid var(--foreground)" }}>
<span className="absolute top-0.5 w-4 h-4 transition-transform" style={{ background: "var(--foreground)", left: "2px", transform: checked ? "translateX(16px)" : "translateX(0)" }} />
</span>
</button>
<div>
<p className="text-sm font-medium">{label}</p>
{hint && <p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>{hint}</p>}
</div>
</div>
);
}
function StepIndicator({ step }: { step: number }) {
const steps = ["Загрузка", "Предпросмотр", "Опции", "Готово"];
return (
<div className="flex items-center gap-0 mb-6">
{steps.map((label, i) => {
const num = i + 1;
const active = num === step;
const done = num < step;
return (
<div key={num} className="flex items-center">
<div className="flex flex-col items-center gap-1">
<div
className="w-7 h-7 flex items-center justify-center text-xs font-bold"
style={{
border: "2px solid var(--foreground)",
background: done || active ? "var(--foreground)" : "transparent",
color: done || active ? "var(--background)" : "var(--foreground)",
}}
>
{done ? "✓" : num}
</div>
<span className="text-xs" style={{ color: active ? "var(--foreground)" : "var(--muted-foreground)", fontWeight: active ? 700 : 400 }}>
{label}
</span>
</div>
{i < steps.length - 1 && (
<div className="w-12 h-0.5 mx-1 mb-5" style={{ background: done ? "var(--foreground)" : "var(--border)" }} />
)}
</div>
);
})}
</div>
);
}
// ── Main component ────────────────────────────────────────────────────────────
export function CsvImporter({ courses }: { courses: Course[] }) {
const [step, setStep] = useState(1);
const [pending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
// Step 1 state
const [encoding, setEncoding] = useState<"utf8" | "win1251">("utf8");
const [updateExisting, setUpdateExisting] = useState(false);
const [fileBase64, setFileBase64] = useState<string | null>(null);
const [fileName, setFileName] = useState<string | null>(null);
// Step 2 state
const [preview, setPreview] = useState<PreviewResult | null>(null);
// Step 3 state
const [autoVerifyEmail, setAutoVerifyEmail] = useState(true);
const [courseId, setCourseId] = useState("");
const [accessDays, setAccessDays] = useState("0");
const [sendWelcome, setSendWelcome] = useState(false);
// Step 4 state
const [result, setResult] = useState<ApplyResult | null>(null);
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setFileName(file.name);
const reader = new FileReader();
reader.onload = () => {
const ab = reader.result as ArrayBuffer;
const bytes = new Uint8Array(ab);
let binary = "";
bytes.forEach((b) => (binary += String.fromCharCode(b)));
setFileBase64(btoa(binary));
};
reader.readAsArrayBuffer(file);
}
function handleParse() {
if (!fileBase64) return;
setError(null);
startTransition(async () => {
try {
const result = await parseCSV(fileBase64, encoding, updateExisting);
setPreview(result);
setStep(2);
} catch (e) {
setError(e instanceof Error ? e.message : "Ошибка разбора файла");
}
});
}
function handleApply() {
if (!preview) return;
setError(null);
const options: ImportOptions = {
updateExisting,
autoVerifyEmail,
courseId: courseId || undefined,
accessDays: parseInt(accessDays) || 0,
sendWelcome,
encoding,
};
startTransition(async () => {
try {
const r = await applyImport(preview.rows, options);
setResult(r);
setStep(4);
} catch (e) {
setError(e instanceof Error ? e.message : "Ошибка импорта");
}
});
}
function handleReset() {
setStep(1);
setFileBase64(null);
setFileName(null);
setPreview(null);
setResult(null);
setError(null);
if (fileRef.current) fileRef.current.value = "";
}
return (
<div>
<StepIndicator step={step} />
{/* ── Step 1: Upload ── */}
{step === 1 && (
<div className="space-y-5">
{/* File picker */}
<div
className="flex flex-col items-center justify-center gap-3 p-8 cursor-pointer"
style={{ border: "2px dashed var(--border)" }}
onClick={() => fileRef.current?.click()}
>
<Upload size={28} style={{ color: "var(--muted-foreground)" }} />
{fileName ? (
<div className="text-center">
<p className="font-medium flex items-center gap-1.5">
<FileText size={15} /> {fileName}
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>Нажмите чтобы выбрать другой файл</p>
</div>
) : (
<div className="text-center">
<p className="font-medium">Выберите CSV-файл</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>Поддерживаются файлы из emdesell, Excel и любого табличного редактора</p>
</div>
)}
<input ref={fileRef} type="file" accept=".csv,text/csv" className="hidden" onChange={handleFileChange} />
</div>
{/* Options */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Кодировка</label>
<select value={encoding} onChange={(e) => setEncoding(e.target.value as "utf8" | "win1251")} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
<option value="utf8">UTF-8 (стандарт)</option>
<option value="win1251">Windows-1251 (Excel)</option>
</select>
</div>
<div className="flex items-end pb-0.5">
<Toggle
label="Обновлять существующих"
hint="Если пользователь уже есть — обновить данные"
checked={updateExisting}
onChange={setUpdateExisting}
/>
</div>
</div>
{/* Template download hint */}
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Ожидаемые колонки: <span className="font-mono">Email</span>, <span className="font-mono">Имя</span>, <span className="font-mono">Фамилия</span>, <span className="font-mono">Телефон</span> (порядок не важен, первая строка заголовки).
</p>
{error && <p className="text-sm p-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
<button
type="button"
disabled={!fileBase64 || pending}
onClick={handleParse}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm flex items-center gap-2"
style={{ opacity: !fileBase64 || pending ? 0.5 : 1 }}
>
{pending ? <><Loader size={14} className="animate-spin" /> Разбираю...</> : "Далее →"}
</button>
</div>
)}
{/* ── Step 2: Preview ── */}
{step === 2 && preview && (
<div className="space-y-4">
{/* Stats */}
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Будет создано", count: preview.countNew, color: "#3A6A3A" },
{ label: "Будет обновлено", count: preview.countUpdate, color: "var(--foreground)" },
{ label: "Ошибок", count: preview.countError, color: "oklch(0.577 0.245 27.325)" },
].map(({ label, count, color }) => (
<div key={label} className="card-aubade p-4 text-center">
<p className="text-2xl font-bold" style={{ color }}>{count}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>{label}</p>
</div>
))}
</div>
{/* Table */}
<div className="overflow-auto max-h-72" style={{ border: "2px solid var(--border)" }}>
<table className="w-full text-sm">
<thead>
<tr style={{ borderBottom: "2px solid var(--border)", background: "var(--background)" }}>
{["#", "Email", "Имя", "Телефон", "Статус"].map((h) => (
<th key={h} className="text-left px-3 py-2 text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{preview.rows.map((row) => (
<tr key={row.index} style={{ borderBottom: "1px solid var(--border)" }}>
<td className="px-3 py-1.5 text-xs" style={{ color: "var(--muted-foreground)" }}>{row.index}</td>
<td className="px-3 py-1.5 font-mono text-xs">{row.email}</td>
<td className="px-3 py-1.5 text-xs">{row.name}</td>
<td className="px-3 py-1.5 text-xs">{row.phone || "—"}</td>
<td className="px-3 py-1.5">
{row.status === "new" && <span className="text-xs font-bold" style={{ color: "#3A6A3A" }}> Новый</span>}
{row.status === "update" && <span className="text-xs font-bold"> Обновить</span>}
{row.status === "error" && (
<span className="text-xs font-bold flex items-center gap-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
<AlertCircle size={12} /> {row.errorMsg}
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-3">
<button type="button" onClick={() => setStep(3)} disabled={preview.countNew + preview.countUpdate === 0}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
style={{ opacity: preview.countNew + preview.countUpdate === 0 ? 0.4 : 1 }}>
Далее
</button>
<button type="button" onClick={handleReset} className="btn-aubade px-4 py-2 text-sm"> Назад</button>
</div>
</div>
)}
{/* ── Step 3: Options ── */}
{step === 3 && (
<div className="space-y-5 max-w-md">
<Toggle
label="Подтвердить email автоматически"
hint="Пользователи смогут войти сразу, без подтверждения почты."
checked={autoVerifyEmail}
onChange={setAutoVerifyEmail}
/>
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Курс для доступа</label>
<select value={courseId} onChange={(e) => setCourseId(e.target.value)} style={{ ...inputStyle, appearance: "none" }} {...focusHandlers}>
<option value=""> Не присваивать </option>
{courses.map((c) => <option key={c.id} value={c.id}>{c.title}</option>)}
</select>
</div>
{courseId && (
<div className="space-y-1.5">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Срок доступа (дней)</label>
<input
type="number"
min="0"
value={accessDays}
onChange={(e) => setAccessDays(e.target.value)}
placeholder="0 — бессрочно"
style={inputStyle}
{...focusHandlers}
/>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>0 = бессрочный доступ</p>
</div>
)}
<Toggle
label="Отправить приветственное письмо"
hint="Письмо будет отправлено каждому новому пользователю."
checked={sendWelcome}
onChange={setSendWelcome}
/>
{error && <p className="text-sm p-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
<div className="flex gap-3 pt-2">
<button type="button" onClick={handleApply} disabled={pending}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm flex items-center gap-2"
style={{ opacity: pending ? 0.5 : 1 }}>
{pending ? <><Loader size={14} className="animate-spin" /> Импортирую...</> : "Применить импорт"}
</button>
<button type="button" onClick={() => setStep(2)} className="btn-aubade px-4 py-2 text-sm"> Назад</button>
</div>
</div>
)}
{/* ── Step 4: Result ── */}
{step === 4 && result && (
<div className="space-y-5">
<div className="flex items-center gap-3 p-5" style={{ border: "2px solid #3A6A3A" }}>
<CheckCircle size={24} style={{ color: "#3A6A3A", flexShrink: 0 }} />
<div>
<p className="font-bold">Импорт завершён</p>
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>
Создано: <strong>{result.created}</strong> · Обновлено: <strong>{result.updated}</strong>
</p>
</div>
</div>
{result.errors.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Ошибки ({result.errors.length})
</p>
<div className="max-h-40 overflow-y-auto space-y-1">
{result.errors.map((e, i) => (
<p key={i} className="text-xs font-mono p-2" style={{ border: "1px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>{e}</p>
))}
</div>
</div>
)}
<button type="button" onClick={handleReset} className="btn-aubade px-4 py-2 text-sm">
Импортировать ещё
</button>
</div>
)}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
interface Student {
id: string;
name: string;
email: string;
}
interface Enrollment {
userId: string;
expiresAt: Date | null;
}
interface LogEntry {
id: string;
action: string;
createdAt: Date;
note: string | null;
user: { name: string };
grantedBy: { name: string } | null;
}
interface Props {
courseId: string;
allStudents: Student[];
enrollments: Enrollment[];
accessLogs: LogEntry[];
}
export function EnrollmentManager({ courseId, allStudents, enrollments, accessLogs }: Props) {
const [enrolledMap, setEnrolledMap] = useState<Map<string, Date | null>>(
() => new Map(enrollments.map((e) => [e.userId, e.expiresAt]))
);
const [search, setSearch] = useState("");
const [expiryDate, setExpiryDate] = useState("");
const [note, setNote] = useState("");
const [showLog, setShowLog] = useState(false);
const [pending, startTransition] = useTransition();
const filtered = allStudents.filter(
(s) =>
s.name.toLowerCase().includes(search.toLowerCase()) ||
s.email.toLowerCase().includes(search.toLowerCase())
);
function handleGrant(userId: string) {
const newMap = new Map(enrolledMap);
newMap.set(userId, expiryDate ? new Date(expiryDate) : null);
setEnrolledMap(newMap);
startTransition(() => grantAccess(courseId, userId, expiryDate || null, note || undefined));
}
function handleRevoke(userId: string) {
const newMap = new Map(enrolledMap);
newMap.delete(userId);
setEnrolledMap(newMap);
startTransition(() => revokeAccess(courseId, userId, note || undefined));
}
const enrolledStudents = allStudents.filter((s) => enrolledMap.has(s.id));
function formatExpiry(date: Date | null) {
if (!date) return "Бессрочно";
const d = new Date(date);
const now = new Date();
const expired = d < now;
return (
<span style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
{expired ? "Истёк " : "До "}
{d.toLocaleDateString("ru-RU")}
</span>
);
}
return (
<div className="space-y-5">
{/* Enrolled list */}
{enrolledStudents.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Доступ открыт {enrolledStudents.length}
</p>
<div className="space-y-1.5">
{enrolledStudents.map((s) => (
<div key={s.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<div>
<p className="text-sm font-medium">{s.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{s.email}</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs">{formatExpiry(enrolledMap.get(s.id) ?? null)}</span>
<button onClick={() => handleRevoke(s.id)} disabled={pending} className="text-xs underline" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Grant form */}
<div>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Добавить ученика
</p>
<div className="flex gap-3 mb-3 flex-wrap">
<div className="flex-1 min-w-48">
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>Поиск</label>
<Input placeholder="Имя или email..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div>
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
Срок доступа
</label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-44" />
</div>
<div className="flex-1 min-w-40">
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
Примечание
</label>
<Input placeholder="Оплата, договор..." value={note} onChange={(e) => setNote(e.target.value)} />
</div>
</div>
<div className="space-y-1.5 max-h-52 overflow-y-auto">
{filtered.map((student) => {
const enrolled = enrolledMap.has(student.id);
return (
<div key={student.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
<div>
<p className="text-sm font-medium">{student.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{student.email}</p>
</div>
{enrolled ? (
<button onClick={() => handleRevoke(student.id)} disabled={pending} className="text-xs px-3 py-1.5" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
) : (
<button onClick={() => handleGrant(student.id)} disabled={pending} className="btn-aubade text-xs py-1.5 px-3">
Дать доступ
</button>
)}
</div>
);
})}
{filtered.length === 0 && (
<p className="text-sm py-2" style={{ color: "var(--muted-foreground)" }}>Студентов не найдено</p>
)}
</div>
</div>
{/* Access log */}
{accessLogs.length > 0 && (
<div>
<button
onClick={() => setShowLog(!showLog)}
className="text-xs font-bold uppercase tracking-widest underline"
style={{ color: "var(--muted-foreground)" }}
>
История доступа ({accessLogs.length}) {showLog ? "▲" : "▼"}
</button>
{showLog && (
<div className="mt-3 space-y-1.5 max-h-64 overflow-y-auto">
{accessLogs.map((log) => (
<div key={log.id} className="flex items-start gap-3 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)", background: "var(--background)" }}>
<span style={{ color: log.action === "granted" ? "#3A6A3A" : "oklch(0.577 0.245 27.325)", fontWeight: 700 }}>
{log.action === "granted" ? "▲ Выдан" : "▼ Отозван"}
</span>
<span className="flex-1">{log.user.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{log.grantedBy?.name ?? "—"}
</span>
{log.note && <span style={{ color: "var(--muted-foreground)" }}>{log.note}</span>}
<span style={{ color: "var(--muted-foreground)" }}>
{new Date(log.createdAt).toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useState, useTransition } from "react";
import { saveHomework, deleteHomework } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/homework-actions";
interface Props {
lessonId: string;
initial: { id: string; description: string } | null;
}
export function HomeworkEditor({ lessonId, initial }: Props) {
const [editing, setEditing] = useState(!initial);
const [text, setText] = useState(initial?.description ?? "");
const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition();
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
resize: "vertical" as const,
minHeight: "120px",
};
function handleSave() {
if (!text.trim()) return;
startTransition(async () => {
await saveHomework(lessonId, text.trim());
setSaved(true);
setEditing(false);
setTimeout(() => setSaved(false), 2000);
});
}
function handleDelete() {
if (!confirm("Удалить домашнее задание? Все сданные работы будут удалены.")) return;
startTransition(async () => {
await deleteHomework(lessonId);
setText("");
setEditing(true);
});
}
if (!editing && initial) {
return (
<div className="space-y-3">
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{text || initial.description}
</div>
<div className="flex gap-2">
<button onClick={() => setEditing(true)} className="btn-aubade text-xs px-3 py-1.5">
Редактировать
</button>
<button onClick={handleDelete} disabled={pending} className="text-xs px-3 py-1.5" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Удалить ДЗ
</button>
{saved && <span className="text-xs self-center" style={{ color: "var(--muted-foreground)" }}> Сохранено</span>}
</div>
</div>
);
}
return (
<div className="space-y-3">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
style={inputStyle}
placeholder="Опишите задание для студентов..."
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<div className="flex items-center gap-2">
<button
onClick={handleSave}
disabled={pending || !text.trim()}
className="btn-aubade btn-aubade-accent text-xs px-4 py-1.5"
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }}
>
{pending ? "Сохранение..." : "Сохранить задание"}
</button>
{initial && (
<button onClick={() => { setEditing(false); setText(initial.description); }} className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Отмена
</button>
)}
</div>
</div>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useTransition } from "react";
import { Search } from "lucide-react";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
};
type Course = { id: string; title: string };
export function HomeworkFilters({ courses }: { courses: Course[] }) {
const router = useRouter();
const pathname = usePathname();
const sp = useSearchParams();
const [, startTransition] = useTransition();
function update(key: string, value: string) {
const params = new URLSearchParams(sp.toString());
if (value) params.set(key, value);
else params.delete(key);
params.delete("page"); // reset to page 1 on filter change
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
const status = sp.get("status") ?? "";
const courseId = sp.get("courseId") ?? "";
const search = sp.get("search") ?? "";
return (
<div className="flex flex-wrap gap-2 mb-5">
{/* Search */}
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
<input
defaultValue={search}
placeholder="Имя или email ученика"
style={{ ...inputStyle, paddingLeft: "2rem", width: 220 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
update("search", e.currentTarget.value.trim());
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
/>
</div>
{/* Status */}
<select
value={status}
onChange={(e) => update("status", e.target.value)}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Все статусы</option>
<option value="pending">Ожидают проверки</option>
<option value="reviewed">Проверено</option>
</select>
{/* Course */}
<select
value={courseId}
onChange={(e) => update("courseId", e.target.value)}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer", maxWidth: 220 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Все курсы</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
{/* Reset */}
{(search || status || courseId) && (
<button
type="button"
onClick={() => {
startTransition(() => router.push(pathname));
}}
className="text-xs px-3"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Сбросить
</button>
)}
</div>
);
}
+371
View File
@@ -0,0 +1,371 @@
"use client";
import { useState, useCallback, useTransition } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
interface LessonData {
id: string;
title: string;
kinescopeId: string;
content: object;
published: boolean;
}
interface SiblingLesson {
id: string;
title: string;
}
export function LessonEditor({
lesson,
courseId,
moduleId,
courseSlug,
prevLesson,
nextLesson,
}: {
lesson: LessonData;
courseId: string;
moduleId: string;
courseSlug: string;
prevLesson?: SiblingLesson | null;
nextLesson?: SiblingLesson | null;
}) {
const router = useRouter();
const [title, setTitle] = useState(lesson.title);
const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId);
const [published, setPublished] = useState(lesson.published);
const [uploading, setUploading] = useState(false);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition();
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
} as React.CSSProperties;
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Image.configure({ inline: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
],
content: Object.keys(lesson.content).length ? lesson.content : undefined,
editorProps: {
attributes: {
class: "prose prose-slate max-w-none min-h-[300px] focus:outline-none p-4",
},
},
});
const uploadImage = useCallback(async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file || !editor) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/upload", { method: "POST", body: fd });
const data = await res.json();
if (data.url) editor.chain().focus().setImage({ src: data.url }).run();
setUploading(false);
};
input.click();
}, [editor]);
const importMd = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".md";
input.onchange = async () => {
const file = input.files?.[0];
if (!file || !editor) return;
setImporting(true);
setImportError(null);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/import-md", { method: "POST", body: fd });
if (!res.ok) throw new Error("Ошибка импорта");
const data = await res.json();
if (data.title) setTitle(data.title);
if (data.kinescopeId) setKinescopeId(data.kinescopeId);
if (data.published !== null) setPublished(data.published);
if (data.content) editor.commands.setContent(data.content);
} catch {
setImportError("Не удалось импортировать файл");
} finally {
setImporting(false);
}
};
input.click();
}, [editor]);
const addLink = useCallback(() => {
if (!editor) return;
const prev = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("Ссылка:", prev ?? "https://");
if (url === null) return;
if (url === "") {
editor.chain().focus().unsetLink().run();
} else {
editor.chain().focus().setLink({ href: url, target: "_blank" }).run();
}
}, [editor]);
function handleSave() {
if (!editor) return;
startTransition(async () => {
await saveLesson(lesson.id, courseId, moduleId, {
title,
kinescopeId,
content: editor.getJSON(),
published,
});
setSaved(true);
setTimeout(() => setSaved(false), 2000);
});
}
function navigateTo(lessonId: string) {
router.push(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`);
}
return (
<div className="space-y-5">
{/* Header controls */}
<div className="flex items-center justify-between gap-2 flex-wrap">
{/* Left: published toggle + prev/next */}
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
aria-checked={published}
onClick={() => setPublished(!published)}
className="flex items-center gap-2 text-sm"
>
<span
className="relative inline-block w-10 h-6 transition-colors"
style={{
background: published ? "var(--accent)" : "var(--border)",
border: "2px solid var(--foreground)",
}}
>
<span
className="absolute top-0.5 w-4 h-4 transition-transform"
style={{
background: "var(--foreground)",
left: "2px",
transform: published ? "translateX(16px)" : "translateX(0)",
}}
/>
</span>
<span style={{ color: published ? "var(--foreground)" : "var(--muted-foreground)" }}>
{published ? "Опубликован" : "Черновик"}
</span>
</button>
{/* Prev / Next navigation */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => prevLesson && navigateTo(prevLesson.id)}
disabled={!prevLesson}
title={prevLesson ? `${prevLesson.title}` : "Первый урок в модуле"}
className="btn-aubade flex items-center gap-1 px-2 py-2 text-sm disabled:opacity-30"
>
<ChevronLeft size={14} />
</button>
<button
type="button"
onClick={() => nextLesson && navigateTo(nextLesson.id)}
disabled={!nextLesson}
title={nextLesson ? `${nextLesson.title}` : "Последний урок в модуле"}
className="btn-aubade flex items-center gap-1 px-2 py-2 text-sm disabled:opacity-30"
>
<ChevronRight size={14} />
</button>
</div>
</div>
{/* Right: Import / Preview / Save */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={importMd}
disabled={importing || pending}
className="btn-aubade flex items-center gap-1.5 px-3 py-2 text-sm"
title="Импортировать из .md файла Obsidian"
style={{ opacity: importing || pending ? 0.6 : 1 }}
>
<FileUp size={14} />
{importing ? "Импорт..." : "Импорт .md"}
</button>
<a
href={`/courses/${courseSlug}/lessons/${lesson.id}`}
target="_blank"
rel="noopener noreferrer"
className="btn-aubade flex items-center gap-1.5 px-3 py-2 text-sm"
title="Просмотр как студент"
>
<Eye size={14} />
Просмотр
</a>
<button
type="button"
onClick={handleSave}
disabled={pending || uploading}
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-3 py-2 text-sm"
style={{ opacity: pending || uploading ? 0.6 : 1 }}
title="Сохранить урок"
>
<Save size={14} />
{pending ? "Сохранение..." : saved ? "Сохранено" : "Сохранить"}
</button>
</div>
</div>
{importError && (
<p className="text-xs px-3 py-2" style={{ background: "oklch(0.577 0.245 27.325 / 0.1)", color: "oklch(0.577 0.245 27.325)", border: "1px solid oklch(0.577 0.245 27.325 / 0.3)" }}>
{importError}
</p>
)}
{/* Title */}
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Заголовок урока
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ ...inputStyle, fontSize: "1.1rem", fontWeight: "700" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
{/* Kinescope ID */}
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Kinescope Video ID
</label>
<input
value={kinescopeId}
onChange={(e) => setKinescopeId(e.target.value)}
placeholder="Оставьте пустым если видео нет"
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
{/* TipTap Editor */}
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Содержимое урока
</label>
{/* Toolbar */}
<div
className="flex flex-wrap gap-0.5 p-2"
style={{ border: "2px solid var(--border)", borderBottom: "1px solid var(--border)", background: "var(--color-surface)" }}
>
{/* Text style */}
<ToolBtn onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}><strong>Ж</strong></ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleItalic().run()} active={editor?.isActive("italic")}><em>К</em></ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleUnderline().run()} active={editor?.isActive("underline")}><span style={{ textDecoration: "underline" }}>Ч</span></ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleStrike().run()} active={editor?.isActive("strike")}><span style={{ textDecoration: "line-through" }}>З</span></ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleCode().run()} active={editor?.isActive("code")}>`code`</ToolBtn>
<Sep />
{/* Headings */}
<ToolBtn onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} active={editor?.isActive("heading", { level: 1 })}>H1</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} active={editor?.isActive("heading", { level: 2 })}>H2</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} active={editor?.isActive("heading", { level: 3 })}>H3</ToolBtn>
<Sep />
{/* Lists & blocks */}
<ToolBtn onClick={() => editor?.chain().focus().toggleBulletList().run()} active={editor?.isActive("bulletList")}> Список</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleOrderedList().run()} active={editor?.isActive("orderedList")}>1. Список</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>&ldquo;&rdquo; Цитата</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleCodeBlock().run()} active={editor?.isActive("codeBlock")}>{'</>'} Код</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().setHorizontalRule().run()}> Разделитель</ToolBtn>
<Sep />
{/* Link & image */}
<ToolBtn onClick={addLink} active={editor?.isActive("link")}>🔗 Ссылка</ToolBtn>
<ToolBtn onClick={uploadImage} disabled={uploading}>{uploading ? "Загрузка..." : "🖼 Фото"}</ToolBtn>
<Sep />
{/* History */}
<ToolBtn onClick={() => editor?.chain().focus().undo().run()}> Отменить</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}> Повторить</ToolBtn>
</div>
{/* Editor content */}
<div style={{ border: "2px solid var(--border)", borderTop: "none", background: "var(--background)" }}>
<EditorContent editor={editor} />
</div>
</div>
</div>
);
}
function Sep() {
return <div className="w-px mx-1 self-stretch" style={{ background: "var(--border)" }} />;
}
function ToolBtn({
onClick,
active,
disabled,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="px-2 py-1 text-xs transition-colors disabled:opacity-50"
style={{
background: active ? "var(--foreground)" : "transparent",
color: active ? "var(--background)" : "var(--foreground)",
border: "1px solid transparent",
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--border)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}
>
{children}
</button>
);
}
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
interface LessonFile {
id: string;
name: string;
url: string;
size: number;
}
export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: string; initialFiles: LessonFile[] }) {
const [files, setFiles] = useState(initialFiles);
const [uploading, setUploading] = useState(false);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
fd.append("lessonId", lessonId);
const res = await fetch("/api/admin/lesson-files", { method: "POST", body: fd });
const created = await res.json();
if (created.id) setFiles((prev) => [...prev, created]);
setUploading(false);
e.target.value = "";
}
async function handleDelete(fileId: string) {
if (!confirm("Удалить файл?")) return;
await fetch("/api/admin/lesson-files", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId }),
});
setFiles((prev) => prev.filter((f) => f.id !== fileId));
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
return (
<div className="space-y-3">
{files.length > 0 && (
<div className="space-y-2">
{files.map((f) => (
<div key={f.id} className="flex items-center gap-3 px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<span className="text-base">📎</span>
<a href={f.url} target="_blank" rel="noopener noreferrer" className="flex-1 underline font-medium">{f.name}</a>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<button onClick={() => handleDelete(f.id)} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Удалить
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<label className="btn-aubade text-xs cursor-pointer">
{uploading ? "Загрузка..." : "+ Добавить файл"}
<input type="file" className="sr-only" onChange={handleUpload} disabled={uploading} />
</label>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX до 100 МБ</span>
</div>
</div>
);
}
+450
View File
@@ -0,0 +1,450 @@
"use client";
import { useState, useTransition } from "react";
import { Save } from "lucide-react";
import { saveSettings } from "@/app/admin/settings/actions";
import type { Settings } from "@/lib/settings";
// ── Small primitives ──────────────────────────────────────────────────────────
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
} as React.CSSProperties;
const focusHandlers = {
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
function Section({
title,
hint,
children,
}: {
title: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="card-aubade p-6 space-y-5">
<div>
<h2
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{title}
</h2>
{hint && (
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{hint}
</p>
)}
</div>
{children}
</div>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{label}
</label>
{hint && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{hint}
</p>
)}
{children}
</div>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-start gap-3">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className="mt-0.5 flex-shrink-0"
>
<span
className="relative inline-block w-10 h-6 transition-colors"
style={{
background: checked ? "var(--accent)" : "var(--border)",
border: "2px solid var(--foreground)",
}}
>
<span
className="absolute top-0.5 w-4 h-4 transition-transform"
style={{
background: "var(--foreground)",
left: "2px",
transform: checked ? "translateX(16px)" : "translateX(0)",
}}
/>
</span>
</button>
<div>
<p className="text-sm font-medium" style={{ color: "var(--foreground)" }}>
{label}
</p>
{hint && (
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>
{hint}
</p>
)}
</div>
</div>
);
}
function SelectField({
label,
hint,
value,
onChange,
options,
}: {
label: string;
hint?: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string }[];
}) {
return (
<Field label={label} hint={hint}>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
style={{
...inputStyle,
appearance: "none",
cursor: "pointer",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
);
}
// ── Main form ─────────────────────────────────────────────────────────────────
export function SettingsForm({ initial }: { initial: Settings }) {
const [s, setS] = useState(initial);
const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition();
function set(key: keyof Settings, value: string) {
setS((prev) => ({ ...prev, [key]: value }));
}
function bool(key: keyof Settings) {
return s[key] === "true";
}
function handleSave() {
startTransition(async () => {
await saveSettings(s);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
});
}
return (
<div className="space-y-5">
{/* Save button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={pending}
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
style={{ opacity: pending ? 0.6 : 1 }}
>
<Save size={14} />
{pending ? "Сохранение..." : saved ? "Сохранено ✓" : "Сохранить настройки"}
</button>
</div>
{/* ── 1. Основное ── */}
<Section title="Основное">
<Field label="Название школы" hint="Отображается в заголовке браузера, письмах и подписях">
<input
value={s.schoolName}
onChange={(e) => set("schoolName", e.target.value)}
style={inputStyle}
{...focusHandlers}
/>
</Field>
<Field label="Описание школы" hint="Мета-тег description для поисковых систем">
<textarea
value={s.schoolDescription}
onChange={(e) => set("schoolDescription", e.target.value)}
rows={2}
style={{ ...inputStyle, resize: "vertical" }}
{...focusHandlers}
/>
</Field>
<Field label="Ключевые слова" hint="Мета-тег keywords, через запятую">
<input
value={s.schoolKeywords}
onChange={(e) => set("schoolKeywords", e.target.value)}
placeholder="obsidian, pkm, second brain"
style={inputStyle}
{...focusHandlers}
/>
</Field>
<div className="space-y-3 pt-1">
<Toggle
label="Режим технических работ"
hint="Ученики увидят страницу-заглушку. Администраторы входят в обычном режиме."
checked={bool("maintenanceMode")}
onChange={(v) => set("maintenanceMode", v ? "true" : "false")}
/>
<Toggle
label="Открытая регистрация"
hint="Если выключено — форма регистрации недоступна, новые аккаунты создаёт только администратор."
checked={bool("registrationEnabled")}
onChange={(v) => set("registrationEnabled", v ? "true" : "false")}
/>
</div>
</Section>
{/* ── 2. Уведомления ── */}
<Section
title="Уведомления"
hint="Кому отправлять системные письма о новых ДЗ, регистрациях и вопросах учеников."
>
<Field
label="Email(ы) для уведомлений"
hint="По одному адресу на строку. Если пусто — письма не отправляются."
>
<textarea
value={s.notificationEmails}
onChange={(e) => set("notificationEmails", e.target.value)}
rows={3}
placeholder={"admin@school.ru\ncurator@school.ru"}
style={{ ...inputStyle, resize: "vertical", fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<div className="space-y-3 pt-1">
<Toggle
label="Уведомлять о новом домашнем задании"
checked={bool("notifyOnHomework")}
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
/>
<Toggle
label="Уведомлять о новой регистрации ученика"
checked={bool("notifyOnRegistration")}
onChange={(v) => set("notifyOnRegistration", v ? "true" : "false")}
/>
<Toggle
label="Уведомлять ученика о полученном фидбеке"
checked={bool("notifyStudentOnFeedback")}
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
/>
</div>
</Section>
{/* ── 3. Данные ученика ── */}
<Section title="Данные ученика" hint="Поля при регистрации и требования к аккаунту.">
<Toggle
label="Требовать подтверждение email"
hint="Пока email не подтверждён — ученик не может войти в личный кабинет."
checked={bool("requireEmailVerification")}
onChange={(v) => set("requireEmailVerification", v ? "true" : "false")}
/>
<SelectField
label="Фамилия при регистрации"
value={s.lastNameField}
onChange={(v) => set("lastNameField", v)}
options={[
{ value: "required", label: "Обязательная" },
{ value: "optional", label: "Необязательная" },
{ value: "hidden", label: "Не показывать" },
]}
/>
<SelectField
label="Телефон при регистрации"
value={s.phoneField}
onChange={(v) => set("phoneField", v)}
options={[
{ value: "required", label: "Обязательный" },
{ value: "optional", label: "Необязательный" },
{ value: "hidden", label: "Не показывать" },
]}
/>
</Section>
{/* ── 4. Юридические документы ── */}
<Section
title="Юридические документы"
hint="Ссылки на внешние документы (Google Docs, Notion и т.п.)."
>
<Field label="Политика конфиденциальности (URL)">
<input
value={s.privacyPolicyUrl}
onChange={(e) => set("privacyPolicyUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="Согласие на обработку персональных данных (URL)">
<input
value={s.termsUrl}
onChange={(e) => set("termsUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="Договор-оферта (URL)">
<input
value={s.offerUrl}
onChange={(e) => set("offerUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Toggle
label="Чекбокс «Я принимаю условия» на форме регистрации"
hint="Ученик обязан поставить галочку перед отправкой формы."
checked={bool("showTermsCheckbox")}
onChange={(v) => set("showTermsCheckbox", v ? "true" : "false")}
/>
<Field
label="Реквизиты организации"
hint="Отображаются в подвале личного кабинета ученика."
>
<textarea
value={s.orgRequisites}
onChange={(e) => set("orgRequisites", e.target.value)}
rows={3}
placeholder={"ИП Иванов Иван Иванович\nИНН 123456789012\nОГРНИП 123456789012345"}
style={{ ...inputStyle, resize: "vertical" }}
{...focusHandlers}
/>
</Field>
</Section>
{/* ── 5. Права куратора ── */}
<Section title="Права куратора">
<SelectField
label="Куратор видит домашние задания"
value={s.curatorHomeworkScope}
onChange={(v) => set("curatorHomeworkScope", v)}
options={[
{ value: "all", label: "По всем курсам" },
{ value: "assigned", label: "Только по назначенным курсам" },
]}
/>
<div className="space-y-3 pt-1">
<Toggle
label="Куратор может отвечать на вопросы учеников"
checked={bool("curatorCanAnswerQuestions")}
onChange={(v) => set("curatorCanAnswerQuestions", v ? "true" : "false")}
/>
<Toggle
label="Куратор видит список всех студентов"
checked={bool("curatorCanSeeStudents")}
onChange={(v) => set("curatorCanSeeStudents", v ? "true" : "false")}
/>
</div>
</Section>
{/* ── 6. Вставка кода ── */}
<Section
title="Вставка кода"
hint="Произвольный HTML/JS — Яндекс.Метрика, Google Analytics, виджеты. Код добавляется на каждую страницу."
>
<Field label="Код в <head>" hint="Счётчики, пиксели, мета-теги">
<textarea
value={s.headCode}
onChange={(e) => set("headCode", e.target.value)}
rows={4}
placeholder={"<!-- Яндекс.Метрика -->\n<script>...</script>"}
style={{
...inputStyle,
resize: "vertical",
fontFamily: "var(--font-mono)",
fontSize: "0.8rem",
}}
{...focusHandlers}
/>
</Field>
<Field label="Код в <body>" hint="Чаты поддержки, виджеты">
<textarea
value={s.bodyCode}
onChange={(e) => set("bodyCode", e.target.value)}
rows={4}
placeholder={"<!-- JivoSite / Crisp / etc -->\n<script>...</script>"}
style={{
...inputStyle,
resize: "vertical",
fontFamily: "var(--font-mono)",
fontSize: "0.8rem",
}}
{...focusHandlers}
/>
</Field>
</Section>
{/* Bottom save button */}
<div className="flex justify-end pb-4">
<button
type="button"
onClick={handleSave}
disabled={pending}
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
style={{ opacity: pending ? 0.6 : 1 }}
>
<Save size={14} />
{pending ? "Сохранение..." : saved ? "Сохранено ✓" : "Сохранить настройки"}
</button>
</div>
</div>
);
}
+299
View File
@@ -0,0 +1,299 @@
"use client";
import { useState, useTransition } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Link from "next/link";
import { Eye, EyeOff, Video } from "lucide-react";
import {
createLesson,
deleteLesson,
updateLesson,
reorderLessons,
toggleLessonPublished,
moveLessonToModule,
} from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
interface Lesson {
id: string;
title: string;
order: number;
published: boolean;
kinescopeId: string | null;
}
interface OtherModule {
id: string;
title: string;
}
function SortableLesson({
lesson,
courseId,
moduleId,
otherModules,
}: {
lesson: Lesson;
courseId: string;
moduleId: string;
otherModules: OtherModule[];
}) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(lesson.title);
const [pending, startTransition] = useTransition();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: lesson.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startTransition(() => updateLesson(lesson.id, courseId, moduleId, fd));
setEditing(false);
}
function handleDelete() {
if (!confirm(`Удалить урок "${lesson.title}"?`)) return;
startTransition(() => deleteLesson(lesson.id, courseId, moduleId));
}
function handleTogglePublished() {
startTransition(() => toggleLessonPublished(lesson.id, courseId, moduleId, lesson.published));
}
function handleMove(targetModuleId: string) {
if (!targetModuleId) return;
startTransition(() => moveLessonToModule(lesson.id, targetModuleId, courseId, moduleId));
}
return (
<div
ref={setNodeRef}
style={{ ...style, border: "2px solid var(--border)", background: "var(--color-surface)", opacity: pending ? 0.5 : 1 }}
className="flex items-center gap-2 px-3 py-2.5"
>
{/* Drag handle */}
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-lg select-none shrink-0"
style={{ color: "var(--muted-foreground)" }}
aria-label="Перетащить"
>
</button>
{/* Kinescope indicator */}
<span
title={lesson.kinescopeId ? `Kinescope: ${lesson.kinescopeId}` : "Без видео"}
className="shrink-0"
style={{ color: lesson.kinescopeId ? "#3A6A3A" : "var(--border)" }}
>
<Video size={13} />
</span>
{editing ? (
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
<input
name="title"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
required
className="flex-1 px-2 py-1 text-sm"
style={{ border: "2px solid var(--foreground)", background: "var(--background)", outline: "none" }}
/>
<button type="submit" disabled={pending} className="btn-aubade text-xs px-3 py-1">
Сохранить
</button>
<button
type="button"
onClick={() => { setEditing(false); setEditValue(lesson.title); }}
className="text-xs px-3 py-1"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</form>
) : (
<>
<span className="flex-1 text-sm truncate">{lesson.title}</span>
{/* Published toggle */}
<button
type="button"
onClick={handleTogglePublished}
disabled={pending}
title={lesson.published ? "Скрыть" : "Опубликовать"}
className="shrink-0 flex items-center gap-1 text-xs px-2 py-0.5 transition-opacity hover:opacity-70"
style={{
border: "1px solid var(--border)",
background: lesson.published ? "var(--accent)" : "transparent",
color: lesson.published ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{lesson.published ? <Eye size={11} /> : <EyeOff size={11} />}
{lesson.published ? "Опубл." : "Черновик"}
</button>
{/* Edit */}
<Link
href={`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`}
className="btn-aubade text-xs px-3 py-1 shrink-0"
>
Ред.
</Link>
{/* Rename */}
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs shrink-0"
style={{ color: "var(--muted-foreground)" }}
>
Переим.
</button>
{/* Move to module */}
{otherModules.length > 0 && (
<select
defaultValue=""
onChange={(e) => handleMove(e.target.value)}
className="text-xs shrink-0"
style={{
border: "1px solid var(--border)",
background: "var(--background)",
color: "var(--muted-foreground)",
padding: "2px 4px",
cursor: "pointer",
maxWidth: 120,
}}
title="Переместить в модуль"
>
<option value="" disabled>Переместить</option>
{otherModules.map((m) => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
)}
{/* Delete */}
<button
type="button"
onClick={handleDelete}
disabled={pending}
className="text-xs shrink-0"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</>
)}
</div>
);
}
export function SortableLessons({
courseId,
moduleId,
lessons,
otherModules = [],
}: {
courseId: string;
moduleId: string;
lessons: Lesson[];
otherModules?: OtherModule[];
}) {
const [items, setItems] = useState(lessons);
const [creating, startTransition] = useTransition();
const sensors = useSensors(useSensor(PointerSensor));
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((l) => l.id === active.id);
const newIndex = items.findIndex((l) => l.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
startTransition(() => reorderLessons(moduleId, courseId, newItems.map((l) => l.id)));
}
function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
e.currentTarget.reset();
startTransition(() => createLesson(moduleId, courseId, fd));
}
return (
<div className="space-y-2">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((l) => l.id)} strategy={verticalListSortingStrategy}>
{items.map((lesson) => (
<SortableLesson
key={lesson.id}
lesson={lesson}
courseId={courseId}
moduleId={moduleId}
otherModules={otherModules}
/>
))}
</SortableContext>
</DndContext>
{items.length === 0 && (
<p className="text-sm py-3" style={{ color: "var(--muted-foreground)" }}>
Уроков пока нет. Добавьте первый.
</p>
)}
{/* Quick create form */}
<form onSubmit={handleCreate} className="pt-3 space-y-2">
<div className="flex gap-2">
<input
name="title"
placeholder="Название урока"
required
disabled={creating}
className="flex-1 max-w-xs px-3 py-2 text-sm"
style={{ border: "2px solid var(--border)", background: "var(--background)", outline: "none" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<input
name="kinescopeId"
placeholder="Kinescope ID (опционально)"
disabled={creating}
className="flex-1 max-w-xs px-3 py-2 text-sm font-mono"
style={{ border: "2px solid var(--border)", background: "var(--background)", outline: "none" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button type="submit" disabled={creating} className="btn-aubade text-xs px-4 py-2 shrink-0">
{creating ? "Создание..." : "+ Урок"}
</button>
</div>
</form>
</div>
);
}
+207
View File
@@ -0,0 +1,207 @@
"use client";
import { useState, useTransition } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Link from "next/link";
import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions";
interface Module {
id: string;
title: string;
description: string | null;
order: number;
_count: { lessons: number };
}
function SortableModule({ mod, courseId }: { mod: Module; courseId: string }) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(mod.title);
const [editDesc, setEditDesc] = useState(mod.description ?? "");
const [pending, startTransition] = useTransition();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: mod.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startTransition(() => updateModule(mod.id, courseId, fd));
setEditing(false);
}
function handleDelete() {
if (!confirm(`Удалить модуль "${mod.title}"? Все уроки будут удалены.`)) return;
startTransition(() => deleteModule(mod.id, courseId));
}
return (
<div
ref={setNodeRef}
style={{ ...style, border: "2px solid var(--border)", background: "var(--color-surface)" }}
className="flex items-center gap-3 px-4 py-3"
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-lg select-none"
style={{ color: "var(--muted-foreground)" }}
aria-label="Перетащить"
>
</button>
{editing ? (
<form onSubmit={handleUpdate} className="flex flex-col gap-2 flex-1">
<div className="flex items-center gap-2">
<input
name="title"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
required
placeholder="Название модуля"
className="flex-1 px-2 py-1 text-sm"
style={{ border: "2px solid var(--foreground)", background: "var(--background)", outline: "none" }}
/>
<button type="submit" disabled={pending} className="btn-aubade text-xs px-3 py-1 shrink-0">
Сохранить
</button>
<button
type="button"
onClick={() => { setEditing(false); setEditValue(mod.title); setEditDesc(mod.description ?? ""); }}
className="text-xs px-3 py-1 shrink-0"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</div>
<textarea
name="description"
value={editDesc}
onChange={(e) => setEditDesc(e.target.value)}
placeholder="Описание модуля (опционально)"
rows={2}
className="w-full px-2 py-1 text-sm resize-none"
style={{ border: "1px solid var(--border)", background: "var(--background)", outline: "none" }}
/>
</form>
) : (
<>
<div className="flex-1 min-w-0">
<span className="font-medium text-sm">{mod.title}</span>
{mod.description && (
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted-foreground)" }}>
{mod.description}
</p>
)}
</div>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{mod._count.lessons} {mod._count.lessons === 1 ? "урок" : mod._count.lessons < 5 ? "урока" : "уроков"}
</span>
<Link
href={`/admin/courses/${courseId}/modules/${mod.id}`}
className="btn-aubade text-xs px-3 py-1"
>
Уроки
</Link>
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs"
style={{ color: "var(--muted-foreground)" }}
>
Переименовать
</button>
<button
type="button"
onClick={handleDelete}
disabled={pending}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</>
)}
</div>
);
}
export function SortableModules({ courseId, modules }: { courseId: string; modules: Module[] }) {
const [items, setItems] = useState(modules);
const [creating, startTransition] = useTransition();
const sensors = useSensors(useSensor(PointerSensor));
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((m) => m.id === active.id);
const newIndex = items.findIndex((m) => m.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
startTransition(() => reorderModules(courseId, newItems.map((m) => m.id)));
}
function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
e.currentTarget.reset();
startTransition(() => createModule(courseId, fd));
}
return (
<div className="space-y-2">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((m) => m.id)} strategy={verticalListSortingStrategy}>
{items.map((mod) => (
<SortableModule key={mod.id} mod={mod} courseId={courseId} />
))}
</SortableContext>
</DndContext>
{items.length === 0 && (
<p className="text-sm py-3" style={{ color: "var(--muted-foreground)" }}>
Модулей пока нет. Добавьте первый.
</p>
)}
<form onSubmit={handleCreate} className="flex gap-2 pt-3">
<input
name="title"
placeholder="Название нового модуля"
required
disabled={creating}
className="flex-1 max-w-xs px-3 py-2 text-sm"
style={{ border: "2px solid var(--border)", background: "var(--background)", outline: "none" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button type="submit" disabled={creating} className="btn-aubade text-xs px-4 py-2">
{creating ? "Создание..." : "+ Модуль"}
</button>
</form>
</div>
);
}
@@ -0,0 +1,137 @@
"use client";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { bulkGrantAccess, revokeUserAccess } from "@/app/admin/users/[userId]/actions";
interface Course {
id: string;
title: string;
published: boolean;
}
interface Enrollment {
courseId: string;
expiresAt: Date | null;
courseTitle: string;
}
interface Props {
userId: string;
allCourses: Course[];
enrollments: Enrollment[];
}
export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props) {
const [enrolledMap, setEnrolledMap] = useState<Map<string, Date | null>>(
() => new Map(enrollments.map((e) => [e.courseId, e.expiresAt]))
);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [expiryDate, setExpiryDate] = useState("");
const [pending, startTransition] = useTransition();
const unenrolled = allCourses.filter((c) => !enrolledMap.has(c.id));
function toggleSelect(courseId: string) {
setSelected((prev) => {
const next = new Set(prev);
next.has(courseId) ? next.delete(courseId) : next.add(courseId);
return next;
});
}
function handleBulkGrant() {
if (selected.size === 0) return;
const ids = [...selected];
const expiry = expiryDate || null;
const newMap = new Map(enrolledMap);
ids.forEach((id) => newMap.set(id, expiry ? new Date(expiry) : null));
setEnrolledMap(newMap);
setSelected(new Set());
startTransition(() => bulkGrantAccess(userId, ids, expiry));
}
function handleRevoke(courseId: string) {
const newMap = new Map(enrolledMap);
newMap.delete(courseId);
setEnrolledMap(newMap);
startTransition(() => revokeUserAccess(userId, courseId));
}
function formatExpiry(date: Date | null) {
if (!date) return "Бессрочно";
const d = new Date(date);
const expired = d < new Date();
return (
<span style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "inherit" }}>
{expired ? "Истёк " : "До "}{d.toLocaleDateString("ru-RU")}
</span>
);
}
const enrolledCourses = allCourses.filter((c) => enrolledMap.has(c.id));
return (
<div className="space-y-5">
{/* Current enrollments */}
{enrolledCourses.length > 0 ? (
<div className="space-y-1.5">
{enrolledCourses.map((c) => (
<div key={c.id} className="flex items-center justify-between px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<span className="font-medium">{c.title}</span>
<div className="flex items-center gap-4">
<span className="text-xs">{formatExpiry(enrolledMap.get(c.id) ?? null)}</span>
<button onClick={() => handleRevoke(c.id)} disabled={pending} className="text-xs underline" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Отозвать
</button>
</div>
</div>
))}
</div>
) : (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Доступа к курсам нет</p>
)}
{/* Bulk grant */}
{unenrolled.length > 0 && (
<div>
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Добавить курсы
</p>
<div className="space-y-1.5 max-h-48 overflow-y-auto mb-3">
{unenrolled.map((c) => (
<label key={c.id} className="flex items-center gap-3 px-3 py-2 cursor-pointer text-sm" style={{ border: "2px solid var(--border)", background: selected.has(c.id) ? "var(--color-highlight)" : "var(--background)" }}>
<input
type="checkbox"
checked={selected.has(c.id)}
onChange={() => toggleSelect(c.id)}
className="accent-current"
/>
<span>{c.title}</span>
{!c.published && (
<span className="text-xs ml-auto" style={{ color: "var(--muted-foreground)" }}>черновик</span>
)}
</label>
))}
</div>
{selected.size > 0 && (
<div className="flex items-center gap-3">
<div>
<label className="text-xs uppercase tracking-wider block mb-1" style={{ color: "var(--muted-foreground)" }}>
Срок доступа
</label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-44" />
</div>
<div className="pt-5">
<button onClick={handleBulkGrant} disabled={pending} className="btn-aubade btn-aubade-accent">
Дать доступ к {selected.size} курсам
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import { useRouter, usePathname } from "next/navigation";
import { useTransition } from "react";
import { Search } from "lucide-react";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
};
export function UsersSearch({ initialSearch, initialRole }: { initialSearch: string; initialRole: string }) {
const router = useRouter();
const pathname = usePathname();
const [, startTransition] = useTransition();
function update(search: string, role: string) {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (role) params.set("role", role);
startTransition(() => router.push(`${pathname}?${params.toString()}`));
}
return (
<div className="flex flex-wrap gap-2 mb-4">
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2" style={{ color: "var(--muted-foreground)" }} />
<input
defaultValue={initialSearch}
placeholder="Поиск по имени или email"
style={{ ...inputStyle, paddingLeft: "2rem", width: 240 }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
update(e.currentTarget.value.trim(), initialRole);
}}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/>
</div>
<select
defaultValue={initialRole}
onChange={(e) => update(initialSearch, e.target.value)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Все роли</option>
<option value="student">Ученики</option>
<option value="curator">Кураторы</option>
<option value="admin">Администраторы</option>
</select>
{(initialSearch || initialRole) && (
<button
type="button"
onClick={() => startTransition(() => router.push(pathname))}
className="text-xs px-3"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Сбросить
</button>
)}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
type Enrollment = {
courseId: string;
courseTitle: string;
expiresAt: Date | null;
};
type UserRow = {
id: string;
name: string;
email: string;
role: string;
emailVerified: boolean;
createdAt: Date;
enrollmentCount: number;
enrollments: Enrollment[];
};
const roleLabel: Record<string, string> = {
admin: "Администратор",
curator: "Куратор",
student: "Ученик",
};
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
admin: "default",
curator: "secondary",
student: "outline",
};
function UserPopup({ user }: { user: UserRow }) {
const now = new Date();
return (
<div
className="absolute z-50 right-0 top-full mt-1 w-72 p-4 space-y-3 text-sm"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "4px 4px 0 0 var(--foreground)",
}}
>
{/* Contact */}
<div className="space-y-0.5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Контакты</p>
<p className="font-mono text-xs">{user.email}</p>
</div>
{/* Courses */}
{user.enrollments.length > 0 ? (
<div className="space-y-1.5">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Курсы ({user.enrollments.length})
</p>
{user.enrollments.map((e) => {
const expired = e.expiresAt && new Date(e.expiresAt) < now;
return (
<div key={e.courseId} className="flex items-start justify-between gap-2">
<p className="text-xs flex-1 truncate">{e.courseTitle}</p>
<span
className="text-xs shrink-0"
style={{ color: expired ? "oklch(0.577 0.245 27.325)" : "var(--muted-foreground)" }}
>
{e.expiresAt
? expired
? "просрочен"
: `до ${new Date(e.expiresAt).toLocaleDateString("ru-RU")}`
: "бессрочно"}
</span>
</div>
);
})}
</div>
) : (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Курсов нет</p>
)}
<Link
href={`/admin/users/${user.id}`}
className="block text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Открыть профиль
</Link>
</div>
);
}
export function UsersTable({ users }: { users: UserRow[] }) {
const [hoveredId, setHoveredId] = useState<string | null>(null);
return (
<div className="bg-white border border-slate-200 rounded-2xl overflow-visible">
<table className="w-full">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
{["Пользователь", "Роль", "Курсов", "Email подтверждён", "Зарегистрирован", ""].map((h) => (
<th key={h} className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">{h}</th>
))}
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr
key={user.id}
className="border-b last:border-0"
style={{ borderColor: "var(--border)" }}
>
<td className="px-5 py-3">
<Link
href={`/admin/users/${user.id}`}
className="font-medium hover:underline"
style={{ color: "var(--foreground)" }}
>
{user.name}
</Link>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{user.email}</p>
</td>
<td className="px-5 py-3">
<Badge variant={roleVariant[user.role] ?? "outline"}>
{roleLabel[user.role] ?? user.role}
</Badge>
</td>
<td className="px-5 py-3 text-sm text-slate-600">{user.enrollmentCount}</td>
<td className="px-5 py-3">
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
{user.emailVerified ? "Да" : "Нет"}
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-400">
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
</td>
{/* Hover popup trigger */}
<td className="px-3 py-3 relative">
<div
className="relative inline-block"
onMouseEnter={() => setHoveredId(user.id)}
onMouseLeave={() => setHoveredId(null)}
>
<button
type="button"
className="text-xs px-2 py-1"
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
>
···
</button>
{hoveredId === user.id && <UserPopup user={user} />}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,19 @@
"use client";
import KinescopeReactPlayer from "@kinescope/react-kinescope-player";
interface Props {
videoId: string;
}
export function KinescopePlayer({ videoId }: Props) {
return (
<div className="w-full aspect-video" style={{ border: "2px solid var(--border)" }}>
<KinescopeReactPlayer
videoId={videoId}
width="100%"
height="100%"
/>
</div>
);
}
+159
View File
@@ -0,0 +1,159 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
interface Lesson {
id: string;
title: string;
}
interface Module {
id: string;
title: string;
lessons: Lesson[];
}
interface Course {
slug: string;
title: string;
modules: Module[];
}
export function CourseSidebar({
course,
completedLessonIds = new Set(),
}: {
course: Course;
completedLessonIds?: Set<string>;
}) {
const pathname = usePathname();
const [open, setOpen] = useState(true);
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
const completedCount = course.modules
.flatMap((m) => m.lessons)
.filter((l) => completedLessonIds.has(l.id)).length;
const progressPct = totalLessons > 0 ? Math.round((completedCount / totalLessons) * 100) : 0;
return (
<>
{/* Mobile toggle */}
<button
className="md:hidden fixed bottom-4 right-4 z-20 btn-aubade px-3 py-2 text-sm"
onClick={() => setOpen(!open)}
>
{open ? "✕" : "☰ Уроки"}
</button>
<aside
className={`w-64 shrink-0 flex flex-col overflow-y-auto ${open ? "flex" : "hidden md:flex"}`}
style={{
borderRight: "2px solid var(--border)",
backgroundColor: "var(--background)",
maxHeight: "calc(100vh - 53px)",
position: "sticky",
top: "53px",
}}
>
{/* Course title + progress */}
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
<Link
href={`/courses/${course.slug}`}
className="font-bold text-sm leading-snug block mb-1"
style={{ color: "var(--foreground)" }}
>
{course.title}
</Link>
<Link
href="/dashboard"
className="text-xs block underline mb-3"
style={{ color: "var(--muted-foreground)" }}
>
Все курсы
</Link>
{totalLessons > 0 && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{completedCount} из {totalLessons} уроков
</span>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
{progressPct}%
</span>
</div>
<div
className="h-1.5 w-full"
style={{ background: "var(--border)" }}
>
<div
className="h-full transition-all duration-300"
style={{
width: `${progressPct}%`,
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
}}
/>
</div>
</div>
)}
</div>
{/* Modules and lessons */}
<nav className="flex-1 py-2">
{course.modules.map((mod) => {
const modCompleted = mod.lessons.filter((l) => completedLessonIds.has(l.id)).length;
return (
<div key={mod.id}>
<div className="flex items-center justify-between px-4 py-2">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
{mod.title}
</p>
{mod.lessons.length > 0 && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{modCompleted}/{mod.lessons.length}
</span>
)}
</div>
{mod.lessons.map((lesson) => {
const active = pathname.includes(lesson.id);
const done = completedLessonIds.has(lesson.id);
return (
<Link
key={lesson.id}
href={`/courses/${course.slug}/lessons/${lesson.id}`}
className="flex items-center gap-2 px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
style={{
borderLeftColor: active ? "var(--foreground)" : "transparent",
backgroundColor: active ? "var(--color-highlight)" : "transparent",
fontWeight: active ? 600 : 400,
color: done && !active ? "var(--muted-foreground)" : "var(--foreground)",
}}
>
<span
className="shrink-0 w-4 h-4 flex items-center justify-center text-xs"
style={{
border: `1.5px solid ${done ? "var(--foreground)" : "var(--border)"}`,
background: done ? "var(--foreground)" : "transparent",
color: "var(--background)",
}}
>
{done && "✓"}
</span>
<span className="flex-1 leading-snug">{lesson.title}</span>
</Link>
);
})}
</div>
);
})}
</nav>
</aside>
</>
);
}
+195
View File
@@ -0,0 +1,195 @@
"use client";
import { useState, useTransition } from "react";
import { submitHomework } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/homework-actions";
interface HWFile { name: string; url: string; size: number }
interface Feedback {
id: string;
text: string;
createdAt: Date;
curator: { name: string };
}
interface Submission {
id: string;
text: string | null;
files: HWFile[];
submittedAt: Date;
feedbacks: Feedback[];
}
interface Props {
homework: { id: string; description: string };
submission: Submission | null;
slug: string;
lessonId: string;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export function HomeworkSection({ homework, submission, slug, lessonId }: Props) {
const [text, setText] = useState(submission?.text ?? "");
const [files, setFiles] = useState<HWFile[]>(submission?.files ?? []);
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState(!submission);
const isReviewed = submission && submission.feedbacks.length > 0;
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "0.875rem",
fontFamily: "inherit",
resize: "vertical" as const,
minHeight: "140px",
};
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (!file) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/student/homework-upload", { method: "POST", body: fd });
const data = await res.json();
if (data.url) setFiles((prev) => [...prev, data]);
setUploading(false);
e.target.value = "";
}
function removeFile(url: string) {
setFiles((prev) => prev.filter((f) => f.url !== url));
}
function handleSubmit() {
startTransition(() => submitHomework(homework.id, slug, lessonId, text, files));
setEditing(false);
}
return (
<div className="space-y-4">
{/* Assignment description */}
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{homework.description}
</div>
{/* Submitted & reviewed */}
{isReviewed && (
<div className="space-y-3">
<div className="space-y-2">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Ваш ответ</p>
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
{submission!.text || "—"}
</div>
</div>
{submission!.feedbacks.map((fb) => (
<div key={fb.id} className="px-4 py-3 space-y-1" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
<div className="flex items-center justify-between">
<p className="text-xs font-bold uppercase tracking-widest">Обратная связь</p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
</div>
))}
</div>
)}
{/* Submitted, pending review */}
{submission && !isReviewed && !editing && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs px-2 py-0.5 font-bold uppercase tracking-widest" style={{ border: "2px solid var(--border)", background: "var(--color-surface)", color: "var(--muted-foreground)" }}>
На проверке
</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
</span>
</div>
<button onClick={() => setEditing(true)} className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
Изменить
</button>
</div>
{submission.text && (
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
{submission.text}
</div>
)}
{submission.files.length > 0 && (
<div className="space-y-1">
{submission.files.map((f) => (
<a key={f.url} href={f.url} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 text-xs"
style={{ border: "2px solid var(--border)" }}>
<span>📎</span>
<span className="flex-1 underline">{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
</a>
))}
</div>
)}
</div>
)}
{/* Form */}
{editing && !isReviewed && (
<div className="space-y-3">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
style={inputStyle}
placeholder="Напишите ваш ответ..."
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
{/* File list */}
{files.length > 0 && (
<div className="space-y-1">
{files.map((f) => (
<div key={f.url} className="flex items-center gap-2 px-3 py-2 text-xs" style={{ border: "2px solid var(--border)" }}>
<span>📎</span>
<span className="flex-1">{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<button onClick={() => removeFile(f.url)} style={{ color: "oklch(0.577 0.245 27.325)" }}></button>
</div>
))}
</div>
)}
<div className="flex items-center gap-2 flex-wrap">
<button
onClick={handleSubmit}
disabled={pending || (!text.trim() && files.length === 0)}
className="btn-aubade btn-aubade-accent text-sm px-5 py-2"
style={{ opacity: pending || (!text.trim() && files.length === 0) ? 0.6 : 1 }}
>
{pending ? "Отправка..." : submission ? "Обновить ответ" : "Сдать работу"}
</button>
<label className="btn-aubade text-xs px-3 py-2 cursor-pointer">
{uploading ? "Загрузка..." : "📎 Прикрепить файл"}
<input type="file" className="sr-only" onChange={handleFileUpload} disabled={uploading} />
</label>
{submission && (
<button onClick={() => setEditing(false)} className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Отмена
</button>
)}
</div>
</div>
)}
</div>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { useState, useTransition } from "react";
import { addComment, deleteComment } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/comment-actions";
type Comment = {
id: string;
text: string;
deleted: boolean;
createdAt: Date;
user: { id: string; name: string };
};
interface Props {
lessonId: string;
slug: string;
comments: Comment[];
currentUserId: string;
currentUserRole: string;
}
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole }: Props) {
const [text, setText] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const canModerate = currentUserRole === "curator" || currentUserRole === "admin";
function handleAdd(e: React.FormEvent) {
e.preventDefault();
if (!text.trim()) return;
setError(null);
startTransition(async () => {
try {
await addComment(lessonId, slug, text.trim());
setText("");
} catch {
setError("Не удалось отправить комментарий. Попробуйте ещё раз.");
}
});
}
function handleDelete(commentId: string) {
startTransition(async () => {
try {
await deleteComment(commentId, lessonId, slug);
} catch {
// ignore
}
});
}
return (
<div>
{/* Comment list */}
<div className="space-y-5 mb-6">
{comments.length === 0 && (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Пока нет комментариев. Будьте первым!
</p>
)}
{comments.map((comment) => (
<div key={comment.id} className="flex gap-3">
{/* Avatar */}
<div
className="w-8 h-8 shrink-0 flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: "var(--accent)",
color: "var(--foreground)",
border: "2px solid var(--border)",
}}
>
{comment.user.name[0]?.toUpperCase() ?? "?"}
</div>
{/* Body */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="text-sm font-bold">{comment.user.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{new Date(comment.createdAt).toLocaleDateString("ru-RU", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
{comment.deleted ? (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
[Комментарий удалён]
</p>
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
{comment.text}
</p>
)}
{!comment.deleted && (comment.user.id === currentUserId || canModerate) && (
<button
onClick={() => handleDelete(comment.id)}
disabled={isPending}
className="text-xs mt-1 underline"
style={{ color: "var(--muted-foreground)" }}
>
Удалить
</button>
)}
</div>
</div>
))}
</div>
{/* Add comment form */}
<form onSubmit={handleAdd}>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Напишите комментарий..."
rows={3}
maxLength={2000}
disabled={isPending}
className="w-full text-sm p-3 resize-y"
style={{
border: "2px solid var(--border)",
backgroundColor: "var(--background)",
color: "var(--foreground)",
outline: "none",
fontFamily: "inherit",
}}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
/>
{error && (
<p className="text-xs mt-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
{error}
</p>
)}
<div className="flex items-center justify-between mt-2">
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{text.length > 0 ? `${text.length}/2000` : ""}
</span>
<button
type="submit"
disabled={isPending || !text.trim()}
className="btn-aubade btn-aubade-accent text-sm"
>
{isPending ? "Отправка..." : "Отправить"}
</button>
</div>
</form>
</div>
);
}
@@ -0,0 +1,29 @@
"use client";
import { useTransition } from "react";
import { Check } from "lucide-react";
import { toggleLessonProgress } from "@/app/(student)/courses/[slug]/lessons/[lessonId]/actions";
export function LessonCompleteButton({
lessonId,
slug,
isCompleted,
}: {
lessonId: string;
slug: string;
isCompleted: boolean;
}) {
const [pending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => toggleLessonProgress(lessonId, slug))}
disabled={pending}
className={`btn-aubade ${isCompleted ? "btn-aubade-accent" : ""} flex items-center gap-2 px-5 py-2.5 text-sm`}
style={{ opacity: pending ? 0.6 : 1 }}
>
<Check size={15} strokeWidth={isCompleted ? 3 : 2} />
{pending ? "..." : isCompleted ? "Пройдено" : "Отметить как пройденный"}
</button>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
export function LessonContent({ content }: { content: object }) {
const editor = useEditor({
extensions: [
StarterKit,
Image.configure({ inline: false }),
Link.configure({ openOnClick: true }),
],
content,
editable: false,
editorProps: {
attributes: {
class: "prose prose-slate max-w-none focus:outline-none",
},
},
});
return <EditorContent editor={editor} />;
}
+187
View File
@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+201
View File
@@ -0,0 +1,201 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+49
View File
@@ -0,0 +1,49 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+10
View File
@@ -3,6 +3,7 @@ import { prismaAdapter } from "better-auth/adapters/prisma";
import { admin } from "better-auth/plugins"; import { admin } from "better-auth/plugins";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
import { sendWelcomeEmail } from "./email";
export const auth = betterAuth({ export const auth = betterAuth({
database: prismaAdapter(prisma, { database: prismaAdapter(prisma, {
@@ -16,6 +17,15 @@ export const auth = betterAuth({
verify: ({ hash, password }) => bcrypt.compare(password, hash), verify: ({ hash, password }) => bcrypt.compare(password, hash),
}, },
}, },
databaseHooks: {
user: {
create: {
after: async (user) => {
await sendWelcomeEmail(user.email, user.name);
},
},
},
},
plugins: [ plugins: [
admin({ admin({
defaultRole: "student", defaultRole: "student",
+191
View File
@@ -0,0 +1,191 @@
import { Resend } from "resend";
import { getSetting } from "./settings";
function getResend() {
return new Resend(process.env.RESEND_API_KEY);
}
const FROM = process.env.EMAIL_FROM ?? "noreply@mailsend.second-brain.ru";
const BASE_URL = process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru";
async function getSchoolName() {
return getSetting("schoolName");
}
// ── HTML template (inline styles for maximum email client compatibility) ───────
function base(content: string, schoolName = "Second Brain") {
return `<!DOCTYPE html>
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
<title>Second Brain</title>
</head>
<body style="margin:0;padding:0;background-color:#FFFFFF;font-family:Arial,Helvetica,sans-serif;color:#323232;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
<!-- Outer wrapper -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#FFFFFF;">
<tr>
<td align="center" style="padding:32px 16px;">
<!-- Shadow wrapper: gray bg + padding creates border+shadow illusion -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:560px;background-color:#AAAAAA;">
<tr>
<td style="padding:2px 6px 6px 2px;">
<!-- Card -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#F5F5F0;">
<!-- Header -->
<tr>
<td style="padding:20px 28px;border-bottom:2px solid #AAAAAA;background-color:#F5F5F0;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:13px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#323232;">${schoolName} · Образовательная платформа</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:28px 28px 24px;">
${content}
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:14px 28px;border-top:2px solid #AAAAAA;background-color:#F5F5F0;">
<p style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:11px;color:#AAAAAA;">Это автоматическое письмо, не отвечайте на него.</p>
</td>
</tr>
</table>
<!-- /Card -->
</td>
</tr>
</table>
<!-- /Shadow wrapper -->
</td>
</tr>
</table>
</body>
</html>`;
}
// Reusable inline styles
const p = `style="margin:0 0 14px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.65;color:#323232;"`;
const pLast = `style="margin:0;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:1.65;color:#323232;"`;
function btn(href: string, label: string) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-top:24px;">
<tr>
<td style="background-color:#E8F0D8;border:2px solid #323232;border-right:4px solid #323232;border-bottom:4px solid #323232;">
<a href="${href}" target="_blank" style="display:inline-block;padding:10px 22px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;color:#323232;text-decoration:none;">${label} →</a>
</td>
</tr>
</table>`;
}
function quote(text: string) {
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" style="margin:16px 0;">
<tr>
<td style="border-left:3px solid #323232;padding:10px 14px;background-color:#E8F0D8;">
<p style="margin:0;font-family:'Courier New',Courier,monospace;font-size:13px;line-height:1.6;color:#323232;">${text.replace(/\n/g, "<br/>")}</p>
</td>
</tr>
</table>`;
}
// ── Email senders ─────────────────────────────────────────────────────────────
export async function sendCourseAccessEmail(to: string, name: string, courseTitle: string) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Вам открыт доступ к курсу «${courseTitle}»`,
html: base(`
<p ${p}>Привет, ${name}!</p>
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong>.</p>
<p ${pLast}>Перейдите на платформу чтобы начать обучение:</p>
${btn(`${BASE_URL}/dashboard`, "Перейти к курсам")}
`, school),
}).catch((e) => console.error("[email] sendCourseAccessEmail:", e));
}
export async function sendHomeworkSubmittedEmail(
to: string,
curatorName: string,
studentName: string,
lessonTitle: string,
submissionId: string
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Новая работа на проверку — ${lessonTitle}`,
html: base(`
<p ${p}>Привет, ${curatorName}!</p>
<p ${p}>Студент <strong>${studentName}</strong> сдал работу по уроку <strong>«${lessonTitle}»</strong>.</p>
<p ${pLast}>Откройте работу чтобы проверить и оставить фидбек:</p>
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Проверить работу")}
`, school),
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
}
export async function sendFeedbackReceivedEmail(
to: string,
studentName: string,
lessonTitle: string,
feedbackText: string,
lessonUrl: string
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Получен фидбек по уроку «${lessonTitle}»`,
html: base(`
<p ${p}>Привет, ${studentName}!</p>
<p ${p}>Куратор проверил вашу работу по уроку <strong>«${lessonTitle}»</strong> и оставил обратную связь:</p>
${quote(feedbackText)}
<p ${pLast}>Вернитесь к уроку чтобы увидеть полный фидбек:</p>
${btn(lessonUrl, "Открыть урок")}
`, school),
}).catch((e) => console.error("[email] sendFeedbackReceivedEmail:", e));
}
export async function sendWelcomeEmail(to: string, name: string) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Добро пожаловать в ${school}`,
html: base(`
<p ${p}>Привет, ${name}!</p>
<p ${p}>Ваш аккаунт на образовательной платформе <strong>${school}</strong> подтверждён.</p>
<p ${pLast}>После того как администратор откроет вам доступ к курсу, вы получите письмо и сможете начать обучение.</p>
${btn(`${BASE_URL}/dashboard`, "Перейти на платформу")}
`, school),
}).catch((e) => console.error("[email] sendWelcomeEmail:", e));
}
export async function sendTestEmail(to: string) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Тест — ${school} LMS`,
html: base(`
<p ${p}>Привет!</p>
<p ${p}>Это тестовое письмо от платформы <strong>${school} LMS</strong>.</p>
<p ${p}>Так будут выглядеть все уведомления — доступ к курсу, фидбек по ДЗ и другие.</p>
<p ${pLast}>Если письмо отображается корректно, значит email-уведомления настроены и работают.</p>
${btn(`${BASE_URL}/dashboard`, "Открыть платформу")}
`, school),
}).catch((e) => console.error("[email] sendTestEmail:", e));
}
+188
View File
@@ -0,0 +1,188 @@
import { unified } from "unified";
import remarkParse from "remark-parse";
// ── Types ─────────────────────────────────────────────────────────────────────
type Mark = { type: string; attrs?: Record<string, unknown> };
type TipTapNode = {
type: string;
attrs?: Record<string, unknown>;
content?: TipTapNode[];
text?: string;
marks?: Mark[];
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type MdastNode = Record<string, any>;
// ── Inline converter ──────────────────────────────────────────────────────────
function convertInline(nodes: MdastNode[], marks: Mark[] = []): TipTapNode[] {
const result: TipTapNode[] = [];
for (const node of nodes) {
switch (node.type) {
case "text": {
if (!node.value) break;
const n: TipTapNode = { type: "text", text: node.value as string };
if (marks.length) n.marks = marks;
result.push(n);
break;
}
case "strong":
result.push(...convertInline(node.children, [...marks, { type: "bold" }]));
break;
case "emphasis":
result.push(...convertInline(node.children, [...marks, { type: "italic" }]));
break;
case "delete":
result.push(...convertInline(node.children, [...marks, { type: "strike" }]));
break;
case "inlineCode":
result.push({
type: "text",
text: node.value as string,
marks: [...marks, { type: "code" }],
});
break;
case "link": {
const linkMark: Mark = {
type: "link",
attrs: {
href: node.url ?? "#",
target: "_blank",
rel: "noopener noreferrer",
class: null,
},
};
result.push(...convertInline(node.children, [...marks, linkMark]));
break;
}
case "image":
// Only HTTP/HTTPS images — local Obsidian paths are not resolvable here
if (typeof node.url === "string" && /^https?:\/\//.test(node.url)) {
result.push({
type: "image",
attrs: {
src: node.url,
alt: node.alt ?? null,
title: node.title ?? null,
},
});
}
break;
case "break":
result.push({ type: "hardBreak" });
break;
default:
// Try to extract content from unknown inline nodes
if (Array.isArray(node.children)) {
result.push(...convertInline(node.children, marks));
} else if (node.value) {
const n: TipTapNode = { type: "text", text: node.value as string };
if (marks.length) n.marks = marks;
result.push(n);
}
}
}
return result;
}
// ── Block converter ───────────────────────────────────────────────────────────
function convertBlock(nodes: MdastNode[]): TipTapNode[] {
const result: TipTapNode[] = [];
for (const node of nodes) {
switch (node.type) {
case "paragraph": {
const content = convertInline(node.children ?? []);
result.push(content.length ? { type: "paragraph", content } : { type: "paragraph" });
break;
}
case "heading": {
const content = convertInline(node.children ?? []);
result.push({
type: "heading",
attrs: { level: node.depth ?? 2 },
content,
});
break;
}
case "blockquote": {
const content = convertBlock(node.children ?? []);
result.push({ type: "blockquote", content });
break;
}
case "code": {
result.push({
type: "codeBlock",
attrs: { language: node.lang ?? null },
content: [{ type: "text", text: node.value as string }],
});
break;
}
case "list": {
const listType = node.ordered ? "orderedList" : "bulletList";
const items = (node.children as MdastNode[]).map((item) => ({
type: "listItem",
content: convertBlock(item.children ?? []),
}));
const listNode: TipTapNode = { type: listType, content: items };
if (node.ordered) listNode.attrs = { start: (node.start as number) ?? 1 };
result.push(listNode);
break;
}
case "thematicBreak":
result.push({ type: "horizontalRule" });
break;
// Skip html, definitions, footnotes, etc.
}
}
return result;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Converts a Markdown string to a TipTap/ProseMirror JSON document.
* Handles: headings, paragraphs, bold, italic, strike, inline code,
* code blocks, blockquotes, lists (nested), links, images (HTTP only),
* horizontal rules, hard breaks.
*
* Obsidian-specific syntax (![[wikilink]], [[link]]) is silently ignored
* since local file paths are not available during server-side import.
*/
export function mdToTiptap(markdown: string): object {
// Strip Obsidian wikilinks: [[link]] → plain text, ![[image]] → removed
const cleaned = markdown
.replace(/!\[\[([^\]]+)\]\]/g, "") // remove ![[image]] embeds
.replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_m, target, alias) => alias ?? target); // [[link|alias]] → alias or target
const tree = unified().use(remarkParse).parse(cleaned);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = convertBlock((tree as any).children ?? []);
if (content.length === 0) {
content.push({ type: "paragraph" });
}
return { type: "doc", content };
}
+82
View File
@@ -0,0 +1,82 @@
import { prisma } from "./prisma";
// ── Defaults ──────────────────────────────────────────────────────────────────
export const SETTINGS_DEFAULTS = {
// Basic
schoolName: "Second Brain",
schoolDescription: "Образовательная платформа Second Brain",
schoolKeywords: "",
maintenanceMode: "false",
registrationEnabled: "true",
// Notifications
notificationEmails: "", // newline-separated list
notifyOnHomework: "true",
notifyOnRegistration: "true",
notifyStudentOnFeedback: "true",
// Student profile
requireEmailVerification: "true",
lastNameField: "optional", // required | optional | hidden
phoneField: "hidden", // required | optional | hidden
// Legal
privacyPolicyUrl: "",
termsUrl: "",
offerUrl: "",
showTermsCheckbox: "false",
orgRequisites: "",
// Curator permissions
curatorHomeworkScope: "all", // all | assigned
curatorCanAnswerQuestions: "true",
curatorCanSeeStudents: "true",
// Code injection
headCode: "",
bodyCode: "",
} as const;
export type SettingsKey = keyof typeof SETTINGS_DEFAULTS;
export type Settings = Record<SettingsKey, string>;
// ── Getters ───────────────────────────────────────────────────────────────────
export async function getSettings(): Promise<Settings> {
try {
const rows = await prisma.settings.findMany();
const stored: Record<string, string> = {};
for (const row of rows) stored[row.key] = row.value;
return Object.fromEntries(
Object.entries(SETTINGS_DEFAULTS).map(([k, v]) => [k, stored[k] ?? v])
) as Settings;
} catch {
// DB unavailable at build time — return defaults
return { ...SETTINGS_DEFAULTS } as Settings;
}
}
export async function getSetting(key: SettingsKey): Promise<string> {
try {
const row = await prisma.settings.findUnique({ where: { key } });
return row?.value ?? SETTINGS_DEFAULTS[key];
} catch {
return SETTINGS_DEFAULTS[key];
}
}
// ── Convenience helpers ───────────────────────────────────────────────────────
/** Parse a boolean setting ("true" / "false") */
export function asBool(value: string): boolean {
return value === "true";
}
/** Parse notification emails (newline-separated string → array) */
export function parseNotificationEmails(value: string): string[] {
return value
.split("\n")
.map((e) => e.trim())
.filter(Boolean);
}

Some files were not shown because too many files have changed in this diff Show More