Add platform settings (Stage 9)
- Settings key-value table in Prisma with migration - getSettings() / getSetting() helpers in lib/settings.ts - Admin UI at /admin/settings with 6 sections: General, Notifications, Student profile, Legal docs, Curator permissions, Code injection - saveSettings() server action with admin-only guard - Maintenance mode: non-admin users redirected to /maintenance page - schoolName propagated to page metadata and all email templates - headCode / bodyCode injected into root layout <head> and <body> Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+163
-11
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
**Доработки таблицы пользователей (добавить в рамках Этапа 9):**
|
||||||
|
- [ ] Фильтры: по роли (Ученик / Куратор / Администратор), по статусу email (подтверждён/нет)
|
||||||
|
- [ ] Поиск по имени / email
|
||||||
|
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
|
||||||
|
- [ ] Ховер-попап на строке: список курсов пользователя со сроками доступа + email + телефон
|
||||||
|
- [ ] Быстрые действия в строке: кнопка «Выдать доступ» без перехода на страницу пользователя
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Этап 1.5 — Расширенное управление доступом ✅ ЗАВЕРШЁН (07.04.2026)
|
## Этап 1.5 — Расширенное управление доступом ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
|
|
||||||
- [x] Срок доступа: `expiresAt` в `CourseEnrollment`, просроченный подсвечивается красным
|
- [x] Срок доступа: `expiresAt` в `CourseEnrollment`, просроченный подсвечивается красным
|
||||||
@@ -80,6 +89,13 @@
|
|||||||
- [x] Admin: AdminShell на `/curator/*` маршрутах (сайдбар не пропадает)
|
- [x] Admin: AdminShell на `/curator/*` маршрутах (сайдбар не пропадает)
|
||||||
- [x] Admin: реальная статистика на дашборде (студенты, курсы, ДЗ, прогресс)
|
- [x] Admin: реальная статистика на дашборде (студенты, курсы, ДЗ, прогресс)
|
||||||
|
|
||||||
|
**Доработки (добавить в рамках Этапа 9):**
|
||||||
|
- [ ] Фильтры в списке ДЗ: по имени/email ученика, по уроку, по курсу, по статусу
|
||||||
|
- [ ] Поиск по имени/email ученика
|
||||||
|
- [ ] Пагинация + выбор кол-ва записей на странице (20/50/100)
|
||||||
|
- [ ] Расширенные статусы: «Новое» / «Просмотрено» / «Прокомментировано»
|
||||||
|
- [ ] Шаблоны ответов куратора — готовые тексты фидбека, выбираемые из списка
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 6 — Обсуждения под уроками ✅ ЗАВЕРШЁН (07.04.2026)
|
## Этап 6 — Обсуждения под уроками ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
@@ -90,6 +106,13 @@
|
|||||||
- [x] Модерация: автор, куратор или admin может удалить комментарий
|
- [x] Модерация: автор, куратор или admin может удалить комментарий
|
||||||
- [x] Счётчик активных комментариев в заголовке секции
|
- [x] Счётчик активных комментариев в заголовке секции
|
||||||
|
|
||||||
|
**Не реализовано (добавить в Этап 9 или отдельно):**
|
||||||
|
- [ ] `/admin/comments` — сводная таблица всех комментариев по всем урокам
|
||||||
|
- Колонки: №, Имя ученика, Урок (ссылка), Время, кол-во ответов, превью текста
|
||||||
|
- Удалить комментарий прямо из списка
|
||||||
|
- Пагинация
|
||||||
|
- Ссылка в сайдбаре AdminNav
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026)
|
## Этап 7 — Email-уведомления ✅ ЗАВЕРШЁН (07.04.2026)
|
||||||
@@ -114,31 +137,128 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 9 — Миграция с emdesell ← СЛЕДУЮЩИЙ
|
## Этап 9 — Настройки платформы (Admin Settings) ✅ ЗАВЕРШЁН (08.04.2026)
|
||||||
**Цель:** все пользователи и контент перенесены в новую LMS.
|
**Цель:** администратор управляет ключевыми параметрами платформы без правки кода.
|
||||||
|
|
||||||
- [ ] Скрипт импорта пользователей из CSV-экспорта emdesell (email, имя, курсы)
|
### Основное
|
||||||
- [ ] Создание пользователей без пароля + письмо «установите пароль»
|
- [ ] Название школы (используется в заголовке сайта, подписи писем)
|
||||||
- [ ] Назначение доступов к курсам по данным из CSV
|
- [ ] Описание школы (мета-тег 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, структура курсов)
|
- [ ] Чек-лист ручного переноса контента (уроки, PDF, структура курсов)
|
||||||
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
|
- [ ] Скрипт проверки целостности: все enrolled пользователи имеют доступ к нужным курсам
|
||||||
- [ ] QA: проверить 10 случайных аккаунтов после импорта
|
- [ ] QA: проверить 10 случайных аккаунтов после импорта
|
||||||
|
|
||||||
**Критерий готовности:** все ученики из emdesell могут войти в новую LMS и продолжить обучение.
|
**Критерий готовности:** загружаю CSV из emdesell → предпросмотр показывает корректные данные → применяю → ученики получают письма → могут войти и продолжить обучение.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 10 — Telegram-бот и аналитика
|
## Этап 12 — Telegram-бот и аналитика
|
||||||
**Цель:** получаю уведомления в Telegram, вижу базовую аналитику.
|
**Цель:** уведомления в Telegram для всех участников, базовая аналитика.
|
||||||
|
|
||||||
- [ ] Telegram-бот: уведомление куратору/админу о новом ДЗ
|
**Настройки (в разделе Настройки → Telegram):**
|
||||||
- [ ] Telegram-бот: уведомление об ошибках (500-е, failed email и т.д.)
|
- Токен бота (вводится в админке, хранится в Settings)
|
||||||
|
- Интеграция вкл/выкл глобально
|
||||||
|
- Показывать кнопку «Подключить Telegram» в кабинете ученика: да/нет
|
||||||
|
|
||||||
|
**Уведомления куратору/админу:**
|
||||||
|
- [ ] Новое ДЗ на проверку
|
||||||
|
- [ ] Новый вопрос от ученика
|
||||||
|
- [ ] Новая регистрация студента
|
||||||
|
- [ ] Ошибки платформы (500-е, failed email и т.д.)
|
||||||
|
|
||||||
|
**Уведомления ученику:**
|
||||||
|
- [ ] Получен фидбек по ДЗ
|
||||||
|
- [ ] Ответ куратора на вопрос
|
||||||
|
- [ ] Открыт доступ к новому курсу
|
||||||
|
|
||||||
|
**Реализация:**
|
||||||
|
- [ ] Ученик привязывает Telegram через `/start` в боте (сохраняется `telegramChatId` в User)
|
||||||
|
- [ ] Кнопка «Подключить Telegram» в личном кабинете ученика
|
||||||
|
- [ ] Админ/куратор вводит свой `chatId` в профиле или через `/start`
|
||||||
|
- [ ] Настройки бота в разделе Настройки → Telegram
|
||||||
- [ ] Yandex.Metrika: базовое подключение (pageviews)
|
- [ ] Yandex.Metrika: базовое подключение (pageviews)
|
||||||
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
|
- [ ] Admin: простая страница аналитики (активные ученики, прогресс по курсам)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Этап 11 — Тесты и квизы
|
## Этап 13 — Тесты и квизы
|
||||||
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
|
**Цель:** можно добавить тест к уроку, ученик проходит и получает результат.
|
||||||
|
|
||||||
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть)
|
- [ ] Prisma: Quiz, QuizQuestion, QuizOption, QuizAttempt (схема уже есть)
|
||||||
@@ -164,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 граф знаний
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -295,3 +295,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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { auth } from "@/lib/auth";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
import { LogoutButton } from "@/components/layout/logout-button";
|
||||||
|
import { getSetting } from "@/lib/settings";
|
||||||
|
|
||||||
export default async function StudentLayout({ children }: { children: React.ReactNode }) {
|
export default async function StudentLayout({ 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");
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
|
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
|
||||||
<header
|
<header
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,19 @@ import { redirect } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LogoutButton } from "@/components/layout/logout-button";
|
import { LogoutButton } from "@/components/layout/logout-button";
|
||||||
import { AdminShell } from "@/components/admin/admin-shell";
|
import { AdminShell } from "@/components/admin/admin-shell";
|
||||||
|
import { getSetting } from "@/lib/settings";
|
||||||
|
|
||||||
export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
|
export default async function CuratorLayout({ 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 !== "curator" && session.user.role !== "admin") redirect("/dashboard");
|
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
|
// Admin uses the admin shell with sidebar
|
||||||
if (session.user.role === "admin") {
|
if (session.user.role === "admin") {
|
||||||
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
|
return <AdminShell userName={session.user.name}>{children}</AdminShell>;
|
||||||
|
|||||||
+20
-5
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Fira_Mono } from "next/font/google";
|
import { Fira_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { getSettings } from "@/lib/settings";
|
||||||
|
|
||||||
const firaMono = Fira_Mono({
|
const firaMono = Fira_Mono({
|
||||||
weight: ["400", "500", "700"],
|
weight: ["400", "500", "700"],
|
||||||
@@ -9,19 +10,33 @@ const firaMono = Fira_Mono({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
title: "Second Brain — Обучение",
|
const settings = await getSettings();
|
||||||
description: "Образовательная платформа Second Brain",
|
return {
|
||||||
|
title: `${settings.schoolName} — Обучение`,
|
||||||
|
description: settings.schoolDescription || "Образовательная платформа",
|
||||||
|
keywords: settings.schoolKeywords || undefined,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
|
<html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
{settings.headCode ? (
|
||||||
|
<head dangerouslySetInnerHTML={{ __html: settings.headCode }} />
|
||||||
|
) : null}
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const links = [
|
|||||||
{ href: "/admin/categories", label: "Категории" },
|
{ href: "/admin/categories", label: "Категории" },
|
||||||
{ href: "/admin/users", label: "Пользователи" },
|
{ href: "/admin/users", label: "Пользователи" },
|
||||||
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
{ href: "/curator/homework", label: "ДЗ на проверку" },
|
||||||
|
{ href: "/admin/settings", label: "Настройки" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AdminNav() {
|
export function AdminNav() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+21
-11
@@ -1,4 +1,5 @@
|
|||||||
import { Resend } from "resend";
|
import { Resend } from "resend";
|
||||||
|
import { getSetting } from "./settings";
|
||||||
|
|
||||||
function getResend() {
|
function getResend() {
|
||||||
return new Resend(process.env.RESEND_API_KEY);
|
return new Resend(process.env.RESEND_API_KEY);
|
||||||
@@ -6,9 +7,13 @@ function getResend() {
|
|||||||
const FROM = process.env.EMAIL_FROM ?? "noreply@mailsend.second-brain.ru";
|
const FROM = process.env.EMAIL_FROM ?? "noreply@mailsend.second-brain.ru";
|
||||||
const BASE_URL = process.env.BETTER_AUTH_URL ?? "https://school.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) ───────
|
// ── HTML template (inline styles for maximum email client compatibility) ───────
|
||||||
|
|
||||||
function base(content: string) {
|
function base(content: string, schoolName = "Second Brain") {
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml">
|
<html lang="ru" xmlns="http://www.w3.org/1999/xhtml">
|
||||||
<head>
|
<head>
|
||||||
@@ -36,7 +41,7 @@ function base(content: string) {
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<tr>
|
<tr>
|
||||||
<td style="padding:20px 28px;border-bottom:2px solid #AAAAAA;background-color:#F5F5F0;">
|
<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;">Second Brain · Образовательная платформа</p>
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
@@ -97,6 +102,7 @@ function quote(text: string) {
|
|||||||
// ── Email senders ─────────────────────────────────────────────────────────────
|
// ── Email senders ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function sendCourseAccessEmail(to: string, name: string, courseTitle: string) {
|
export async function sendCourseAccessEmail(to: string, name: string, courseTitle: string) {
|
||||||
|
const school = await getSchoolName();
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
to,
|
to,
|
||||||
@@ -106,7 +112,7 @@ export async function sendCourseAccessEmail(to: string, name: string, courseTitl
|
|||||||
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong>.</p>
|
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong>.</p>
|
||||||
<p ${pLast}>Перейдите на платформу чтобы начать обучение:</p>
|
<p ${pLast}>Перейдите на платформу чтобы начать обучение:</p>
|
||||||
${btn(`${BASE_URL}/dashboard`, "Перейти к курсам")}
|
${btn(`${BASE_URL}/dashboard`, "Перейти к курсам")}
|
||||||
`),
|
`, school),
|
||||||
}).catch((e) => console.error("[email] sendCourseAccessEmail:", e));
|
}).catch((e) => console.error("[email] sendCourseAccessEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +123,7 @@ export async function sendHomeworkSubmittedEmail(
|
|||||||
lessonTitle: string,
|
lessonTitle: string,
|
||||||
submissionId: string
|
submissionId: string
|
||||||
) {
|
) {
|
||||||
|
const school = await getSchoolName();
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
to,
|
to,
|
||||||
@@ -126,7 +133,7 @@ export async function sendHomeworkSubmittedEmail(
|
|||||||
<p ${p}>Студент <strong>${studentName}</strong> сдал работу по уроку <strong>«${lessonTitle}»</strong>.</p>
|
<p ${p}>Студент <strong>${studentName}</strong> сдал работу по уроку <strong>«${lessonTitle}»</strong>.</p>
|
||||||
<p ${pLast}>Откройте работу чтобы проверить и оставить фидбек:</p>
|
<p ${pLast}>Откройте работу чтобы проверить и оставить фидбек:</p>
|
||||||
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Проверить работу")}
|
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Проверить работу")}
|
||||||
`),
|
`, school),
|
||||||
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
|
}).catch((e) => console.error("[email] sendHomeworkSubmittedEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +144,7 @@ export async function sendFeedbackReceivedEmail(
|
|||||||
feedbackText: string,
|
feedbackText: string,
|
||||||
lessonUrl: string
|
lessonUrl: string
|
||||||
) {
|
) {
|
||||||
|
const school = await getSchoolName();
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
to,
|
to,
|
||||||
@@ -147,35 +155,37 @@ export async function sendFeedbackReceivedEmail(
|
|||||||
${quote(feedbackText)}
|
${quote(feedbackText)}
|
||||||
<p ${pLast}>Вернитесь к уроку чтобы увидеть полный фидбек:</p>
|
<p ${pLast}>Вернитесь к уроку чтобы увидеть полный фидбек:</p>
|
||||||
${btn(lessonUrl, "Открыть урок")}
|
${btn(lessonUrl, "Открыть урок")}
|
||||||
`),
|
`, school),
|
||||||
}).catch((e) => console.error("[email] sendFeedbackReceivedEmail:", e));
|
}).catch((e) => console.error("[email] sendFeedbackReceivedEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendWelcomeEmail(to: string, name: string) {
|
export async function sendWelcomeEmail(to: string, name: string) {
|
||||||
|
const school = await getSchoolName();
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
to,
|
to,
|
||||||
subject: "Добро пожаловать в Second Brain",
|
subject: `Добро пожаловать в ${school}`,
|
||||||
html: base(`
|
html: base(`
|
||||||
<p ${p}>Привет, ${name}!</p>
|
<p ${p}>Привет, ${name}!</p>
|
||||||
<p ${p}>Ваш аккаунт на образовательной платформе <strong>Second Brain</strong> подтверждён.</p>
|
<p ${p}>Ваш аккаунт на образовательной платформе <strong>${school}</strong> подтверждён.</p>
|
||||||
<p ${pLast}>После того как администратор откроет вам доступ к курсу, вы получите письмо и сможете начать обучение.</p>
|
<p ${pLast}>После того как администратор откроет вам доступ к курсу, вы получите письмо и сможете начать обучение.</p>
|
||||||
${btn(`${BASE_URL}/dashboard`, "Перейти на платформу")}
|
${btn(`${BASE_URL}/dashboard`, "Перейти на платформу")}
|
||||||
`),
|
`, school),
|
||||||
}).catch((e) => console.error("[email] sendWelcomeEmail:", e));
|
}).catch((e) => console.error("[email] sendWelcomeEmail:", e));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendTestEmail(to: string) {
|
export async function sendTestEmail(to: string) {
|
||||||
|
const school = await getSchoolName();
|
||||||
await getResend().emails.send({
|
await getResend().emails.send({
|
||||||
from: FROM,
|
from: FROM,
|
||||||
to,
|
to,
|
||||||
subject: "Тест — Second Brain LMS",
|
subject: `Тест — ${school} LMS`,
|
||||||
html: base(`
|
html: base(`
|
||||||
<p ${p}>Привет!</p>
|
<p ${p}>Привет!</p>
|
||||||
<p ${p}>Это тестовое письмо от платформы <strong>Second Brain LMS</strong>.</p>
|
<p ${p}>Это тестовое письмо от платформы <strong>${school} LMS</strong>.</p>
|
||||||
<p ${p}>Так будут выглядеть все уведомления — доступ к курсу, фидбек по ДЗ и другие.</p>
|
<p ${p}>Так будут выглядеть все уведомления — доступ к курсу, фидбек по ДЗ и другие.</p>
|
||||||
<p ${pLast}>Если письмо отображается корректно, значит email-уведомления настроены и работают.</p>
|
<p ${pLast}>Если письмо отображается корректно, значит email-уведомления настроены и работают.</p>
|
||||||
${btn(`${BASE_URL}/dashboard`, "Открыть платформу")}
|
${btn(`${BASE_URL}/dashboard`, "Открыть платформу")}
|
||||||
`),
|
`, school),
|
||||||
}).catch((e) => console.error("[email] sendTestEmail:", e));
|
}).catch((e) => console.error("[email] sendTestEmail:", e));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
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> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSetting(key: SettingsKey): Promise<string> {
|
||||||
|
const row = await prisma.settings.findUnique({ where: { key } });
|
||||||
|
return row?.value ?? 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