Compare commits
29 Commits
d356dddc96
...
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 768a38b9d3 | |||
| f0024c4243 | |||
| d0ba4bf909 | |||
| dd46a10c20 | |||
| 99c143d670 | |||
| 58a61d6f04 | |||
| e77588deb8 | |||
| 093e403f5f | |||
| 66b311f17e | |||
| 32b0fa9d6f | |||
| c647b29712 | |||
| 6d93a7b406 | |||
| 97f4c1ec24 | |||
| ec51dd34bb | |||
| b40d518b74 | |||
| 6975a9f97e | |||
| 9bc18247df | |||
| 543d5b2d5e | |||
| d0c8c6dd53 | |||
| c88b5d2004 | |||
| 4183a912e4 | |||
| 07b9a6d261 | |||
| 05dd4d1df2 | |||
| 03e3972388 | |||
| 8fdc67b4a5 | |||
| e9eff5bae5 | |||
| 992763aeb9 | |||
| 09325187f9 | |||
| 01a9ef482c |
+265
-142
@@ -6,13 +6,12 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Этап 0 — Каркас, auth, роли ✅ ЗАВЕРШЁН (07.04.2026)
|
## Этап 0 — Каркас, auth, роли ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
**Задеплоено на:** https://school.second-brain.ru
|
|
||||||
|
|
||||||
- [x] Next.js 16.2.2 (App Router, TypeScript, Tailwind v4)
|
- [x] Next.js 16.2.2 (App Router, TypeScript, Tailwind v4)
|
||||||
- [x] Docker Compose: PostgreSQL 16 (прод на Hetzner)
|
- [x] Docker Compose: PostgreSQL 16 (прод на Hetzner)
|
||||||
- [x] Prisma 7: схема User, Session, Account + полная LMS-модель
|
- [x] Prisma 7: схема User, Session, Account + полная LMS-модель
|
||||||
- [x] Better Auth: вход по email/password, роли student/curator/admin
|
- [x] Better Auth: вход по email/password, роли student/curator/admin
|
||||||
- [x] proxy.ts: защита маршрутов по сессии
|
- [x] Middleware: защита маршрутов по сессии
|
||||||
- [x] Дашборды для трёх ролей
|
- [x] Дашборды для трёх ролей
|
||||||
- [x] Страница входа, регистрации, подтверждения email
|
- [x] Страница входа, регистрации, подтверждения email
|
||||||
- [x] Seed: admin/curator/student (пароль: Password123!)
|
- [x] Seed: admin/curator/student (пароль: Password123!)
|
||||||
@@ -21,62 +20,249 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке)
|
## Этап 1 — Курсы → Модули → Уроки (CRUD в админке) ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
**Цель:** могу создать полную структуру курса из браузера.
|
|
||||||
|
|
||||||
- [ ] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
|
- [x] Prisma-схема: Course, Module, Lesson (с порядком, статусом published)
|
||||||
- [ ] Admin: список курсов, создать / редактировать / удалить курс
|
- [x] Admin: список курсов, создать / редактировать / удалить курс
|
||||||
- [ ] Admin: список модулей внутри курса, drag-and-drop сортировка
|
- [x] Admin: список модулей внутри курса, drag-and-drop сортировка
|
||||||
- [ ] Admin: список уроков внутри модуля, drag-and-drop сортировка
|
- [x] Admin: список уроков внутри модуля, drag-and-drop сортировка
|
||||||
- [ ] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
|
- [x] Admin: редактор урока с TipTap (заголовки, списки, цитаты, код, картинки, ссылки)
|
||||||
- [ ] Загрузка картинок в уроке → Hetzner Object Storage
|
- [x] Загрузка картинок в уроке → Hetzner Object Storage
|
||||||
- [ ] Поле для Kinescope ID в уроке (просто текстовое, без интеграции — это Этап 2)
|
- [x] Поле для Kinescope ID в уроке
|
||||||
- [ ] Публикация/скрытие курса и урока (черновик / опубликован)
|
- [x] Публикация/скрытие курса и урока (черновик / опубликован)
|
||||||
- [ ] Управление доступом: выдать / забрать доступ к курсу для пользователя
|
- [x] Управление доступом: выдать / забрать доступ к курсу для пользователя
|
||||||
|
- [x] Дизайн: Fira Mono, #F5F5F0, Aubade-карточки
|
||||||
**Критерий готовности:** создаю курс «Obsidian PKM», добавляю 2 модуля, в каждом по 3 урока с текстом и картинкой, публикую.
|
- [x] Admin: таблица пользователей (/admin/users)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 2 — Интеграция Kinescope, рендер уроков для ученика
|
**Доработки таблицы пользователей (добавить в рамках Этапа 9):**
|
||||||
**Цель:** ученик видит урок с видео Kinescope и текстом.
|
- [ ] Фильтры: по роли (Ученик / Куратор / Администратор), по статусу email (подтверждён/нет)
|
||||||
|
- [ ] Поиск по имени / email
|
||||||
- [ ] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
|
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
|
||||||
- [ ] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
|
- [ ] Ховер-попап на строке: список курсов пользователя со сроками доступа + email + телефон
|
||||||
- [ ] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
|
- [ ] Быстрые действия в строке: кнопка «Выдать доступ» без перехода на страницу пользователя
|
||||||
- [ ] Страница курса для ученика: список модулей и уроков, статус прохождения
|
|
||||||
- [ ] Навигация по урокам: предыдущий / следующий
|
|
||||||
- [ ] Блокировка доступа к курсу без enrollment (middleware или server component)
|
|
||||||
- [ ] Страница «Мои курсы» в личном кабинете ученика
|
|
||||||
|
|
||||||
**Кинескоп-нюанс:** пока без DRM (бесплатный план), просто передаём `videoId` в компонент. Когда появится платный план — добавим signed URLs в отдельной задаче.
|
|
||||||
|
|
||||||
**Критерий готовности:** ученик открывает урок, смотрит видео из Kinescope, читает текст, скачивает PDF.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 3 — Прогресс и линейное открытие уроков
|
## Этап 1.5 — Расширенное управление доступом ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
**Цель:** ученик проходит курс последовательно, прогресс сохраняется.
|
|
||||||
|
|
||||||
- [ ] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
|
- [x] Срок доступа: `expiresAt` в `CourseEnrollment`, просроченный подсвечивается красным
|
||||||
- [ ] Кнопка «Урок завершён» — создаёт запись в LessonProgress
|
- [x] Категории курсов: таблица `Category`, `/admin/categories`, привязка к курсу
|
||||||
- [ ] Логика блокировки: следующий урок открыт только если предыдущий завершён
|
- [x] Расширенный энролл: `/admin/users/[userId]` — выбор нескольких курсов + срок одной операцией
|
||||||
- [ ] Прогресс-бар по курсу (% завершённых уроков)
|
- [x] История доступа: `AccessLog` — каждая операция логируется (кто, когда, метод, примечание)
|
||||||
- [ ] Прогресс-бар по модулю
|
|
||||||
- [ ] API: `POST /api/progress/complete` — принимает lessonId, создаёт прогресс
|
|
||||||
- [ ] Иконки статуса уроков в боковой панели: ✓ пройден / 🔒 заблокирован / → текущий
|
|
||||||
|
|
||||||
**Примечание:** если в уроке есть тест (Этап 4) или ДЗ (Этап 5) — кнопка «Завершить» появляется только после их прохождения/отправки. Эту логику дорабатываем на соответствующих этапах.
|
|
||||||
|
|
||||||
**Критерий готовности:** прохожу урок 1, нажимаю «Завершён» — открывается урок 2. Урок 3 заблокирован. Прогресс-бар показывает 33%.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 4 — Тесты и квизы
|
## Этап 2 — Интеграция Kinescope, рендер уроков для ученика ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Компонент `KinescopePlayer` — обёртка над `@kinescope/react-kinescope-player`
|
||||||
|
- [x] Рендер урока для ученика: видео (если есть Kinescope ID) + текст + файлы
|
||||||
|
- [x] Загрузка PDF/файлов к уроку (Object Storage), список для скачивания
|
||||||
|
- [x] Страница курса для ученика: список модулей и уроков, статус прохождения
|
||||||
|
- [x] Навигация по урокам: предыдущий / следующий
|
||||||
|
- [x] Блокировка доступа к курсу без enrollment (layout server component)
|
||||||
|
- [x] Страница «Мои курсы» в личном кабинете ученика (dashboard)
|
||||||
|
- [x] Кнопки Сохранить / Просмотр в редакторе урока
|
||||||
|
- [x] Иконка-статус уроков в боковой панели курса (✓ пройден)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 3 — Прогресс ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Prisma: таблица `LessonProgress` (userId, lessonId, completedAt)
|
||||||
|
- [x] Кнопка «Урок завершён» — создаёт/удаляет запись в LessonProgress
|
||||||
|
- [x] Прогресс-бар по курсу в боковой панели (% завершённых уроков)
|
||||||
|
- [x] Прогресс-бар по курсу на дашборде студента
|
||||||
|
- [x] Admin bypass: администратор видит все уроки без отметок о прогрессе
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 5 — Домашние задания и обратная связь куратора ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Prisma: Homework, HomeworkSubmission, HomeworkFeedback
|
||||||
|
- [x] Admin: добавить / редактировать / удалить блок ДЗ к уроку (HomeworkEditor)
|
||||||
|
- [x] Ученик: форма отправки ДЗ (текст + файлы → Object Storage)
|
||||||
|
- [x] Ученик: видит статус ДЗ и фидбек куратора в карточке урока
|
||||||
|
- [x] Куратор / Admin: список ДЗ на проверку (`/curator/homework`)
|
||||||
|
- [x] Куратор / Admin: просмотр работы, текстовый комментарий с обратной связью
|
||||||
|
- [x] Admin: AdminShell на `/curator/*` маршрутах (сайдбар не пропадает)
|
||||||
|
- [x] Admin: реальная статистика на дашборде (студенты, курсы, ДЗ, прогресс)
|
||||||
|
|
||||||
|
**Доработки (добавить в рамках Этапа 9):**
|
||||||
|
- [ ] Фильтры в списке ДЗ: по имени/email ученика, по уроку, по курсу, по статусу
|
||||||
|
- [ ] Поиск по имени/email ученика
|
||||||
|
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
|
||||||
|
- [ ] Расширенные статусы: «Новое» / «Просмотрено» / «Прокомментировано»
|
||||||
|
- [ ] Шаблоны ответов куратора — готовые тексты фидбека, выбираемые из списка
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 6 — Обсуждения под уроками ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Prisma: LessonComment (soft-delete через поле `deleted`)
|
||||||
|
- [x] Рендер списка комментариев под уроком (server-fetched, client-rendered)
|
||||||
|
- [x] Форма отправки комментария (только для enrolled учеников и admin)
|
||||||
|
- [x] Модерация: автор, куратор или admin может удалить комментарий
|
||||||
|
- [x] Счётчик активных комментариев в заголовке секции
|
||||||
|
|
||||||
|
**Не реализовано (добавить в Этап 9 или отдельно):**
|
||||||
|
- [ ] `/admin/comments` — сводная таблица всех комментариев по всем урокам
|
||||||
|
- Колонки: №, Имя ученика, Урок (ссылка), Время, кол-во ответов, превью текста
|
||||||
|
- Удалить комментарий прямо из списка
|
||||||
|
- Пагинация
|
||||||
|
- Ссылка в сайдбаре AdminNav
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] Базовый HTML email-шаблон (фирменный стиль Second Brain)
|
||||||
|
- [x] Приветственное письмо при регистрации (`databaseHooks.user.create.after`)
|
||||||
|
- [x] Письмо ученику об открытии доступа к курсу
|
||||||
|
- [x] Куратор / Admin: уведомление о новом ДЗ на проверку
|
||||||
|
- [x] Ученик: уведомление о полученном фидбеке
|
||||||
|
- [x] Resend domain: mailsend.second-brain.ru (verified)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 8 — Импорт уроков из Markdown (Obsidian) ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
|
- [x] API: `POST /api/admin/import-md` — принимает .md-файл
|
||||||
|
- [x] Парсинг frontmatter (title, kinescopeId, order, published) через `gray-matter`
|
||||||
|
- [x] Конвертация Markdown → TipTap JSON через `unified` + `remark-parse`
|
||||||
|
- [x] Поддержка: заголовки, параграфы, жирный/курсив/зачёркнутый, инлайн-код, блоки кода, цитаты, списки, ссылки, изображения (HTTP), горизонтальные разделители
|
||||||
|
- [x] Очистка Obsidian-синтаксиса: `![[image]]` удаляется, `[[link|alias]]` → текст
|
||||||
|
- [x] UI: кнопка «Импорт .md» в редакторе урока — заполняет форму без автосохранения
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 9 — Настройки платформы (Admin Settings) ✅ ЗАВЕРШЁН (08.04.2026)
|
||||||
|
**Цель:** администратор управляет ключевыми параметрами платформы без правки кода.
|
||||||
|
|
||||||
|
### Основное
|
||||||
|
- [ ] Название школы (используется в заголовке сайта, подписи писем)
|
||||||
|
- [ ] Описание школы (мета-тег description)
|
||||||
|
- [ ] Ключевые слова (мета-тег keywords)
|
||||||
|
- [ ] Режим тех. работ: вкл/выкл (показывает заглушку всем кроме admin)
|
||||||
|
- [ ] Регистрация учеников: вкл/выкл
|
||||||
|
|
||||||
|
### Оформление
|
||||||
|
- [ ] Логотип школы (загрузка → Object Storage, отображается в шапке)
|
||||||
|
- [ ] Фавикон (загрузка → Object Storage)
|
||||||
|
- [ ] Показывать логотип: да/нет
|
||||||
|
|
||||||
|
### Уведомления
|
||||||
|
- [ ] Email(ы) для системных уведомлений (кому слать письма о ДЗ, вопросах, регистрациях)
|
||||||
|
- [ ] Уведомление куратору/админу о новом ДЗ: вкл/выкл
|
||||||
|
- [ ] Уведомление куратору/админу о новом вопросе ученика: вкл/выкл
|
||||||
|
- [ ] Уведомление админу о новой регистрации: вкл/выкл
|
||||||
|
- [ ] Уведомление ученику при ответе на ДЗ/вопрос: вкл/выкл
|
||||||
|
|
||||||
|
### Данные ученика
|
||||||
|
- [ ] Требовать подтверждение email перед доступом к курсам: да/нет
|
||||||
|
- [ ] Фамилия при регистрации: обязательная / необязательная / выключена
|
||||||
|
- [ ] Телефон при регистрации: обязательный / необязательный / выключен
|
||||||
|
|
||||||
|
### Защита
|
||||||
|
- [ ] Одна активная сессия на аккаунт: вкл/выкл
|
||||||
|
- [ ] CAPTCHA на форме регистрации: вкл/выкл (reCAPTCHA v3)
|
||||||
|
|
||||||
|
### Права куратора
|
||||||
|
- [ ] Куратор видит ДЗ: по всем курсам / только по назначенным курсам
|
||||||
|
- [ ] Куратор может отвечать на вопросы учеников: да/нет
|
||||||
|
- [ ] Куратор видит список всех студентов: да/нет
|
||||||
|
|
||||||
|
### Вставка кода
|
||||||
|
- [ ] Произвольный код в `<head>` (Yandex.Metrika, Google Analytics, пиксели)
|
||||||
|
- [ ] Произвольный код в `<body>` (виджеты, чаты поддержки)
|
||||||
|
|
||||||
|
### Юридические документы
|
||||||
|
- [ ] URL Политики конфиденциальности (ссылка на внешний документ)
|
||||||
|
- [ ] URL Согласия на обработку персональных данных
|
||||||
|
- [ ] URL Договора-оферты
|
||||||
|
- [ ] Показывать чекбокс «Я принимаю условия» при регистрации: да/нет
|
||||||
|
- [ ] Реквизиты организации (текстовое поле, отображается в подвале)
|
||||||
|
|
||||||
|
### Соц. сети
|
||||||
|
- [ ] YouTube: одна ссылка
|
||||||
|
- [ ] VK: несколько ссылок (название + URL), например «Основная группа» и «Канал»
|
||||||
|
- [ ] Telegram: несколько ссылок (название + URL), например «Основной канал» и «Канал курса»
|
||||||
|
(отображаются в подвале личного кабинета ученика; хранятся как JSON-массив в Settings)
|
||||||
|
|
||||||
|
### Вопросы учеников
|
||||||
|
- [ ] Система вопросов глобально: вкл/выкл
|
||||||
|
- [ ] Куратор/админ может написать ученику первым: да/нет
|
||||||
|
- [ ] Вопросы только по курсам ученика: да/нет
|
||||||
|
- [ ] Включать вопросы для новых курсов автоматически: да/нет
|
||||||
|
|
||||||
|
**Хранение:** таблица `Settings` (key-value), доступна через `getSettings()` в server components.
|
||||||
|
**Критерий готовности:** меняю название школы → оно появляется в заголовке. Включаю тех. работы → ученики видят заглушку. Куратор привязан к курсу — видит только его ДЗ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 11 — Импорт/Экспорт учеников и миграция с emdesell
|
||||||
|
**Цель:** все пользователи и контент перенесены в новую LMS. Раздел `/admin/import-export`.
|
||||||
|
|
||||||
|
### Импорт учеников (CSV)
|
||||||
|
- [ ] Скачать файл-шаблон CSV (Email, Имя, Фамилия, Телефон)
|
||||||
|
- [ ] Загрузка CSV, поддержка кодировок Windows-1251 и UTF-8
|
||||||
|
- [ ] Опция: подтверждать email автоматически (да/нет)
|
||||||
|
- [ ] Опция: обновлять уже существующие аккаунты (да/нет)
|
||||||
|
- [ ] Присвоение доступов к курсам при импорте (выбор курса + срок в днях, 0 = бессрочно)
|
||||||
|
- [ ] Опция: отправить письмо-уведомление ученику (со ссылкой для установки пароля)
|
||||||
|
- [ ] Предпросмотр перед применением (таблица: кто создаётся, кто обновляется, кому даётся доступ)
|
||||||
|
- [ ] Применить импорт — создать пользователей, выдать доступы, отправить письма
|
||||||
|
|
||||||
|
### Экспорт учеников (CSV)
|
||||||
|
- [ ] Все ученики или фильтр по конкретному курсу/доступу
|
||||||
|
- [ ] Фильтр по просмотрам уроков (экспортировать только тех кто смотрел)
|
||||||
|
- [ ] Выбор кодировки: Windows-1251 (для Excel) / UTF-8
|
||||||
|
- [ ] Поля: Email, Имя, Фамилия, Телефон, Дата регистрации, Курсы, Прогресс
|
||||||
|
|
||||||
|
### Миграция контента
|
||||||
|
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
|
||||||
|
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
|
||||||
|
- [ ] QA: проверить 10 случайных аккаунтов после импорта
|
||||||
|
|
||||||
|
**Критерий готовности:** загружаю CSV из emdesell → предпросмотр показывает корректные данные → применяю → ученики получают письма → могут войти и продолжить обучение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 12 — Telegram-бот и аналитика
|
||||||
|
**Цель:** уведомления в Telegram для всех участников, базовая аналитика.
|
||||||
|
|
||||||
|
**Настройки (в разделе Настройки → Telegram):**
|
||||||
|
- Токен бота (вводится в админке, хранится в Settings)
|
||||||
|
- Интеграция вкл/выкл глобально
|
||||||
|
- Показывать кнопку «Подключить Telegram» в кабинете ученика: да/нет
|
||||||
|
|
||||||
|
**Уведомления куратору/админу:**
|
||||||
|
- [ ] Новое ДЗ на проверку
|
||||||
|
- [ ] Новый вопрос от ученика
|
||||||
|
- [ ] Новая регистрация студента
|
||||||
|
- [ ] Ошибки платформы (500-е, failed email и т.д.)
|
||||||
|
|
||||||
|
**Уведомления ученику:**
|
||||||
|
- [ ] Получен фидбек по ДЗ
|
||||||
|
- [ ] Ответ куратора на вопрос
|
||||||
|
- [ ] Открыт доступ к новому курсу
|
||||||
|
|
||||||
|
**Реализация:**
|
||||||
|
- [ ] Ученик привязывает Telegram через `/start` в боте (сохраняется `telegramChatId` в User)
|
||||||
|
- [ ] Кнопка «Подключить Telegram» в личном кабинете ученика
|
||||||
|
- [ ] Админ/куратор вводит свой `chatId` в профиле или через `/start`
|
||||||
|
- [ ] Настройки бота в разделе Настройки → Telegram
|
||||||
|
- [ ] Yandex.Metrika: базовое подключение (pageviews)
|
||||||
|
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Этап 13 — Тесты и квизы
|
||||||
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
|
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
|
||||||
|
|
||||||
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt
|
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть)
|
||||||
- [ ] Admin: создание теста к уроку (добавить вопрос → варианты ответов → отметить правильный)
|
- [ ] Admin: создание теста к уроку (вопрос → варианты → отметить правильный)
|
||||||
- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст
|
- [ ] Типы вопросов: одиночный выбор, множественный выбор, короткий текст
|
||||||
- [ ] Рендер теста в уроке для ученика
|
- [ ] Рендер теста в уроке для ученика
|
||||||
- [ ] Авто-проверка (single/multiple choice), результат сразу
|
- [ ] Авто-проверка (single/multiple choice), результат сразу
|
||||||
@@ -87,105 +273,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
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
Generated
+767
@@ -14,12 +14,14 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tiptap/extension-image": "^3.22.2",
|
"@tiptap/extension-image": "^3.22.2",
|
||||||
"@tiptap/extension-link": "^3.22.2",
|
"@tiptap/extension-link": "^3.22.2",
|
||||||
"@tiptap/extension-placeholder": "^3.22.2",
|
"@tiptap/extension-placeholder": "^3.22.2",
|
||||||
|
"@tiptap/extension-underline": "^3.22.2",
|
||||||
"@tiptap/pm": "^3.22.2",
|
"@tiptap/pm": "^3.22.2",
|
||||||
"@tiptap/react": "^3.22.2",
|
"@tiptap/react": "^3.22.2",
|
||||||
"@tiptap/starter-kit": "^3.22.2",
|
"@tiptap/starter-kit": "^3.22.2",
|
||||||
@@ -27,17 +29,21 @@
|
|||||||
"better-auth": "^1.6.0",
|
"better-auth": "^1.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.10.0",
|
||||||
"shadcn": "^4.1.2",
|
"shadcn": "^4.1.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"unified": "^11.0.5",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2762,6 +2768,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kinescope/react-kinescope-player": {
|
||||||
|
"version": "0.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kinescope/react-kinescope-player/-/react-kinescope-player-0.5.4.tgz",
|
||||||
|
"integrity": "sha512-2IYyHqTV8DwKvhMOyO+CeOeS0D15eG21N0SB1qq9LwXsG+kccrEW/XBxhDGmRX15PamPLWiggmdljtmM06lp1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@kurkle/color": {
|
"node_modules/@kurkle/color": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
@@ -5195,6 +5211,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
||||||
|
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -5232,12 +5257,27 @@
|
|||||||
"@types/mdurl": "^2"
|
"@types/mdurl": "^2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/mdast": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/mdurl": {
|
"node_modules/@types/mdurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
@@ -5282,6 +5322,12 @@
|
|||||||
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/unist": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
@@ -6281,6 +6327,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bail": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
@@ -6687,6 +6743,16 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/character-entities": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chart.js": {
|
"node_modules/chart.js": {
|
||||||
"version": "4.5.1",
|
"version": "4.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
@@ -7148,6 +7214,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decode-named-character-reference": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"character-entities": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -7289,6 +7368,15 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/destr": {
|
"node_modules/destr": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
@@ -7306,6 +7394,19 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/devlop": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dequal": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/diff": {
|
"node_modules/diff": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
|
||||||
@@ -8252,6 +8353,24 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/extend-shallow": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-extendable": "^0.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-check": {
|
"node_modules/fast-check": {
|
||||||
"version": "3.23.2",
|
"version": "3.23.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz",
|
||||||
@@ -8900,6 +9019,43 @@
|
|||||||
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
"node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/gray-matter": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"js-yaml": "^3.13.1",
|
||||||
|
"kind-of": "^6.0.2",
|
||||||
|
"section-matter": "^1.0.0",
|
||||||
|
"strip-bom-string": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gray-matter/node_modules/argparse": {
|
||||||
|
"version": "1.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||||
|
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sprintf-js": "~1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/gray-matter/node_modules/js-yaml": {
|
||||||
|
"version": "3.14.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
|
||||||
|
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^1.0.7",
|
||||||
|
"esprima": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-bigints": {
|
"node_modules/has-bigints": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
|
||||||
@@ -9342,6 +9498,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-extendable": {
|
||||||
|
"version": "0.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
|
||||||
|
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-extglob": {
|
"node_modules/is-extglob": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
@@ -9904,6 +10069,15 @@
|
|||||||
"json-buffer": "3.0.1"
|
"json-buffer": "3.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/kind-of": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kleur": {
|
"node_modules/kleur": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||||
@@ -10410,6 +10584,43 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdast-util-from-markdown": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mdast": "^4.0.0",
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"decode-named-character-reference": "^1.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"mdast-util-to-string": "^4.0.0",
|
||||||
|
"micromark": "^4.0.0",
|
||||||
|
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
||||||
|
"micromark-util-decode-string": "^2.0.0",
|
||||||
|
"micromark-util-normalize-identifier": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0",
|
||||||
|
"unist-util-stringify-position": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mdast-util-to-string": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mdast": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mdurl": {
|
"node_modules/mdurl": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||||
@@ -10452,6 +10663,448 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromark": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/debug": "^4.0.0",
|
||||||
|
"debug": "^4.0.0",
|
||||||
|
"decode-named-character-reference": "^1.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"micromark-core-commonmark": "^2.0.0",
|
||||||
|
"micromark-factory-space": "^2.0.0",
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-chunked": "^2.0.0",
|
||||||
|
"micromark-util-combine-extensions": "^2.0.0",
|
||||||
|
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
||||||
|
"micromark-util-encode": "^2.0.0",
|
||||||
|
"micromark-util-normalize-identifier": "^2.0.0",
|
||||||
|
"micromark-util-resolve-all": "^2.0.0",
|
||||||
|
"micromark-util-sanitize-uri": "^2.0.0",
|
||||||
|
"micromark-util-subtokenize": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-core-commonmark": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decode-named-character-reference": "^1.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"micromark-factory-destination": "^2.0.0",
|
||||||
|
"micromark-factory-label": "^2.0.0",
|
||||||
|
"micromark-factory-space": "^2.0.0",
|
||||||
|
"micromark-factory-title": "^2.0.0",
|
||||||
|
"micromark-factory-whitespace": "^2.0.0",
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-chunked": "^2.0.0",
|
||||||
|
"micromark-util-classify-character": "^2.0.0",
|
||||||
|
"micromark-util-html-tag-name": "^2.0.0",
|
||||||
|
"micromark-util-normalize-identifier": "^2.0.0",
|
||||||
|
"micromark-util-resolve-all": "^2.0.0",
|
||||||
|
"micromark-util-subtokenize": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-factory-destination": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-factory-label": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-factory-space": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-factory-title": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-factory-space": "^2.0.0",
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-factory-whitespace": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-factory-space": "^2.0.0",
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-character": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-chunked": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-symbol": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-classify-character": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-combine-extensions": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-chunked": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-decode-numeric-character-reference": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-symbol": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-decode-string": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decode-named-character-reference": "^1.0.0",
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-encode": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-html-tag-name": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-normalize-identifier": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-symbol": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-resolve-all": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-sanitize-uri": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"micromark-util-character": "^2.0.0",
|
||||||
|
"micromark-util-encode": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-subtokenize": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"micromark-util-chunked": "^2.0.0",
|
||||||
|
"micromark-util-symbol": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-symbol": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/micromark-util-types": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "GitHub Sponsors",
|
||||||
|
"url": "https://github.com/sponsors/unifiedjs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "OpenCollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromatch": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -12131,6 +12784,22 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/remark-parse": {
|
||||||
|
"version": "11.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||||
|
"integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/mdast": "^4.0.0",
|
||||||
|
"mdast-util-from-markdown": "^2.0.0",
|
||||||
|
"micromark-util-types": "^2.0.0",
|
||||||
|
"unified": "^11.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remeda": {
|
"node_modules/remeda": {
|
||||||
"version": "2.33.4",
|
"version": "2.33.4",
|
||||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||||
@@ -12411,6 +13080,19 @@
|
|||||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/section-matter": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend-shallow": "^2.0.1",
|
||||||
|
"kind-of": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -12843,6 +13525,12 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/sqlstring": {
|
"node_modules/sqlstring": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
|
||||||
@@ -13095,6 +13783,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strip-bom-string": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/strip-final-newline": {
|
"node_modules/strip-final-newline": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
|
||||||
@@ -13354,6 +14051,16 @@
|
|||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/trough": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -13663,6 +14370,38 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unified": {
|
||||||
|
"version": "11.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"bail": "^2.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"extend": "^3.0.0",
|
||||||
|
"is-plain-obj": "^4.0.0",
|
||||||
|
"trough": "^2.0.0",
|
||||||
|
"vfile": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unist-util-stringify-position": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/universalify": {
|
"node_modules/universalify": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||||
@@ -13833,6 +14572,34 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vfile": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"vfile-message": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vfile-message": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"unist-util-stringify-position": "^4.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/w3c-keyname": {
|
"node_modules/w3c-keyname": {
|
||||||
"version": "2.2.8",
|
"version": "2.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
|||||||
@@ -19,12 +19,14 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@tiptap/extension-image": "^3.22.2",
|
"@tiptap/extension-image": "^3.22.2",
|
||||||
"@tiptap/extension-link": "^3.22.2",
|
"@tiptap/extension-link": "^3.22.2",
|
||||||
"@tiptap/extension-placeholder": "^3.22.2",
|
"@tiptap/extension-placeholder": "^3.22.2",
|
||||||
|
"@tiptap/extension-underline": "^3.22.2",
|
||||||
"@tiptap/pm": "^3.22.2",
|
"@tiptap/pm": "^3.22.2",
|
||||||
"@tiptap/react": "^3.22.2",
|
"@tiptap/react": "^3.22.2",
|
||||||
"@tiptap/starter-kit": "^3.22.2",
|
"@tiptap/starter-kit": "^3.22.2",
|
||||||
@@ -32,17 +34,21 @@
|
|||||||
"better-auth": "^1.6.0",
|
"better-auth": "^1.6.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.10.0",
|
||||||
"shadcn": "^4.1.2",
|
"shadcn": "^4.1.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0",
|
"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;
|
||||||
+56
-14
@@ -24,13 +24,15 @@ model User {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
enrollments CourseEnrollment[]
|
enrollments CourseEnrollment[]
|
||||||
progress LessonProgress[]
|
progress LessonProgress[]
|
||||||
submissions HomeworkSubmission[]
|
submissions HomeworkSubmission[]
|
||||||
comments LessonComment[]
|
comments LessonComment[]
|
||||||
feedbacks HomeworkFeedback[]
|
feedbacks HomeworkFeedback[]
|
||||||
|
accessLogs AccessLog[] @relation("AccessLogUser")
|
||||||
|
adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
|
||||||
}
|
}
|
||||||
|
|
||||||
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,20 +97,24 @@ 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
|
||||||
order Int @default(0)
|
description String?
|
||||||
createdAt DateTime @default(now())
|
order Int @default(0)
|
||||||
updatedAt DateTime @updatedAt
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
lessons Lesson[]
|
lessons Lesson[]
|
||||||
@@ -137,7 +153,8 @@ model LessonFile {
|
|||||||
model CourseEnrollment {
|
model CourseEnrollment {
|
||||||
userId String
|
userId String
|
||||||
courseId String
|
courseId String
|
||||||
enrolledAt DateTime @default(now())
|
enrolledAt DateTime @default(now())
|
||||||
|
expiresAt DateTime?
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
@@ -145,6 +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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function CoursePage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
const course = await prisma.course.findUnique({
|
||||||
|
where: { slug, published: true },
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: {
|
||||||
|
lessons: {
|
||||||
|
where: { published: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) notFound();
|
||||||
|
|
||||||
|
// Redirect to the first published lesson
|
||||||
|
for (const mod of course.modules) {
|
||||||
|
if (mod.lessons.length > 0) {
|
||||||
|
redirect(`/courses/${slug}/lessons/${mod.lessons[0].id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No lessons yet — show placeholder
|
||||||
|
return (
|
||||||
|
<div className="p-10 text-center">
|
||||||
|
<p className="text-4xl mb-4">📭</p>
|
||||||
|
<p className="font-bold text-lg">Уроков пока нет</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Курс в разработке. Загляните позже.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,153 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
export default async function StudentDashboard() {
|
export default async function StudentDashboard() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
|
|
||||||
|
const enrollments = await prisma.courseEnrollment.findMany({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
include: {
|
||||||
|
course: {
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
include: {
|
||||||
|
lessons: {
|
||||||
|
where: { published: true },
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: { select: { modules: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { enrolledAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const active = enrollments.filter((e) => !e.expiresAt || e.expiresAt > now);
|
||||||
|
const expired = enrollments.filter((e) => e.expiresAt && e.expiresAt <= now);
|
||||||
|
|
||||||
|
// Fetch progress for all lessons in active courses
|
||||||
|
const allLessonIds = active.flatMap((e) =>
|
||||||
|
e.course.modules.flatMap((m) => m.lessons.map((l) => l.id))
|
||||||
|
);
|
||||||
|
const progressRecords = allLessonIds.length > 0
|
||||||
|
? await prisma.lessonProgress.findMany({
|
||||||
|
where: { userId: session.user.id, lessonId: { in: allLessonIds } },
|
||||||
|
select: { lessonId: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const completedSet = new Set(progressRecords.map((p) => p.lessonId));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-amber-50">
|
<main className="max-w-4xl mx-auto px-6 py-10 w-full">
|
||||||
<header className="bg-white border-b border-amber-100 px-6 py-4 flex items-center justify-between">
|
<h1 className="text-2xl font-bold mb-1">Мои курсы</h1>
|
||||||
<h1 className="text-xl font-bold text-amber-900">Second Brain</h1>
|
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<div className="flex items-center gap-4">
|
{active.length} активных курсов
|
||||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
</p>
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
{active.length === 0 ? (
|
||||||
</header>
|
<div className="card-aubade p-12 text-center">
|
||||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
|
||||||
Добро пожаловать, {session.user.name}!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 mb-8">Ваши курсы появятся здесь.</p>
|
|
||||||
<div className="bg-white rounded-2xl border border-amber-100 p-8 text-center text-gray-400">
|
|
||||||
<p className="text-4xl mb-3">📚</p>
|
<p className="text-4xl mb-3">📚</p>
|
||||||
<p>Доступных курсов пока нет.</p>
|
<p className="font-medium">Доступных курсов пока нет</p>
|
||||||
<p className="text-sm mt-1">Обратитесь к администратору за доступом.</p>
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Обратитесь к администратору за доступом
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
) : (
|
||||||
</div>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{active.map(({ course, expiresAt }) => {
|
||||||
|
const totalLessons = course.modules.reduce((s, m) => s + m.lessons.length, 0);
|
||||||
|
const completedLessons = course.modules
|
||||||
|
.flatMap((m) => m.lessons)
|
||||||
|
.filter((l) => completedSet.has(l.id)).length;
|
||||||
|
const progressPct = totalLessons > 0
|
||||||
|
? Math.round((completedLessons / totalLessons) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={course.id}
|
||||||
|
href={`/courses/${course.slug}`}
|
||||||
|
className="card-aubade p-0 overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
{course.coverImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={course.coverImage} alt={course.title} className="w-full aspect-video object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-video flex items-center justify-center text-4xl" style={{ backgroundColor: "var(--color-surface)" }}>
|
||||||
|
📚
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4 flex-1 flex flex-col gap-2">
|
||||||
|
<h2 className="font-bold text-base leading-tight">{course.title}</h2>
|
||||||
|
{course.description && (
|
||||||
|
<p className="text-xs line-clamp-2" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
{totalLessons > 0 && (
|
||||||
|
<div className="mt-auto">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{completedLessons} из {totalLessons} уроков
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-xs font-bold"
|
||||||
|
style={{ color: progressPct === 100 ? "var(--foreground)" : "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{progressPct === 100 ? "✓ Завершён" : `${progressPct}%`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full" style={{ background: "var(--border)" }}>
|
||||||
|
<div
|
||||||
|
className="h-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${progressPct}%`,
|
||||||
|
background: progressPct === 100 ? "var(--foreground)" : "var(--accent)",
|
||||||
|
border: progressPct > 0 ? "1px solid var(--foreground)" : "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expiresAt && (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Доступ до {new Date(expiresAt).toLocaleDateString("ru-RU")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired.length > 0 && (
|
||||||
|
<div className="mt-10">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Доступ истёк
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{expired.map(({ course, expiresAt }) => (
|
||||||
|
<div key={course.id} className="flex items-center justify-between px-4 py-3 opacity-50" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<span className="text-sm font-medium">{course.title}</span>
|
||||||
|
<span className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
Истёк {new Date(expiresAt!).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(str: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
а:"a",б:"b",в:"v",г:"g",д:"d",е:"e",ё:"yo",ж:"zh",з:"z",и:"i",й:"y",
|
||||||
|
к:"k",л:"l",м:"m",н:"n",о:"o",п:"p",р:"r",с:"s",т:"t",у:"u",ф:"f",
|
||||||
|
х:"kh",ц:"ts",ч:"ch",ш:"sh",щ:"shch",ъ:"",ы:"y",ь:"",э:"e",ю:"yu",я:"ya",
|
||||||
|
};
|
||||||
|
return str.toLowerCase()
|
||||||
|
.replace(/[а-яё]/g, (c) => map[c] ?? c)
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCategory(formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const slug = (formData.get("slug") as string).trim() || slugify(title);
|
||||||
|
const count = await prisma.category.count();
|
||||||
|
await prisma.category.create({ data: { title, slug, order: count } });
|
||||||
|
revalidatePath("/admin/categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCategory(id: string, formData: FormData) {
|
||||||
|
await requireAdmin();
|
||||||
|
const title = formData.get("title") as string;
|
||||||
|
const slug = formData.get("slug") as string;
|
||||||
|
await prisma.category.update({ where: { id }, data: { title, slug } });
|
||||||
|
revalidatePath("/admin/categories");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCategory(id: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.category.delete({ where: { id } });
|
||||||
|
revalidatePath("/admin/categories");
|
||||||
|
revalidatePath("/admin/courses");
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { CategoryRow } from "@/components/admin/category-row";
|
||||||
|
import { createCategory } from "./actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
|
export default async function CategoriesPage() {
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
include: { _count: { select: { courses: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--foreground)" }}>Категории</h1>
|
||||||
|
<p className="text-sm mt-0.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{categories.length} категорий
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-8">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<p className="text-sm py-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Категорий пока нет. Создайте первую.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
categories.map((cat) => (
|
||||||
|
<CategoryRow key={cat.id} category={cat} courseCount={cat._count.courses} />
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Новая категория
|
||||||
|
</p>
|
||||||
|
<form action={createCategory} className="flex flex-col gap-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Название
|
||||||
|
</label>
|
||||||
|
<Input name="title" placeholder="Obsidian PKM" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs uppercase tracking-widest font-bold block mb-1.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Slug
|
||||||
|
</label>
|
||||||
|
<Input name="slug" placeholder="obsidian-pkm (авто)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="submit">+ Создать</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function adminDeleteComment(commentId: string) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
|
||||||
|
|
||||||
|
await prisma.lessonComment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: { deleted: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/admin/comments");
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,13 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { sendCourseAccessEmail } from "@/lib/email";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Modules ──────────────────────────────────────────────────────────────────
|
// ── Modules ──────────────────────────────────────────────────────────────────
|
||||||
@@ -16,14 +19,16 @@ export async function createModule(courseId: string, formData: FormData) {
|
|||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const title = formData.get("title") as string;
|
const title = formData.get("title") as string;
|
||||||
const count = await prisma.module.count({ where: { courseId } });
|
const count = await prisma.module.count({ where: { courseId } });
|
||||||
await prisma.module.create({ data: { courseId, title, order: count } });
|
const mod = await prisma.module.create({ data: { courseId, title, order: count } });
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
|
redirect(`/admin/courses/${courseId}/modules/${mod.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
|
export async function updateModule(moduleId: string, courseId: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const title = formData.get("title") as string;
|
const title = formData.get("title") as string;
|
||||||
await prisma.module.update({ where: { id: moduleId }, data: { title } });
|
const description = (formData.get("description") as string | null)?.trim() || null;
|
||||||
|
await prisma.module.update({ where: { id: moduleId }, data: { title, description } });
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +50,55 @@ export async function reorderModules(courseId: string, orderedIds: string[]) {
|
|||||||
|
|
||||||
// ── Enrollment ───────────────────────────────────────────────────────────────
|
// ── Enrollment ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function grantAccess(courseId: string, userId: string) {
|
export async function grantAccess(
|
||||||
await requireAdmin();
|
courseId: string,
|
||||||
|
userId: string,
|
||||||
|
expiresAt?: string | null,
|
||||||
|
note?: string
|
||||||
|
) {
|
||||||
|
const session = await requireAdmin();
|
||||||
await prisma.courseEnrollment.upsert({
|
await prisma.courseEnrollment.upsert({
|
||||||
where: { userId_courseId: { userId, courseId } },
|
where: { userId_courseId: { userId, courseId } },
|
||||||
update: {},
|
update: { expiresAt: expiresAt ? new Date(expiresAt) : null },
|
||||||
create: { userId, courseId },
|
create: { userId, courseId, expiresAt: expiresAt ? new Date(expiresAt) : null },
|
||||||
});
|
});
|
||||||
|
await prisma.accessLog.create({
|
||||||
|
data: {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
action: "granted",
|
||||||
|
method: "manual",
|
||||||
|
grantedById: session.user.id,
|
||||||
|
note: note || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send email notification
|
||||||
|
const [user, course] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
||||||
|
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
|
||||||
|
]);
|
||||||
|
if (user && course) {
|
||||||
|
await sendCourseAccessEmail(user.email, user.name, course.title);
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function revokeAccess(courseId: string, userId: string) {
|
export async function revokeAccess(courseId: string, userId: string, note?: string) {
|
||||||
await requireAdmin();
|
const session = await requireAdmin();
|
||||||
await prisma.courseEnrollment.delete({
|
await prisma.courseEnrollment.delete({
|
||||||
where: { userId_courseId: { userId, courseId } },
|
where: { userId_courseId: { userId, courseId } },
|
||||||
});
|
});
|
||||||
|
await prisma.accessLog.create({
|
||||||
|
data: {
|
||||||
|
courseId,
|
||||||
|
userId,
|
||||||
|
action: "revoked",
|
||||||
|
method: "manual",
|
||||||
|
grantedById: session.user.id,
|
||||||
|
note: note || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
revalidatePath(`/admin/courses/${courseId}`);
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
@@ -13,10 +14,13 @@ async function requireAdmin() {
|
|||||||
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
|
export async function createLesson(moduleId: string, courseId: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const title = formData.get("title") as string;
|
const title = formData.get("title") as string;
|
||||||
|
const kinescopeId = (formData.get("kinescopeId") as string | null)?.trim() || null;
|
||||||
const count = await prisma.lesson.count({ where: { moduleId } });
|
const count = await prisma.lesson.count({ where: { moduleId } });
|
||||||
const lesson = await prisma.lesson.create({ data: { moduleId, title, order: count } });
|
const lesson = await prisma.lesson.create({
|
||||||
|
data: { moduleId, title, kinescopeId, order: count },
|
||||||
|
});
|
||||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||||
return lesson.id;
|
redirect(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
|
export async function updateLesson(lessonId: string, courseId: string, moduleId: string, formData: FormData) {
|
||||||
@@ -41,3 +45,46 @@ export async function reorderLessons(moduleId: string, courseId: string, ordered
|
|||||||
);
|
);
|
||||||
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function toggleLessonPublished(
|
||||||
|
lessonId: string,
|
||||||
|
courseId: string,
|
||||||
|
moduleId: string,
|
||||||
|
currentValue: boolean
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.lesson.update({
|
||||||
|
where: { id: lessonId },
|
||||||
|
data: { published: !currentValue },
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`);
|
||||||
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function moveLessonToModule(
|
||||||
|
lessonId: string,
|
||||||
|
targetModuleId: string,
|
||||||
|
courseId: string,
|
||||||
|
sourceModuleId: string
|
||||||
|
) {
|
||||||
|
await requireAdmin();
|
||||||
|
// verify target module belongs to same course
|
||||||
|
const target = await prisma.module.findFirst({
|
||||||
|
where: { id: targetModuleId, courseId },
|
||||||
|
});
|
||||||
|
if (!target) throw new Error("Module not found");
|
||||||
|
|
||||||
|
const maxOrder = await prisma.lesson.aggregate({
|
||||||
|
where: { moduleId: targetModuleId },
|
||||||
|
_max: { order: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.lesson.update({
|
||||||
|
where: { id: lessonId },
|
||||||
|
data: { moduleId: targetModuleId, order: (maxOrder._max.order ?? -1) + 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/admin/courses/${courseId}/modules/${sourceModuleId}`);
|
||||||
|
revalidatePath(`/admin/courses/${courseId}/modules/${targetModuleId}`);
|
||||||
|
revalidatePath(`/admin/courses/${courseId}`);
|
||||||
|
}
|
||||||
|
|||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
async function requireAdmin() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveHomework(lessonId: string, description: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.homework.upsert({
|
||||||
|
where: { lessonId },
|
||||||
|
update: { description },
|
||||||
|
create: { lessonId, description },
|
||||||
|
});
|
||||||
|
revalidatePath(`/admin/courses`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteHomework(lessonId: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
await prisma.homework.delete({ where: { lessonId } });
|
||||||
|
revalidatePath(`/admin/courses`);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LessonEditor } from "@/components/admin/lesson-editor";
|
import { LessonEditor } from "@/components/admin/lesson-editor";
|
||||||
|
import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
|
||||||
|
import { HomeworkEditor } from "@/components/admin/homework-editor";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
|
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
|
||||||
@@ -10,44 +12,75 @@ interface Props {
|
|||||||
export default async function LessonEditorPage({ params }: Props) {
|
export default async function LessonEditorPage({ params }: Props) {
|
||||||
const { courseId, moduleId, lessonId } = await params;
|
const { courseId, moduleId, lessonId } = await params;
|
||||||
|
|
||||||
const lesson = await prisma.lesson.findUnique({
|
const [lesson, siblings] = await Promise.all([
|
||||||
where: { id: lessonId },
|
prisma.lesson.findUnique({
|
||||||
include: {
|
where: { id: lessonId },
|
||||||
module: {
|
include: {
|
||||||
include: { course: { select: { title: true } } },
|
files: { orderBy: { createdAt: "asc" } },
|
||||||
|
homework: true,
|
||||||
|
module: {
|
||||||
|
include: { course: { select: { title: true, slug: true } } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
prisma.lesson.findMany({
|
||||||
|
where: { moduleId },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!lesson || lesson.moduleId !== moduleId) notFound();
|
if (!lesson || lesson.moduleId !== moduleId) notFound();
|
||||||
|
|
||||||
|
const idx = siblings.findIndex((l) => l.id === lessonId);
|
||||||
|
const prevLesson = idx > 0 ? siblings[idx - 1] : null;
|
||||||
|
const nextLesson = idx < siblings.length - 1 ? siblings[idx + 1] : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl">
|
<div className="p-8 max-w-4xl">
|
||||||
<nav className="text-sm text-slate-400 mb-6">
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
|
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}`} className="hover:text-slate-600">
|
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{lesson.module.course.title}</Link>
|
||||||
{lesson.module.course.title}
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:text-slate-600">
|
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:underline">{lesson.module.title}</Link>
|
||||||
{lesson.module.title}
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-slate-700">{lesson.title}</span>
|
<span style={{ color: "var(--foreground)" }}>{lesson.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<LessonEditor
|
{/* Lesson editor */}
|
||||||
lesson={{
|
<div className="card-aubade p-6 mb-6">
|
||||||
id: lesson.id,
|
<LessonEditor
|
||||||
title: lesson.title,
|
lesson={{
|
||||||
kinescopeId: lesson.kinescopeId ?? "",
|
id: lesson.id,
|
||||||
content: lesson.content as object ?? {},
|
title: lesson.title,
|
||||||
published: lesson.published,
|
kinescopeId: lesson.kinescopeId ?? "",
|
||||||
}}
|
content: (lesson.content as object) ?? {},
|
||||||
courseId={courseId}
|
published: lesson.published,
|
||||||
moduleId={moduleId}
|
}}
|
||||||
/>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { SortableLessons } from "@/components/admin/sortable-lessons";
|
import { SortableLessons } from "@/components/admin/sortable-lessons";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -11,35 +10,50 @@ interface Props {
|
|||||||
export default async function ModulePage({ params }: Props) {
|
export default async function ModulePage({ params }: Props) {
|
||||||
const { courseId, moduleId } = await params;
|
const { courseId, moduleId } = await params;
|
||||||
|
|
||||||
const module = await prisma.module.findUnique({
|
const [module, allModules] = await Promise.all([
|
||||||
where: { id: moduleId },
|
prisma.module.findUnique({
|
||||||
include: {
|
where: { id: moduleId },
|
||||||
course: { select: { title: true } },
|
include: {
|
||||||
lessons: { orderBy: { order: "asc" } },
|
course: { select: { title: true } },
|
||||||
},
|
lessons: { orderBy: { order: "asc" } },
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
prisma.module.findMany({
|
||||||
|
where: { courseId, NOT: { id: moduleId } },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!module || module.courseId !== courseId) notFound();
|
if (!module || module.courseId !== courseId) notFound();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-3xl">
|
<div className="p-8 max-w-3xl">
|
||||||
<nav className="text-sm text-slate-400 mb-6">
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
|
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}`} className="hover:text-slate-600">{module.course.title}</Link>
|
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{module.course.title}</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-slate-700">{module.title}</span>
|
<span style={{ color: "var(--foreground)" }}>{module.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="mb-6">
|
||||||
<div>
|
<h1 className="text-2xl font-bold">{module.title}</h1>
|
||||||
<h1 className="text-2xl font-semibold text-slate-800">{module.title}</h1>
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<p className="text-slate-500 text-sm mt-0.5">{module.lessons.length} уроков</p>
|
{module.lessons.length} {module.lessons.length === 1 ? "урок" : module.lessons.length < 5 ? "урока" : "уроков"}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6">
|
<section className="card-aubade p-6">
|
||||||
<SortableLessons courseId={courseId} moduleId={moduleId} lessons={module.lessons} />
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Уроки модуля
|
||||||
|
</p>
|
||||||
|
<SortableLessons
|
||||||
|
courseId={courseId}
|
||||||
|
moduleId={moduleId}
|
||||||
|
lessons={module.lessons}
|
||||||
|
otherModules={allModules}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { CourseEditForm } from "@/components/admin/course-edit-form";
|
import { CourseEditForm } from "@/components/admin/course-edit-form";
|
||||||
import { SortableModules } from "@/components/admin/sortable-modules";
|
import { SortableModules } from "@/components/admin/sortable-modules";
|
||||||
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
|
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
|
||||||
|
import { CourseTree } from "@/components/admin/course-tree";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ courseId: string }>;
|
params: Promise<{ courseId: string }>;
|
||||||
@@ -12,15 +13,31 @@ interface Props {
|
|||||||
export default async function CourseDetailPage({ params }: Props) {
|
export default async function CourseDetailPage({ params }: Props) {
|
||||||
const { courseId } = await params;
|
const { courseId } = await params;
|
||||||
|
|
||||||
const [course, allStudents] = await Promise.all([
|
const [course, allStudents, categories] = await Promise.all([
|
||||||
prisma.course.findUnique({
|
prisma.course.findUnique({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
include: {
|
include: {
|
||||||
modules: {
|
modules: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: { _count: { select: { lessons: true } } },
|
include: {
|
||||||
|
_count: { select: { lessons: true } },
|
||||||
|
lessons: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true, published: true, kinescopeId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enrollments: {
|
||||||
|
select: { userId: true, expiresAt: true },
|
||||||
|
},
|
||||||
|
accessLogs: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 50,
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true } },
|
||||||
|
grantedBy: { select: { name: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
enrollments: { include: { user: { select: { id: true, name: true, email: true } } } },
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
@@ -28,43 +45,61 @@ export default async function CourseDetailPage({ params }: Props) {
|
|||||||
select: { id: true, name: true, email: true },
|
select: { id: true, name: true, email: true },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
|
prisma.category.findMany({ orderBy: { order: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) notFound();
|
if (!course) notFound();
|
||||||
|
|
||||||
const enrolledIds = new Set(course.enrollments.map((e) => e.userId));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl">
|
<div className="p-8 max-w-4xl">
|
||||||
{/* Breadcrumb */}
|
{/* Breadcrumb */}
|
||||||
<nav className="text-sm text-slate-400 mb-6">
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
|
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-slate-700">{course.title}</span>
|
<span style={{ color: "var(--foreground)" }}>{course.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Course metadata */}
|
{/* Course metadata */}
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6">
|
<section className="card-aubade p-6 mb-6">
|
||||||
<h2 className="text-base font-semibold text-slate-700 mb-4">Основная информация</h2>
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<CourseEditForm course={course} />
|
Основная информация
|
||||||
|
</p>
|
||||||
|
<CourseEditForm course={course} categories={categories} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Modules */}
|
{/* Modules */}
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6 mb-6">
|
<section className="card-aubade p-6 mb-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h2 className="text-base font-semibold text-slate-700">Модули</h2>
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<span className="text-sm text-slate-400">{course.modules.length} модулей</span>
|
Модули
|
||||||
|
</p>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{course.modules.length} модулей
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<SortableModules courseId={courseId} modules={course.modules} />
|
<SortableModules courseId={courseId} modules={course.modules} />
|
||||||
</section>
|
</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 */}
|
{/* Access management */}
|
||||||
<section className="bg-white border border-slate-200 rounded-2xl p-6">
|
<section className="card-aubade p-6">
|
||||||
<h2 className="text-base font-semibold text-slate-700 mb-4">Доступ к курсу</h2>
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Управление доступом
|
||||||
|
</p>
|
||||||
<EnrollmentManager
|
<EnrollmentManager
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
allStudents={allStudents}
|
allStudents={allStudents}
|
||||||
enrolledIds={[...enrolledIds]}
|
enrollments={course.enrollments}
|
||||||
|
accessLogs={course.accessLogs}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ export async function updateCourse(courseId: string, formData: FormData) {
|
|||||||
const description = (formData.get("description") as string) || null;
|
const description = (formData.get("description") as string) || null;
|
||||||
const published = formData.get("published") === "true";
|
const published = formData.get("published") === "true";
|
||||||
const coverImage = (formData.get("coverImage") as string) || null;
|
const coverImage = (formData.get("coverImage") as string) || null;
|
||||||
|
const categoryId = (formData.get("categoryId") as string) || null;
|
||||||
|
|
||||||
await prisma.course.update({
|
await prisma.course.update({
|
||||||
where: { id: courseId },
|
where: { id: courseId },
|
||||||
data: { title, slug, description, published, coverImage },
|
data: { title, slug, description, published, coverImage, categoryId },
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath("/admin/courses");
|
revalidatePath("/admin/courses");
|
||||||
|
|||||||
@@ -1,27 +1,202 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export default function AdminDashboard() {
|
export default async function AdminDashboard() {
|
||||||
|
const now = new Date();
|
||||||
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalStudents,
|
||||||
|
newStudentsMonth,
|
||||||
|
totalCourses,
|
||||||
|
publishedCourses,
|
||||||
|
activeEnrollments,
|
||||||
|
expiringWeek,
|
||||||
|
homeworkPending,
|
||||||
|
homeworkTotal,
|
||||||
|
progressTotal,
|
||||||
|
] = 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="p-8">
|
<div className="p-8 max-w-5xl">
|
||||||
<h1 className="text-2xl font-semibold text-slate-800 mb-1">Обзор</h1>
|
<h1 className="text-2xl font-bold mb-1">Обзор</h1>
|
||||||
<p className="text-slate-500 mb-8">Управление платформой Second Brain.</p>
|
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
Платформа Second Brain · {now.toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })}
|
||||||
<Link href="/admin/courses" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors">
|
</p>
|
||||||
<p className="text-3xl mb-2">📚</p>
|
|
||||||
<p className="font-medium text-slate-800">Курсы</p>
|
{/* Stats grid */}
|
||||||
<p className="text-sm text-slate-400 mt-1">Управление контентом</p>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||||
</Link>
|
<StatCard
|
||||||
<Link href="/admin/users" className="bg-white rounded-2xl border border-slate-200 p-6 hover:border-amber-300 transition-colors">
|
label="Студентов"
|
||||||
<p className="text-3xl mb-2">👥</p>
|
value={totalStudents}
|
||||||
<p className="font-medium text-slate-800">Пользователи</p>
|
sub={`+${newStudentsMonth} за месяц`}
|
||||||
<p className="text-sm text-slate-400 mt-1">Управление доступом</p>
|
href="/admin/users"
|
||||||
</Link>
|
/>
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 p-6 opacity-50">
|
<StatCard
|
||||||
<p className="text-3xl mb-2">📊</p>
|
label="Курсов"
|
||||||
<p className="font-medium text-slate-800">Аналитика</p>
|
value={totalCourses}
|
||||||
<p className="text-sm text-slate-400 mt-1">Этап 10</p>
|
sub={`${publishedCourses} опубликовано`}
|
||||||
|
href="/admin/courses"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Активных доступов"
|
||||||
|
value={activeEnrollments}
|
||||||
|
sub={expiringWeek > 0 ? `${expiringWeek} истекает на неделе` : "нет истекающих"}
|
||||||
|
subAccent={expiringWeek > 0}
|
||||||
|
href="/admin/courses"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="ДЗ на проверку"
|
||||||
|
value={homeworkPending}
|
||||||
|
sub={`${homeworkTotal} всего сдано`}
|
||||||
|
subAccent={homeworkPending > 0}
|
||||||
|
href="/curator/homework"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid md:grid-cols-2 gap-6">
|
||||||
|
{/* Recent enrollments */}
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Последние зачисления
|
||||||
|
</p>
|
||||||
|
<Link href="/admin/users" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{recentEnrollments.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет зачислений</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{recentEnrollments.map((e) => (
|
||||||
|
<div key={`${e.userId}-${e.courseId}`} className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{e.user.name}</p>
|
||||||
|
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>{e.course.title}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs shrink-0" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{new Date(e.enrolledAt).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top courses + progress stat */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Популярные курсы
|
||||||
|
</p>
|
||||||
|
<Link href="/admin/courses" className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Все →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{topCourses.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>Нет курсов</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{topCourses.map((c) => (
|
||||||
|
<div key={c.id} className="flex items-center gap-3 text-sm">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{c.title}</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{c._count.modules} модулей
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="font-bold">{c._count.enrollments}</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>студентов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-aubade p-5">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Активность
|
||||||
|
</p>
|
||||||
|
<div className="flex items-end gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold">{progressTotal}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уроков пройдено</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-3xl font-bold">{homeworkTotal}</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>работ сдано</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,30 +1,12 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminNav } from "@/components/admin/admin-nav";
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
|
||||||
|
|
||||||
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) redirect("/login");
|
if (!session) redirect("/login");
|
||||||
if (session.user.role !== "admin") redirect("/dashboard");
|
if (session.user.role !== "admin") redirect("/dashboard");
|
||||||
|
|
||||||
return (
|
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
|
||||||
<div className="min-h-screen flex bg-slate-50">
|
|
||||||
<aside className="w-56 bg-slate-900 text-white flex flex-col shrink-0 fixed h-full z-10">
|
|
||||||
<div className="px-5 py-5 border-b border-slate-800">
|
|
||||||
<p className="font-bold text-amber-400 text-base">Second Brain</p>
|
|
||||||
<p className="text-xs text-slate-400 mt-0.5">Админ-панель</p>
|
|
||||||
</div>
|
|
||||||
<nav className="flex-1 p-3 space-y-0.5 overflow-y-auto">
|
|
||||||
<AdminNav />
|
|
||||||
</nav>
|
|
||||||
<div className="p-4 border-t border-slate-800">
|
|
||||||
<p className="text-xs text-slate-400 mb-3 truncate">{session.user.name}</p>
|
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
<div className="ml-56 flex-1 min-h-screen">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { SETTINGS_DEFAULTS, type SettingsKey } from "@/lib/settings";
|
||||||
|
|
||||||
|
export async function saveSettings(data: Record<string, string>) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
|
||||||
|
|
||||||
|
const validKeys = Object.keys(SETTINGS_DEFAULTS) as SettingsKey[];
|
||||||
|
const ops = validKeys
|
||||||
|
.filter((key) => key in data)
|
||||||
|
.map((key) =>
|
||||||
|
prisma.settings.upsert({
|
||||||
|
where: { key },
|
||||||
|
update: { value: data[key] },
|
||||||
|
create: { key, value: data[key] },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(ops);
|
||||||
|
revalidatePath("/admin/settings");
|
||||||
|
revalidatePath("/", "layout");
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { SettingsForm } from "@/components/admin/settings-form";
|
||||||
|
|
||||||
|
export const metadata = { title: "Настройки платформы" };
|
||||||
|
|
||||||
|
export default async function SettingsPage() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-2xl">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Настройки платформы
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<SettingsForm initial={settings} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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}`);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { sendWelcomeEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
export async function createUser(data: {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
sendWelcome: boolean;
|
||||||
|
}): Promise<{ success: true; userId: string } | { success: false; error: string }> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return { success: false, error: "Нет доступа" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, email, password, role, emailVerified, sendWelcome } = data;
|
||||||
|
|
||||||
|
if (!name.trim() || !email.trim() || !password.trim()) {
|
||||||
|
return { success: false, error: "Заполните все обязательные поля" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
return { success: false, error: "Пользователь с таким email уже существует" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { name: name.trim(), email: email.trim().toLowerCase(), role, emailVerified },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create credential account (Better Auth's internal structure)
|
||||||
|
await prisma.account.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
accountId: user.id,
|
||||||
|
providerId: "credential",
|
||||||
|
password: hashedPassword,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sendWelcome) {
|
||||||
|
await sendWelcomeEmail(user.email, user.name).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, userId: user.id };
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+115
-58
@@ -1,70 +1,127 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import Link from "next/link";
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
import { UsersTable } from "@/components/admin/users-table";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { UsersSearch } from "@/components/admin/users-search";
|
||||||
|
|
||||||
const roleLabel: Record<string, string> = {
|
const PAGE_SIZE = 20;
|
||||||
admin: "Администратор",
|
|
||||||
curator: "Куратор",
|
|
||||||
student: "Ученик",
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleVariant: Record<string, "default" | "secondary" | "outline"> = {
|
interface Props {
|
||||||
admin: "default",
|
searchParams: Promise<{ search?: string; role?: string; page?: string }>;
|
||||||
curator: "secondary",
|
}
|
||||||
student: "outline",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function UsersPage() {
|
export default async function UsersPage({ searchParams }: Props) {
|
||||||
const users = await prisma.user.findMany({
|
const { search = "", role = "", page = "1" } = await searchParams;
|
||||||
orderBy: { createdAt: "desc" },
|
const currentPage = Math.max(1, parseInt(page) || 1);
|
||||||
include: { _count: { select: { enrollments: true } } },
|
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 (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="mb-6">
|
<div className="mb-5 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
|
<div>
|
||||||
<p className="text-slate-500 text-sm mt-0.5">{users.length} пользователей</p>
|
<h1 className="text-2xl font-semibold text-slate-800">Пользователи</h1>
|
||||||
|
<p className="text-slate-500 text-sm mt-0.5">{total} пользователей</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/admin/users/new"
|
||||||
|
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<UserPlus size={14} />
|
||||||
|
Добавить пользователя
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200 rounded-2xl overflow-hidden">
|
{/* Filters */}
|
||||||
<table className="w-full">
|
<Suspense>
|
||||||
<thead>
|
<UsersSearch initialSearch={search} initialRole={role} />
|
||||||
<tr className="border-b border-slate-100 bg-slate-50">
|
</Suspense>
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Пользователь</th>
|
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Роль</th>
|
<UsersTable users={tableUsers} />
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Курсов</th>
|
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Email подтверждён</th>
|
{/* Pagination */}
|
||||||
<th className="text-left px-5 py-3 text-xs font-medium text-slate-500 uppercase">Зарегистрирован</th>
|
{totalPages > 1 && (
|
||||||
</tr>
|
<div className="flex items-center gap-1 mt-4">
|
||||||
</thead>
|
{currentPage > 1 && (
|
||||||
<tbody>
|
<Link href={pageUrl(currentPage - 1)} className="btn-aubade px-3 py-1 text-xs">←</Link>
|
||||||
{users.map((user) => (
|
)}
|
||||||
<tr key={user.id} className="border-b border-slate-50 last:border-0 hover:bg-slate-50">
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
<td className="px-5 py-3">
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - currentPage) <= 2)
|
||||||
<p className="font-medium text-slate-800">{user.name}</p>
|
.reduce<(number | "…")[]>((acc, p, i, arr) => {
|
||||||
<p className="text-xs text-slate-400">{user.email}</p>
|
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
|
||||||
</td>
|
acc.push(p);
|
||||||
<td className="px-5 py-3">
|
return acc;
|
||||||
<Badge variant={roleVariant[user.role] ?? "outline"}>
|
}, [])
|
||||||
{roleLabel[user.role] ?? user.role}
|
.map((p, i) =>
|
||||||
</Badge>
|
p === "…" ? (
|
||||||
</td>
|
<span key={`e${i}`} className="px-2 text-xs" style={{ color: "var(--muted-foreground)" }}>…</span>
|
||||||
<td className="px-5 py-3 text-sm text-slate-600">
|
) : (
|
||||||
{user._count.enrollments}
|
<Link key={p} href={pageUrl(p as number)} className="px-3 py-1 text-xs"
|
||||||
</td>
|
style={{ border: "2px solid var(--border)", background: p === currentPage ? "var(--foreground)" : "transparent", color: p === currentPage ? "var(--background)" : "var(--foreground)" }}>
|
||||||
<td className="px-5 py-3">
|
{p}
|
||||||
<span className={`text-xs font-medium ${user.emailVerified ? "text-green-600" : "text-slate-400"}`}>
|
</Link>
|
||||||
{user.emailVerified ? "Да" : "Нет"}
|
)
|
||||||
</span>
|
)}
|
||||||
</td>
|
{currentPage < totalPages && (
|
||||||
<td className="px-5 py-3 text-sm text-slate-400">
|
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs">→</Link>
|
||||||
{new Date(user.createdAt).toLocaleDateString("ru-RU")}
|
)}
|
||||||
</td>
|
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span>
|
||||||
</tr>
|
</div>
|
||||||
))}
|
)}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import iconv from "iconv-lite";
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Нет доступа" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = request.nextUrl;
|
||||||
|
const courseId = searchParams.get("courseId") || undefined;
|
||||||
|
const encoding = (searchParams.get("encoding") as "utf8" | "win1251") ?? "utf8";
|
||||||
|
|
||||||
|
// Fetch users
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: courseId
|
||||||
|
? { enrollments: { some: { courseId } } }
|
||||||
|
: { role: "student" },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
enrollments: {
|
||||||
|
include: { course: { select: { title: true } } },
|
||||||
|
},
|
||||||
|
progress: { select: { lessonId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build CSV rows
|
||||||
|
const csvHeaders = ["Email", "Имя", "Телефон", "Дата регистрации", "Курсы", "Прогресс (уроков)"];
|
||||||
|
const rows = users.map((u) => {
|
||||||
|
const courses = u.enrollments.map((e) => e.course.title).join(" | ");
|
||||||
|
const progress = u.progress.length;
|
||||||
|
const registeredAt = new Date(u.createdAt).toLocaleDateString("ru-RU");
|
||||||
|
return [u.email, u.name, "", registeredAt, courses, String(progress)];
|
||||||
|
});
|
||||||
|
|
||||||
|
const allRows = [csvHeaders, ...rows];
|
||||||
|
const csvText = allRows
|
||||||
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(";"))
|
||||||
|
.join("\r\n");
|
||||||
|
|
||||||
|
// Encode
|
||||||
|
let body: Buffer;
|
||||||
|
let charset: string;
|
||||||
|
if (encoding === "win1251") {
|
||||||
|
body = iconv.encode(csvText, "win1251");
|
||||||
|
charset = "windows-1251";
|
||||||
|
} else {
|
||||||
|
body = Buffer.from("\uFEFF" + csvText, "utf8"); // BOM for Excel
|
||||||
|
charset = "utf-8";
|
||||||
|
}
|
||||||
|
|
||||||
|
const filename = `students_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
|
||||||
|
return new NextResponse(body as unknown as BodyInit, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": `text/csv; charset=${charset}`,
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { mdToTiptap } from "@/lib/md-to-tiptap";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session || session.user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await req.formData();
|
||||||
|
const file = formData.get("file") as File | null;
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "No file provided" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!file.name.endsWith(".md")) {
|
||||||
|
return NextResponse.json({ error: "Only .md files are supported" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = await file.text();
|
||||||
|
const { data: fm, content } = matter(raw);
|
||||||
|
|
||||||
|
// Extract known frontmatter fields (Obsidian-compatible naming)
|
||||||
|
const title =
|
||||||
|
typeof fm.title === "string" ? fm.title.trim() : null;
|
||||||
|
const kinescopeId =
|
||||||
|
(fm.kinescopeId ?? fm.kinescope_id ?? fm.videoId ?? fm.video_id ?? "") as string;
|
||||||
|
const order =
|
||||||
|
typeof fm.order === "number" ? fm.order : null;
|
||||||
|
const published =
|
||||||
|
typeof fm.published === "boolean" ? fm.published : null;
|
||||||
|
|
||||||
|
const tiptapContent = mdToTiptap(content);
|
||||||
|
|
||||||
|
return NextResponse.json({ title, kinescopeId, order, published, content: tiptapContent });
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
{pending > 0 ? (
|
||||||
|
<Link href="/curator/homework" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
|
||||||
|
Перейти к проверке ({pending}) →
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="card-aubade p-8 text-center">
|
||||||
|
<p className="text-3xl mb-2">✓</p>
|
||||||
|
<p className="font-bold">Все работы проверены</p>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>Новых заданий нет</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
)}
|
||||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
</div>
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
);
|
||||||
Панель куратора
|
}
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 mb-8">Здесь будут домашние задания на проверку.</p>
|
function StatCard({ label, value, accent }: { label: string; value: number; accent?: boolean }) {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
return (
|
||||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
<div className="card-aubade p-4">
|
||||||
<p className="text-3xl mb-2">📝</p>
|
<p className="text-3xl font-bold" style={{ color: accent ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}>
|
||||||
<p className="font-medium text-gray-800">Домашние задания</p>
|
{value}
|
||||||
<p className="text-sm text-gray-400 mt-1">Новых заданий нет</p>
|
</p>
|
||||||
</div>
|
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>{label}</p>
|
||||||
<div className="bg-white rounded-2xl border border-green-100 p-6">
|
|
||||||
<p className="text-3xl mb-2">👥</p>
|
|
||||||
<p className="font-medium text-gray-800">Мои ученики</p>
|
|
||||||
<p className="text-sm text-gray-400 mt-1">Откроется в Этапе 3</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+146
-104
@@ -5,127 +5,169 @@
|
|||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
/* ── 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-sans);
|
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
--font-heading: var(--font-sans);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-card: var(--card);
|
--color-card: var(--card);
|
||||||
|
--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-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
--radius-xl: calc(var(--radius) * 1.4);
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
--radius-2xl: calc(var(--radius) * 1.8);
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
--radius-3xl: calc(var(--radius) * 2.2);
|
|
||||||
--radius-4xl: calc(var(--radius) * 2.6);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Light mode: Second Brain palette ──────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: #F5F5F0;
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: #323232;
|
||||||
--card: oklch(1 0 0);
|
--card: #F5F5F0;
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: #323232;
|
||||||
--popover: oklch(1 0 0);
|
--popover: #F5F5F0;
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: #323232;
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: #323232;
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: #F5F5F0;
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: #E8E8E0;
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: #323232;
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: #E8E8E0;
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: #666666;
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: #E8F0D8;
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: #323232;
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: #AAAAAA;
|
||||||
--input: oklch(0.922 0 0);
|
--input: #AAAAAA;
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: #323232;
|
||||||
--chart-1: oklch(0.87 0 0);
|
--radius: 2px;
|
||||||
--chart-2: oklch(0.556 0 0);
|
|
||||||
--chart-3: oklch(0.439 0 0);
|
/* Aubade */
|
||||||
--chart-4: oklch(0.371 0 0);
|
--aubade-thickness: 2px;
|
||||||
--chart-5: oklch(0.269 0 0);
|
--aubade-shadow-offset: 4px;
|
||||||
--radius: 0.625rem;
|
--color-divider: #AAAAAA;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--color-hover: #D8D8D0;
|
||||||
--sidebar-foreground: oklch(0.145 0 0);
|
--color-surface: #E8E8E0;
|
||||||
--sidebar-primary: oklch(0.205 0 0);
|
--color-highlight: #E8F0D8;
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.97 0 0);
|
/* Admin sidebar */
|
||||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
--sidebar-bg: #2A2A28;
|
||||||
--sidebar-border: oklch(0.922 0 0);
|
--sidebar-surface: #1E1E1C;
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-text: #b3b3b3;
|
||||||
}
|
--sidebar-border: #4A4A48;
|
||||||
|
--sidebar-highlight: #2A3A2A;
|
||||||
.dark {
|
|
||||||
--background: oklch(0.145 0 0);
|
|
||||||
--foreground: oklch(0.985 0 0);
|
|
||||||
--card: oklch(0.205 0 0);
|
|
||||||
--card-foreground: oklch(0.985 0 0);
|
|
||||||
--popover: oklch(0.205 0 0);
|
|
||||||
--popover-foreground: oklch(0.985 0 0);
|
|
||||||
--primary: oklch(0.922 0 0);
|
|
||||||
--primary-foreground: oklch(0.205 0 0);
|
|
||||||
--secondary: oklch(0.269 0 0);
|
|
||||||
--secondary-foreground: oklch(0.985 0 0);
|
|
||||||
--muted: oklch(0.269 0 0);
|
|
||||||
--muted-foreground: oklch(0.708 0 0);
|
|
||||||
--accent: oklch(0.269 0 0);
|
|
||||||
--accent-foreground: oklch(0.985 0 0);
|
|
||||||
--destructive: oklch(0.704 0.191 22.216);
|
|
||||||
--border: oklch(1 0 0 / 10%);
|
|
||||||
--input: oklch(1 0 0 / 15%);
|
|
||||||
--ring: oklch(0.556 0 0);
|
|
||||||
--chart-1: oklch(0.87 0 0);
|
|
||||||
--chart-2: oklch(0.556 0 0);
|
|
||||||
--chart-3: oklch(0.439 0 0);
|
|
||||||
--chart-4: oklch(0.371 0 0);
|
|
||||||
--chart-5: oklch(0.269 0 0);
|
|
||||||
--sidebar: oklch(0.205 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
|
||||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-accent: oklch(0.269 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Base ────────────────────────────────────────────────────────────── */
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border outline-ring/50;
|
@apply border-border outline-ring/50;
|
||||||
}
|
box-sizing: border-box;
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
}
|
||||||
html {
|
html {
|
||||||
@apply font-sans;
|
font-family: var(--font-sans);
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
body {
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Aubade utility classes ─────────────────────────────────────────── */
|
||||||
|
.card-aubade {
|
||||||
|
border: var(--aubade-thickness) solid var(--color-divider);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|||||||
+28
-19
@@ -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 || "Образовательная платформа",
|
||||||
|
keywords: settings.schoolKeywords || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export default async function RootLayout({
|
||||||
title: "Create Next App",
|
|
||||||
description: "Generated by create next app",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,16 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const links = [
|
const links = [
|
||||||
{ href: "/admin/dashboard", label: "Обзор" },
|
{ href: "/admin/dashboard", label: "Обзор" },
|
||||||
{ href: "/admin/courses", label: "Курсы" },
|
{ href: "/admin/courses", label: "Курсы" },
|
||||||
|
{ href: "/admin/categories", label: "Категории" },
|
||||||
{ href: "/admin/users", 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() {
|
export function AdminNav() {
|
||||||
@@ -15,20 +19,30 @@ export function AdminNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{links.map(({ href, label }) => (
|
{links.map(({ href, label }) => {
|
||||||
<Link
|
const active =
|
||||||
key={href}
|
pathname === href ||
|
||||||
href={href}
|
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
|
||||||
className={cn(
|
(href === "/curator/homework" && pathname.startsWith("/curator"));
|
||||||
"block px-3 py-2 rounded-lg text-sm transition-colors",
|
return (
|
||||||
pathname === href || (href !== "/admin/dashboard" && pathname.startsWith(href))
|
<Link
|
||||||
? "bg-slate-700 text-white"
|
key={href}
|
||||||
: "text-slate-300 hover:bg-slate-800 hover:text-white"
|
href={href}
|
||||||
)}
|
className="admin-sidebar-nav-link"
|
||||||
>
|
style={
|
||||||
{label}
|
active
|
||||||
</Link>
|
? {
|
||||||
))}
|
color: "#E8F0D8",
|
||||||
|
borderLeftColor: "#E8F0D8",
|
||||||
|
backgroundColor: "var(--sidebar-surface)",
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,11 +14,18 @@ interface Course {
|
|||||||
description: string | null;
|
description: string | null;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
categoryId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CourseEditForm({ course }: { course: Course }) {
|
interface Category {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
|
||||||
const [published, setPublished] = useState(course.published);
|
const [published, setPublished] = useState(course.published);
|
||||||
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
|
const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
|
||||||
|
const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
@@ -39,6 +46,7 @@ export function CourseEditForm({ course }: { course: Course }) {
|
|||||||
const fd = new FormData(e.currentTarget);
|
const fd = new FormData(e.currentTarget);
|
||||||
fd.set("published", String(published));
|
fd.set("published", String(published));
|
||||||
fd.set("coverImage", coverImage);
|
fd.set("coverImage", coverImage);
|
||||||
|
fd.set("categoryId", categoryId);
|
||||||
startTransition(() => updateCourse(course.id, fd));
|
startTransition(() => updateCourse(course.id, fd));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +71,23 @@ export function CourseEditForm({ course }: { course: Course }) {
|
|||||||
<Label htmlFor="description">Описание</Label>
|
<Label htmlFor="description">Описание</Label>
|
||||||
<Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} />
|
<Textarea id="description" name="description" defaultValue={course.description ?? ""} rows={3} />
|
||||||
</div>
|
</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">
|
<div className="space-y-1.5">
|
||||||
<Label>Обложка</Label>
|
<Label>Обложка</Label>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,8 +19,8 @@ export function CreateCourseDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger asChild>
|
<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">
|
||||||
<Button>+ Создать курс</Button>
|
+ Создать курс
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
|
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions";
|
||||||
|
|
||||||
interface Student {
|
interface Student {
|
||||||
@@ -12,15 +11,35 @@ interface Student {
|
|||||||
email: 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 {
|
interface Props {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
allStudents: Student[];
|
allStudents: Student[];
|
||||||
enrolledIds: string[];
|
enrollments: Enrollment[];
|
||||||
|
accessLogs: LogEntry[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EnrollmentManager({ courseId, allStudents, enrolledIds }: Props) {
|
export function EnrollmentManager({ courseId, allStudents, enrollments, accessLogs }: Props) {
|
||||||
const [enrolled, setEnrolled] = useState(new Set(enrolledIds));
|
const [enrolledMap, setEnrolledMap] = useState<Map<string, Date | null>>(
|
||||||
|
() => new Map(enrollments.map((e) => [e.userId, e.expiresAt]))
|
||||||
|
);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [expiryDate, setExpiryDate] = useState("");
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [showLog, setShowLog] = useState(false);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
|
|
||||||
const filtered = allStudents.filter(
|
const filtered = allStudents.filter(
|
||||||
@@ -29,70 +48,143 @@ export function EnrollmentManager({ courseId, allStudents, enrolledIds }: Props)
|
|||||||
s.email.toLowerCase().includes(search.toLowerCase())
|
s.email.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
function toggle(userId: string) {
|
function handleGrant(userId: string) {
|
||||||
if (enrolled.has(userId)) {
|
const newMap = new Map(enrolledMap);
|
||||||
setEnrolled((prev) => { const s = new Set(prev); s.delete(userId); return s; });
|
newMap.set(userId, expiryDate ? new Date(expiryDate) : null);
|
||||||
startTransition(() => revokeAccess(courseId, userId));
|
setEnrolledMap(newMap);
|
||||||
} else {
|
startTransition(() => grantAccess(courseId, userId, expiryDate || null, note || undefined));
|
||||||
setEnrolled((prev) => new Set(prev).add(userId));
|
|
||||||
startTransition(() => grantAccess(courseId, userId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const enrolledStudents = allStudents.filter((s) => enrolled.has(s.id));
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
|
{/* Enrolled list */}
|
||||||
{enrolledStudents.length > 0 && (
|
{enrolledStudents.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500 mb-2">Доступ открыт ({enrolledStudents.length}):</p>
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<div className="flex flex-wrap gap-2">
|
Доступ открыт — {enrolledStudents.length}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
{enrolledStudents.map((s) => (
|
{enrolledStudents.map((s) => (
|
||||||
<Badge key={s.id} variant="secondary" className="gap-1.5 py-1 pr-1">
|
<div key={s.id} className="flex items-center justify-between px-3 py-2" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||||
{s.name}
|
<div>
|
||||||
<button
|
<p className="text-sm font-medium">{s.name}</p>
|
||||||
onClick={() => toggle(s.id)}
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{s.email}</p>
|
||||||
disabled={pending}
|
</div>
|
||||||
className="ml-1 text-slate-400 hover:text-red-500"
|
<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>
|
Отозвать
|
||||||
</Badge>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Grant form */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500 mb-2">Добавить ученика:</p>
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Input
|
Добавить ученика
|
||||||
placeholder="Поиск по имени или email..."
|
</p>
|
||||||
value={search}
|
<div className="flex gap-3 mb-3 flex-wrap">
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<div className="flex-1 min-w-48">
|
||||||
className="max-w-sm mb-3"
|
<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 className="space-y-1.5 max-h-48 overflow-y-auto">
|
</div>
|
||||||
{filtered.map((student) => (
|
<div>
|
||||||
<div key={student.id} className="flex items-center justify-between px-3 py-2 rounded-lg border border-slate-100 bg-slate-50">
|
<label className="text-xs uppercase tracking-wider mb-1 block" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<div>
|
Срок доступа
|
||||||
<p className="text-sm font-medium text-slate-700">{student.name}</p>
|
</label>
|
||||||
<p className="text-xs text-slate-400">{student.email}</p>
|
<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>
|
</div>
|
||||||
<Button
|
);
|
||||||
size="sm"
|
})}
|
||||||
variant={enrolled.has(student.id) ? "destructive" : "outline"}
|
|
||||||
onClick={() => toggle(student.id)}
|
|
||||||
disabled={pending}
|
|
||||||
>
|
|
||||||
{enrolled.has(student.id) ? "Убрать" : "Дать доступ"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<p className="text-sm text-slate-400 py-2">Студентов не найдено</p>
|
<p className="text-sm py-2" style={{ color: "var(--muted-foreground)" }}>Студентов не найдено</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,10 +5,10 @@ import { useEditor, EditorContent } from "@tiptap/react";
|
|||||||
import StarterKit from "@tiptap/starter-kit";
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
import Image from "@tiptap/extension-image";
|
import Image from "@tiptap/extension-image";
|
||||||
import Link from "@tiptap/extension-link";
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Underline from "@tiptap/extension-underline";
|
||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { useRouter } from "next/navigation";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
|
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
|
||||||
|
|
||||||
interface LessonData {
|
interface LessonData {
|
||||||
@@ -19,25 +19,50 @@ interface LessonData {
|
|||||||
published: boolean;
|
published: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SiblingLesson {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function LessonEditor({
|
export function LessonEditor({
|
||||||
lesson,
|
lesson,
|
||||||
courseId,
|
courseId,
|
||||||
moduleId,
|
moduleId,
|
||||||
|
courseSlug,
|
||||||
|
prevLesson,
|
||||||
|
nextLesson,
|
||||||
}: {
|
}: {
|
||||||
lesson: LessonData;
|
lesson: LessonData;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
|
courseSlug: string;
|
||||||
|
prevLesson?: SiblingLesson | null;
|
||||||
|
nextLesson?: SiblingLesson | null;
|
||||||
}) {
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
const [title, setTitle] = useState(lesson.title);
|
const [title, setTitle] = useState(lesson.title);
|
||||||
const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId);
|
const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId);
|
||||||
const [published, setPublished] = useState(lesson.published);
|
const [published, setPublished] = useState(lesson.published);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
const [pending, startTransition] = useTransition();
|
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({
|
const editor = useEditor({
|
||||||
extensions: [
|
extensions: [
|
||||||
StarterKit,
|
StarterKit,
|
||||||
|
Underline,
|
||||||
Image.configure({ inline: false }),
|
Image.configure({ inline: false }),
|
||||||
Link.configure({ openOnClick: false }),
|
Link.configure({ openOnClick: false }),
|
||||||
Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
|
Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
|
||||||
@@ -68,6 +93,46 @@ export function LessonEditor({
|
|||||||
input.click();
|
input.click();
|
||||||
}, [editor]);
|
}, [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() {
|
function handleSave() {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
@@ -82,74 +147,188 @@ export function LessonEditor({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function navigateTo(lessonId: string) {
|
||||||
|
router.push(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Header controls */}
|
{/* Header controls */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
{/* Left: published toggle + prev/next */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="switch"
|
role="switch"
|
||||||
aria-checked={published}
|
aria-checked={published}
|
||||||
onClick={() => setPublished(!published)}
|
onClick={() => setPublished(!published)}
|
||||||
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
|
className="flex items-center gap-2 text-sm"
|
||||||
>
|
>
|
||||||
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
|
<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>
|
</button>
|
||||||
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSave} disabled={pending || uploading}>
|
|
||||||
{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 */}
|
{/* Title */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="lesson-title">Заголовок урока</Label>
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Input
|
Заголовок урока
|
||||||
id="lesson-title"
|
</label>
|
||||||
|
<input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
className="text-lg font-medium"
|
style={{ ...inputStyle, fontSize: "1.1rem", fontWeight: "700" }}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kinescope ID */}
|
{/* Kinescope ID */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="kinescope-id">Kinescope ID</Label>
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Input
|
Kinescope Video ID
|
||||||
id="kinescope-id"
|
</label>
|
||||||
|
<input
|
||||||
value={kinescopeId}
|
value={kinescopeId}
|
||||||
onChange={(e) => setKinescopeId(e.target.value)}
|
onChange={(e) => setKinescopeId(e.target.value)}
|
||||||
placeholder="Оставьте пустым если видео нет"
|
placeholder="Оставьте пустым если видео нет"
|
||||||
className="font-mono text-sm"
|
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TipTap Editor */}
|
{/* TipTap Editor */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1">
|
||||||
<Label>Содержимое урока</Label>
|
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Содержимое урока
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap gap-1 p-2 bg-slate-50 border border-slate-200 rounded-t-lg border-b-0">
|
<div
|
||||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}>Ж</ToolBtn>
|
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().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: 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>
|
<ToolBtn onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} active={editor?.isActive("heading", { level: 3 })}>H3</ToolBtn>
|
||||||
<div className="w-px bg-slate-200 mx-1" />
|
|
||||||
|
<Sep />
|
||||||
|
|
||||||
|
{/* Lists & blocks */}
|
||||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBulletList().run()} active={editor?.isActive("bulletList")}>• Список</ToolBtn>
|
<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().toggleOrderedList().run()} active={editor?.isActive("orderedList")}>1. Список</ToolBtn>
|
||||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>“”</ToolBtn>
|
<ToolBtn onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>“” Цитата</ToolBtn>
|
||||||
<ToolBtn onClick={() => editor?.chain().focus().toggleCodeBlock().run()} active={editor?.isActive("codeBlock")}>{'</>'}</ToolBtn>
|
<ToolBtn onClick={() => editor?.chain().focus().toggleCodeBlock().run()} active={editor?.isActive("codeBlock")}>{'</>'} Код</ToolBtn>
|
||||||
<div className="w-px bg-slate-200 mx-1" />
|
<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>
|
<ToolBtn onClick={uploadImage} disabled={uploading}>{uploading ? "Загрузка..." : "🖼 Фото"}</ToolBtn>
|
||||||
<div className="w-px bg-slate-200 mx-1" />
|
|
||||||
<ToolBtn onClick={() => editor?.chain().focus().undo().run()}>↩</ToolBtn>
|
<Sep />
|
||||||
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}>↪</ToolBtn>
|
|
||||||
|
{/* History */}
|
||||||
|
<ToolBtn onClick={() => editor?.chain().focus().undo().run()}>↩ Отменить</ToolBtn>
|
||||||
|
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}>↪ Повторить</ToolBtn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Editor content */}
|
{/* Editor content */}
|
||||||
<div className="border border-slate-200 rounded-b-lg bg-white">
|
<div style={{ border: "2px solid var(--border)", borderTop: "none", background: "var(--background)" }}>
|
||||||
<EditorContent editor={editor} />
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,6 +336,10 @@ export function LessonEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Sep() {
|
||||||
|
return <div className="w-px mx-1 self-stretch" style={{ background: "var(--border)" }} />;
|
||||||
|
}
|
||||||
|
|
||||||
function ToolBtn({
|
function ToolBtn({
|
||||||
onClick,
|
onClick,
|
||||||
active,
|
active,
|
||||||
@@ -173,9 +356,14 @@ function ToolBtn({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`px-2 py-1 text-sm rounded transition-colors ${
|
className="px-2 py-1 text-xs transition-colors disabled:opacity-50"
|
||||||
active ? "bg-slate-700 text-white" : "hover:bg-slate-200 text-slate-700"
|
style={{
|
||||||
} disabled:opacity-50`}
|
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}
|
{children}
|
||||||
</button>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,28 +17,42 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Eye, EyeOff, Video } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import {
|
||||||
import { Input } from "@/components/ui/input";
|
createLesson,
|
||||||
import { createLesson, deleteLesson, updateLesson, reorderLessons } from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
|
deleteLesson,
|
||||||
|
updateLesson,
|
||||||
|
reorderLessons,
|
||||||
|
toggleLessonPublished,
|
||||||
|
moveLessonToModule,
|
||||||
|
} from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
|
||||||
|
|
||||||
interface Lesson {
|
interface Lesson {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
order: number;
|
order: number;
|
||||||
published: boolean;
|
published: boolean;
|
||||||
|
kinescopeId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OtherModule {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableLesson({
|
function SortableLesson({
|
||||||
lesson,
|
lesson,
|
||||||
courseId,
|
courseId,
|
||||||
moduleId,
|
moduleId,
|
||||||
|
otherModules,
|
||||||
}: {
|
}: {
|
||||||
lesson: Lesson;
|
lesson: Lesson;
|
||||||
courseId: string;
|
courseId: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
|
otherModules: OtherModule[];
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(lesson.title);
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
useSortable({ id: lesson.id });
|
useSortable({ id: lesson.id });
|
||||||
@@ -46,7 +60,7 @@ function SortableLesson({
|
|||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.4 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -61,33 +75,135 @@ function SortableLesson({
|
|||||||
startTransition(() => deleteLesson(lesson.id, courseId, moduleId));
|
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 (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="flex items-center gap-3 bg-slate-50 border border-slate-200 rounded-xl px-4 py-3">
|
<div
|
||||||
<button type="button" {...attributes} {...listeners} className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing">
|
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>
|
</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 ? (
|
{editing ? (
|
||||||
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
|
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
|
||||||
<Input name="title" defaultValue={lesson.title} autoFocus className="h-8 text-sm" />
|
<input
|
||||||
<Button type="submit" size="sm" disabled={pending}>Сохранить</Button>
|
name="title"
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>Отмена</Button>
|
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>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 font-medium text-slate-700">{lesson.title}</span>
|
<span className="flex-1 text-sm truncate">{lesson.title}</span>
|
||||||
<Badge variant={lesson.published ? "default" : "secondary"} className="text-xs">
|
|
||||||
{lesson.published ? "Опубликован" : "Черновик"}
|
{/* Published toggle */}
|
||||||
</Badge>
|
<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
|
<Link
|
||||||
href={`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`}
|
href={`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`}
|
||||||
className="text-xs text-amber-600 hover:underline"
|
className="btn-aubade text-xs px-3 py-1 shrink-0"
|
||||||
>
|
>
|
||||||
Редактировать
|
Ред. →
|
||||||
</Link>
|
</Link>
|
||||||
<button type="button" onClick={() => setEditing(true)} className="text-xs text-slate-500 hover:text-slate-700">
|
|
||||||
Переименовать
|
{/* Rename */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="text-xs shrink-0"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Переим.
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={handleDelete} disabled={pending} className="text-xs text-red-400 hover:text-red-600">
|
|
||||||
|
{/* 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>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -100,20 +216,21 @@ export function SortableLessons({
|
|||||||
courseId,
|
courseId,
|
||||||
moduleId,
|
moduleId,
|
||||||
lessons,
|
lessons,
|
||||||
|
otherModules = [],
|
||||||
}: {
|
}: {
|
||||||
courseId: string;
|
courseId: string;
|
||||||
moduleId: string;
|
moduleId: string;
|
||||||
lessons: Lesson[];
|
lessons: Lesson[];
|
||||||
|
otherModules?: OtherModule[];
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState(lessons);
|
const [items, setItems] = useState(lessons);
|
||||||
const [, startTransition] = useTransition();
|
const [creating, startTransition] = useTransition();
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
|
|
||||||
function handleDragEnd(event: DragEndEvent) {
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
const oldIndex = items.findIndex((l) => l.id === active.id);
|
const oldIndex = items.findIndex((l) => l.id === active.id);
|
||||||
const newIndex = items.findIndex((l) => l.id === over.id);
|
const newIndex = items.findIndex((l) => l.id === over.id);
|
||||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||||
@@ -129,22 +246,53 @@ export function SortableLessons({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext items={items.map((l) => l.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={items.map((l) => l.id)} strategy={verticalListSortingStrategy}>
|
||||||
{items.map((lesson) => (
|
{items.map((lesson) => (
|
||||||
<SortableLesson key={lesson.id} lesson={lesson} courseId={courseId} moduleId={moduleId} />
|
<SortableLesson
|
||||||
|
key={lesson.id}
|
||||||
|
lesson={lesson}
|
||||||
|
courseId={courseId}
|
||||||
|
moduleId={moduleId}
|
||||||
|
otherModules={otherModules}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<p className="text-sm text-slate-400 py-2">Уроков пока нет. Добавьте первый.</p>
|
<p className="text-sm py-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Уроков пока нет. Добавьте первый.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleCreate} className="flex gap-2 pt-2">
|
{/* Quick create form */}
|
||||||
<Input name="title" placeholder="Название нового урока" required className="max-w-xs" />
|
<form onSubmit={handleCreate} className="pt-3 space-y-2">
|
||||||
<Button type="submit">+ Урок</Button>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,25 +17,20 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions";
|
import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions";
|
||||||
|
|
||||||
interface Module {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
_count: { lessons: number };
|
_count: { lessons: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableModule({
|
function SortableModule({ mod, courseId }: { mod: Module; courseId: string }) {
|
||||||
mod,
|
|
||||||
courseId,
|
|
||||||
}: {
|
|
||||||
mod: Module;
|
|
||||||
courseId: string;
|
|
||||||
}) {
|
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(mod.title);
|
||||||
|
const [editDesc, setEditDesc] = useState(mod.description ?? "");
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
useSortable({ id: mod.id });
|
useSortable({ id: mod.id });
|
||||||
@@ -43,7 +38,7 @@ function SortableModule({
|
|||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.4 : 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
|
||||||
@@ -59,30 +54,91 @@ function SortableModule({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} className="flex items-center gap-3 bg-slate-50 border border-slate-200 rounded-xl px-4 py-3">
|
<div
|
||||||
<button type="button" {...attributes} {...listeners} className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing">
|
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>
|
</button>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
|
<form onSubmit={handleUpdate} className="flex flex-col gap-2 flex-1">
|
||||||
<Input name="title" defaultValue={mod.title} autoFocus className="h-8 text-sm" />
|
<div className="flex items-center gap-2">
|
||||||
<Button type="submit" size="sm" disabled={pending}>Сохранить</Button>
|
<input
|
||||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>Отмена</Button>
|
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>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 font-medium text-slate-700">{mod.title}</span>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-sm text-slate-400">{mod._count.lessons} уроков</span>
|
<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
|
<Link
|
||||||
href={`/admin/courses/${courseId}/modules/${mod.id}`}
|
href={`/admin/courses/${courseId}/modules/${mod.id}`}
|
||||||
className="text-xs text-amber-600 hover:underline"
|
className="btn-aubade text-xs px-3 py-1"
|
||||||
>
|
>
|
||||||
Уроки
|
Уроки →
|
||||||
</Link>
|
</Link>
|
||||||
<button type="button" onClick={() => setEditing(true)} className="text-xs text-slate-500 hover:text-slate-700">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
Переименовать
|
Переименовать
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={handleDelete} disabled={pending} className="text-xs text-red-400 hover:text-red-600">
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={pending}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: "oklch(0.577 0.245 27.325)" }}
|
||||||
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
@@ -93,7 +149,7 @@ function SortableModule({
|
|||||||
|
|
||||||
export function SortableModules({ courseId, modules }: { courseId: string; modules: Module[] }) {
|
export function SortableModules({ courseId, modules }: { courseId: string; modules: Module[] }) {
|
||||||
const [items, setItems] = useState(modules);
|
const [items, setItems] = useState(modules);
|
||||||
const [, startTransition] = useTransition();
|
const [creating, startTransition] = useTransition();
|
||||||
|
|
||||||
const sensors = useSensors(useSensor(PointerSensor));
|
const sensors = useSensors(useSensor(PointerSensor));
|
||||||
|
|
||||||
@@ -116,7 +172,7 @@ export function SortableModules({ courseId, modules }: { courseId: string; modul
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext items={items.map((m) => m.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={items.map((m) => m.id)} strategy={verticalListSortingStrategy}>
|
||||||
{items.map((mod) => (
|
{items.map((mod) => (
|
||||||
@@ -126,12 +182,25 @@ export function SortableModules({ courseId, modules }: { courseId: string; modul
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
|
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<p className="text-sm text-slate-400 py-2">Модулей пока нет. Добавьте первый.</p>
|
<p className="text-sm py-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Модулей пока нет. Добавьте первый.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleCreate} className="flex gap-2 pt-2">
|
<form onSubmit={handleCreate} className="flex gap-2 pt-3">
|
||||||
<Input name="title" placeholder="Название нового модуля" required className="max-w-xs" />
|
<input
|
||||||
<Button type="submit">+ Модуль</Button>
|
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>
|
</form>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { getSessionCookie } from "better-auth/cookies";
|
import { getSessionCookie } from "better-auth/cookies";
|
||||||
|
|
||||||
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/api/auth"];
|
const PUBLIC_ROUTES = ["/login", "/register", "/verify-email", "/api/auth", "/maintenance"];
|
||||||
|
|
||||||
export function proxy(request: NextRequest) {
|
export function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl;
|
const { pathname } = request.nextUrl;
|
||||||
|
|||||||
Reference in New Issue
Block a user