Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8223cbb963 | ||
|
|
273cb2d1b4 | ||
|
|
5ad0bd98f1 | ||
|
|
03aaec76c2 | ||
|
|
a92592ec63 | ||
|
|
8fc9708233 | ||
|
|
20a25695bc | ||
|
|
005cd329b2 | ||
|
|
917253990b | ||
|
|
2498d0f8da | ||
|
|
08e212365d | ||
|
|
92fd88964e | ||
|
|
33c0d3cafd | ||
|
|
5d94949810 | ||
|
|
98cc023709 | ||
|
|
53caf54b03 | ||
|
|
4734b99bea | ||
|
|
118fa3961f | ||
|
|
1ccf994112 | ||
|
|
02b16e311b | ||
|
|
f143f58cea | ||
|
|
fd40b1e394 | ||
|
|
0cbea10e1a | ||
|
|
668dd29397 | ||
|
|
9ad4d762d7 | ||
|
|
c50a295290 | ||
|
|
0c56b0b809 | ||
|
|
89383fab1d | ||
|
|
ef46cdbe29 | ||
|
|
b49680c118 | ||
|
|
1d5b4f9251 | ||
|
|
6316c09993 | ||
|
|
9a74339087 | ||
|
|
2c619be043 | ||
|
|
52073fd914 | ||
|
|
f5768331cf | ||
|
|
541853bb1d | ||
|
|
e795c4d655 | ||
|
|
aaf84ea931 | ||
|
|
16b17685f6 | ||
|
|
5b81a7b594 | ||
|
|
980c4d621e | ||
|
|
8c2bd42be3 | ||
|
|
fa7c25465c | ||
|
|
1133557a62 | ||
|
|
a6835567f1 | ||
|
|
c2f84079ac | ||
|
|
f8f246ca9b | ||
|
|
d2094c9541 | ||
|
|
7c8e72cd94 | ||
|
|
d20e277535 | ||
|
|
47e301a5b8 | ||
|
|
39e2102a18 | ||
|
|
3e2c70dd51 | ||
|
|
a239607360 | ||
|
|
6317dade9e | ||
|
|
1aa99b9def | ||
|
|
b6d555ab3b | ||
|
|
f8aa27533c | ||
|
|
0a1425301d | ||
|
|
06b8d9f64b | ||
|
|
1cde567dbe | ||
|
|
0e67a53122 | ||
|
|
7bf3935c81 | ||
|
|
bb6c806cdd | ||
|
|
9fbb7aea6c | ||
|
|
2436f77de5 | ||
|
|
a9869bfac8 | ||
|
|
4b300f3466 |
@@ -16,6 +16,11 @@ S3_REGION="eu-central"
|
|||||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
NEXT_PUBLIC_TURNSTILE_SITE_KEY=""
|
||||||
TURNSTILE_SECRET_KEY=""
|
TURNSTILE_SECRET_KEY=""
|
||||||
|
|
||||||
|
# Яндекс.Метрика — ID счётчика school.second-brain.ru (цели signup_free/gate_view).
|
||||||
|
# Build-time (NEXT_PUBLIC_*) — передавать через --build-arg при сборке. Сам счётчик
|
||||||
|
# (ym init snippet) ставится в admin → Settings → headCode, отдельно от этого ID.
|
||||||
|
NEXT_PUBLIC_METRIKA_COUNTER_ID=""
|
||||||
|
|
||||||
# Kinescope API
|
# Kinescope API
|
||||||
KINESCOPE_API_KEY=""
|
KINESCOPE_API_KEY=""
|
||||||
|
|
||||||
@@ -24,3 +29,25 @@ INTERNAL_GRANT_SECRET=""
|
|||||||
|
|
||||||
# Freemium: slug бесплатного курса, в который авто-записываем новых юзеров при регистрации
|
# Freemium: slug бесплатного курса, в который авто-записываем новых юзеров при регистрации
|
||||||
FREE_COURSE_SLUG=obsidian-start
|
FREE_COURSE_SLUG=obsidian-start
|
||||||
|
|
||||||
|
# Freemium quiz funnel (Typebot → /api/internal/quiz-lead)
|
||||||
|
QUIZ_LEAD_SECRET=""
|
||||||
|
# Listmonk API base — публичный домен (Listmonk живёт на Hetzner, наружу через newsletter.)
|
||||||
|
LISTMONK_URL="https://newsletter.second-brain.ru"
|
||||||
|
LISTMONK_USER="apiuser"
|
||||||
|
LISTMONK_TOKEN=""
|
||||||
|
LISTMONK_QUIZ_LIST_ID=""
|
||||||
|
|
||||||
|
# Freemium gate: ID wow-урока (2.6 «ежедневные заметки») — после его завершения
|
||||||
|
# показываем лестницу-оффер (трипвайр + полный курс)
|
||||||
|
WOW_MOMENT_LESSON_ID="obs-start-l2-006"
|
||||||
|
|
||||||
|
# Obsidian Toolbox (/tools): "true" — показывать точки входа и роуты (staging);
|
||||||
|
# не задано/иное — скрыто, /tools редиректит на /dashboard (prod, пока дорабатываем)
|
||||||
|
TOOLBOX_VISIBLE=""
|
||||||
|
|
||||||
|
# Чистый PDF (browserless на staging/prod; локально — SSH-туннель на staging)
|
||||||
|
BROWSER_WS_URL="ws://localhost:3333"
|
||||||
|
# Секрет browserless (TOKEN в его env) — на прод/staging генерировать: openssl rand -hex 24
|
||||||
|
BROWSERLESS_TOKEN=""
|
||||||
|
PDF_MONTHLY_LIMIT="100"
|
||||||
|
|||||||
@@ -48,3 +48,9 @@ next-env.d.ts
|
|||||||
|
|
||||||
# Claude Code local plugins (external git repos, не коммитим)
|
# Claude Code local plugins (external git repos, не коммитим)
|
||||||
.claude/plugins/
|
.claude/plugins/
|
||||||
|
|
||||||
|
# agent-browser auth state
|
||||||
|
lms-auth.json
|
||||||
|
|
||||||
|
# SDD scratch
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Quiz-Lead Task 3 — Implementation Report
|
||||||
|
|
||||||
|
Date: 20260622
|
||||||
|
|
||||||
|
## STATUS: DONE
|
||||||
|
|
||||||
|
## Files created/modified
|
||||||
|
|
||||||
|
| File | Action |
|
||||||
|
|------|--------|
|
||||||
|
| `src/app/api/internal/quiz-lead/route.ts` | CREATED |
|
||||||
|
| `.env.example` | MODIFIED — added QUIZ_LEAD_SECRET, LISTMONK_URL, LISTMONK_USER, LISTMONK_TOKEN, LISTMONK_QUIZ_LIST_ID |
|
||||||
|
| `src/middleware.ts` | NOT MODIFIED — `/api/internal` already in PUBLIC_ROUTES with startsWith match; covers /api/internal/quiz-lead without changes |
|
||||||
|
|
||||||
|
## Test infra
|
||||||
|
|
||||||
|
`grep -E 'vitest|jest' package.json` → no output (empty).
|
||||||
|
No test framework present. Verification done via `npx tsc --noEmit` + manual reasoning.
|
||||||
|
|
||||||
|
## Commands and output
|
||||||
|
|
||||||
|
### `npm run type-check`
|
||||||
|
```
|
||||||
|
> lms-sb@0.1.0 type-check
|
||||||
|
> tsc --noEmit
|
||||||
|
|
||||||
|
(no output = clean)
|
||||||
|
```
|
||||||
|
Exit code: 0
|
||||||
|
|
||||||
|
### `npm run lint` (new file only)
|
||||||
|
```
|
||||||
|
grep 'quiz-lead' in lint output → no results (no errors in new file)
|
||||||
|
```
|
||||||
|
|
||||||
|
Pre-existing lint errors in repo (36 total, 20 errors) are NOT related to this task:
|
||||||
|
- `.claude/plugins/superpowers/` scripts using CommonJS require
|
||||||
|
- `quick-enroll-modal.tsx` — react-hooks/immutability (pre-existing)
|
||||||
|
- `kinescope-player.tsx` — react-hooks/set-state-in-effect (pre-existing)
|
||||||
|
|
||||||
|
## Commit
|
||||||
|
|
||||||
|
Base: `9fbb7ae`
|
||||||
|
New: `5e65ad6`
|
||||||
|
|
||||||
|
```
|
||||||
|
5e65ad6 Add quiz-lead internal endpoint for Typebot → Listmonk funnel
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation notes
|
||||||
|
|
||||||
|
- Auth: `x-quiz-secret` header vs `QUIZ_LEAD_SECRET` env var; 401 on mismatch/missing
|
||||||
|
- Validation: email regex, archetype against const tuple, Number.isFinite(score); 400 on failure
|
||||||
|
- Upsert logic: POST create → 409 → GET by email SQL query → PUT update; 502 on Listmonk errors
|
||||||
|
- Middleware: `/api/internal` already in PUBLIC_ROUTES with `startsWith` — no changes needed
|
||||||
|
- TypeScript strict mode: no `any` used; typed interfaces for Listmonk response shapes
|
||||||
|
- No production Listmonk list created; LISTMONK_QUIZ_LIST_ID left as empty placeholder in .env.example
|
||||||
|
- No git push; no deploy
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
None. The pre-existing lint errors in the repo are unrelated to this task and were already present before this commit.
|
||||||
@@ -36,8 +36,7 @@ Production: **https://school.second-brain.ru**
|
|||||||
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
|
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
|
||||||
|
|
||||||
### Next.js 16.2.2
|
### Next.js 16.2.2
|
||||||
- Используется `proxy.ts` вместо `middleware.ts`
|
- Защита маршрутов — `src/proxy.ts` **НЕ существует**: весь код в `src/middleware.ts`, экспорт называется `middleware` (проверено 20260912). Next.js 16 поддерживает `proxy.ts`, но в этом проекте он не заведён — не создавать второй файл, править `middleware.ts`
|
||||||
- Экспортируемая функция называется `proxy`, не `middleware`
|
|
||||||
- Перед написанием кода смотри `node_modules/next/dist/docs/`
|
- Перед написанием кода смотри `node_modules/next/dist/docs/`
|
||||||
|
|
||||||
### Tailwind CSS v4
|
### Tailwind CSS v4
|
||||||
@@ -119,8 +118,7 @@ lms-system/
|
|||||||
│ │ ├── email.ts # Resend email helpers
|
│ │ ├── email.ts # Resend email helpers
|
||||||
│ │ └── utils.ts # cn() и утилиты
|
│ │ └── utils.ts # cn() и утилиты
|
||||||
│ ├── types/ # TypeScript-типы
|
│ ├── types/ # TypeScript-типы
|
||||||
│ ├── proxy.ts # Auth middleware (защита маршрутов)
|
│ └── middleware.ts # Auth middleware (защита маршрутов) + PUBLIC_ROUTES
|
||||||
│ └── middleware.ts # Обёртка над proxy
|
|
||||||
├── prisma/
|
├── prisma/
|
||||||
│ ├── schema.prisma # Схема БД (~314 строк)
|
│ ├── schema.prisma # Схема БД (~314 строк)
|
||||||
│ ├── seed.ts # Тестовые данные
|
│ ├── seed.ts # Тестовые данные
|
||||||
@@ -142,7 +140,7 @@ lms-system/
|
|||||||
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
|
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
|
||||||
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
|
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
|
||||||
|
|
||||||
Защита маршрутов — в `src/proxy.ts` + проверка сессии в layout/page.
|
Защита маршрутов — в `src/middleware.ts` (массив `PUBLIC_ROUTES`, сверка по `startsWith`) + проверка сессии в layout/page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
| TipTap | 2.x | WYSIWYG-редактор уроков |
|
| TipTap | 2.x | WYSIWYG-редактор уроков |
|
||||||
| @kinescope/react-kinescope-player | latest | Видеоплеер |
|
| @kinescope/react-kinescope-player | latest | Видеоплеер |
|
||||||
| Resend | latest | Email-уведомления |
|
| Resend | latest | Email-уведомления |
|
||||||
| AWS SDK (S3) | 3.x | Hetzner Object Storage |
|
| AWS SDK (S3) | 3.x | Backblaze B2 (раздача через Bunny CDN) |
|
||||||
| Zod | 3.x | Валидация данных |
|
| Zod | 3.x | Валидация данных |
|
||||||
| Docker Compose | 2.x | Локальная разработка и деплой |
|
| Docker Compose | 2.x | Локальная разработка и деплой |
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ lms-system/
|
|||||||
│ │ ├── auth.ts # Better Auth config (сервер)
|
│ │ ├── auth.ts # Better Auth config (сервер)
|
||||||
│ │ ├── auth-client.ts # Better Auth client (браузер)
|
│ │ ├── auth-client.ts # Better Auth client (браузер)
|
||||||
│ │ ├── prisma.ts # Prisma singleton client
|
│ │ ├── prisma.ts # Prisma singleton client
|
||||||
│ │ ├── s3.ts # Hetzner Object Storage клиент
|
│ │ ├── s3.ts # Backblaze B2 (S3-совместимый) клиент
|
||||||
│ │ ├── email.ts # Resend email helpers
|
│ │ ├── email.ts # Resend email helpers
|
||||||
│ │ └── utils.ts # cn() и прочие утилиты
|
│ │ └── utils.ts # cn() и прочие утилиты
|
||||||
│ ├── types/
|
│ ├── types/
|
||||||
@@ -135,7 +135,7 @@ docker compose -f docker-compose.prod.yml logs -f app
|
|||||||
- Перед `prisma migrate deploy` на production — делать бэкап БД
|
- Перед `prisma migrate deploy` на production — делать бэкап БД
|
||||||
|
|
||||||
### Файлы и контент
|
### Файлы и контент
|
||||||
- Загружаемые файлы (ДЗ, PDF) — только через Hetzner Object Storage, никогда на диск VPS
|
- Загружаемые файлы (ДЗ, PDF, картинки комментариев) — только в Backblaze B2, никогда на диск VPS
|
||||||
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
|
- Секреты (API-ключи, токены, строки подключения) — **только в `.env.local`**, в коде запрещено
|
||||||
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
|
- `.env.example` всегда обновлять при добавлении новых переменных (без реальных значений)
|
||||||
|
|
||||||
@@ -168,8 +168,9 @@ BETTER_AUTH_URL="http://localhost:3000"
|
|||||||
RESEND_API_KEY=""
|
RESEND_API_KEY=""
|
||||||
EMAIL_FROM="noreply@school.second-brain.ru"
|
EMAIL_FROM="noreply@school.second-brain.ru"
|
||||||
|
|
||||||
# Hetzner Object Storage (S3-совместимый)
|
# Backblaze B2 (S3-совместимый), раздача через Bunny CDN
|
||||||
S3_ENDPOINT="https://fsn1.your-objectstorage.com"
|
S3_ENDPOINT="https://s3.eu-central-003.backblazeb2.com"
|
||||||
|
S3_CDN_URL="https://files.second-brain.ru"
|
||||||
S3_BUCKET="lms-uploads"
|
S3_BUCKET="lms-uploads"
|
||||||
S3_ACCESS_KEY=""
|
S3_ACCESS_KEY=""
|
||||||
S3_SECRET_KEY=""
|
S3_SECRET_KEY=""
|
||||||
@@ -181,6 +182,67 @@ S3_REGION="eu-central"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Грабли уклада (проверено 20260912)
|
||||||
|
|
||||||
|
Неочевидные соглашения, уже действующие в коде. Сломать их легко «улучшением».
|
||||||
|
|
||||||
|
### Тесты
|
||||||
|
|
||||||
|
- `vitest.config.ts` ищет тесты **только** по `src/lib/**/__tests__/**/*.test.ts`. Тест рядом с исходником или в `src/app/**` молча не подбирается — зелёный `npm test` не значит, что твой тест выполнился. Проверяй, что файл попал в прогон.
|
||||||
|
- Интеграционный тест генерации PDF выключен по умолчанию: `describe.runIf(process.env.RUN_PDF_INTEGRATION === "1")` + нужен живой `BROWSER_WS_URL`. Гейт не снимать — тест ходит в интернет и в browserless.
|
||||||
|
- Решения выносим в чистую функцию с внедряемым IO, обёртка с IO остаётся тонкой (`decidePaidAccess` рядом с `hasPaidAccess`; `assertPublicUrl(raw, resolve)`). Prisma и dns в тестах **не мокаем**.
|
||||||
|
|
||||||
|
### Маршруты
|
||||||
|
|
||||||
|
- **`src/proxy.ts` не существует** — вся защита маршрутов в `src/middleware.ts`, экспорт `middleware`. Не заводить второй файл.
|
||||||
|
- Новый API-роут, который авторизуется сам (Bearer-ключ, внутренний секрет), обязан попасть в `PUBLIC_ROUTES` — иначе middleware отдаст редирект на `/login` раньше роута, и в браузере с кукой это не воспроизведётся. Обратная сторона: сверка идёт по `startsWith`, поэтому запись `/api/pdf` уже открывает любой будущий `/api/pdf-*` — такой роут обязан проверять доступ внутри себя.
|
||||||
|
|
||||||
|
### Чистый PDF (`/tools/clean-pdf`, `/api/pdf`)
|
||||||
|
|
||||||
|
- **Два разных значения `ToolUsage.tool`**: `clean-pdf-generate` (`PDF_TOOL_ID`) — только успешные генерации, по ним считаются месячный лимит и burst; `clean-pdf` — аналитика копирований из `CopyButton`. Не объединять: иначе каждое нажатие «Скопировать» списывает генерацию. `clean-pdf-generate` намеренно отсутствует в `TOOL_IDS`, чтобы публичный Server Action не мог списать квоту.
|
||||||
|
- Квота списывается **только после успешной** генерации, месячное окно — от начала месяца по UTC (`monthStartUtc`). Рефакторинг «сначала резервируем квоту, потом рендерим» начнёт списывать за каждую неоткрывшуюся страницу.
|
||||||
|
- `page.pdf()` в playwright-core не принимает `timeout` — оборачивать своим `withTimeout` (Promise.race), иначе зависшая печать держит слот `CONCURRENT` у browserless.
|
||||||
|
- `context.route("**/*")` **не видит WebSocket** — нужен отдельный `context.routeWebSocket`. Два похожих блока подряд — не дублирование, это вторая линия SSRF-обороны; удалять только осознанно.
|
||||||
|
- `isPrivateAddress(ip)` — **fail-closed гейт, а не предикат**: на всё, что не разбирается как IP, возвращает `true`. Не переиспользовать как «адрес из приватного диапазона».
|
||||||
|
- В `resolveUserId` ветка Bearer терминальная: если заголовок `Authorization` есть, фолбэка на сессию нет ни при каком исходе. Не добавлять «если ключ не подошёл — попробуем куку»: запрос с чужим ключом из залогиненного браузера начнёт выполняться от имени владельца сессии.
|
||||||
|
- Заголовки ответа — публичный контракт с уже розданными ключами: `X-Uses-Count`, `X-Max-Uses` и двойной `Content-Disposition` (ASCII-фолбэк + RFC 5987 `filename*`). Их читают Zotero-скрипт и форма на сайте.
|
||||||
|
- Карта ошибок: `BlockedUrlError`/`EmptyContentError` → 422, `RenderError` → 504, остальное → 504, **никогда 500**. Текст `{error}` показывается студенту дословно — только по-русски, без внутренних деталей.
|
||||||
|
- HTML от Defuddle санируется собственным проходом в `template.prepareContent` (JSDOM: `script`/`style`/`iframe`/`object`/`embed`, все `on*`, `javascript:`) — намеренно **не** через `rehype-sanitize`, который заточен под Markdown-пайплайн комментариев.
|
||||||
|
- `zotero-script.ts` генерирует JS шаблонной строкой: учетверённые бэкслеши (`\\\\` в исходнике = один `\` в выданном скрипте). Ошибка экранирования не падает, а тихо ломает регулярки в выданном студенту скрипте — правил строку, прогони тест на валидность (`new Function(script)`).
|
||||||
|
|
||||||
|
### Env
|
||||||
|
|
||||||
|
- `NEXT_PUBLIC_*` инлайнятся **на сборке**. `NEXT_PUBLIC_APP_URL` задан только как runtime-env в `docker-compose.prod.yml`, в образ не попадает → работает захардкоженный фолбэк на прод-адрес. Следствие: на staging студенту выдаются Zotero-скрипт и curl-пример с **прод**-адресом. Правишь — передавай через `--build-arg`, как `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
|
||||||
|
|
||||||
|
### Тариф — это отдельный курс со своей копией уроков
|
||||||
|
|
||||||
|
`obsidian` и `obsidian-full` (как и `zotero` / `zotero-full`) — **две разные записи `Course`, у каждой свой полный комплект `Module` и `Lesson`**. Это не представления одного курса.
|
||||||
|
|
||||||
|
- **Правка контента применяется в обе копии.** Починил текст в «Всё включено» — в базовом тарифе он остался прежним. Копии уже расходились в проде: блок с благодарностью автору был только в дешёвом тарифе.
|
||||||
|
- **Комментарии сыплются под уроки обеих копий.** Ответ под уроком `zotero-full` студенты `zotero` не увидят; выборка «неотвеченные» должна идти по всем курсам сразу.
|
||||||
|
- Перед массовой правкой — `SELECT` по обоим slug, потом `UPDATE ... WHERE id IN (...)`.
|
||||||
|
|
||||||
|
### Markdown рендерится не везде
|
||||||
|
|
||||||
|
| Где | Что рендерится |
|
||||||
|
|---|---|
|
||||||
|
| Комментарии к урокам (`lesson-comments.tsx`) | Markdown + GFM: разметка, таблицы, голый адрес становится ссылкой |
|
||||||
|
| Отзыв куратора на ДЗ (`homework-section.tsx`) | простой текст (`whitespace-pre-wrap` + `linkify`) |
|
||||||
|
| Раздел вопросов (`QuestionThread.tsx`, `QuestionSplitView.tsx`) | простой текст (`whitespace-pre-wrap` + `linkify`) |
|
||||||
|
|
||||||
|
`**жирный**` в отзыве на ДЗ студент увидит звёздочками. Ссылку там же писать голым адресом можно — `linkify` её поднимет.
|
||||||
|
|
||||||
|
Безопасность рендера комментариев (не ослаблять «улучшением»): `rehype-sanitize` режет сырой HTML и `javascript:`; картинки с чужих доменов **не грузятся** — компонент `img` отдаёт их ссылкой, чтобы адрес читателя не утекал на сторонний сервер. Свои картинки — через `/api/student/comment-upload`.
|
||||||
|
|
||||||
|
### Файлы курсов живут на двух разных доменах
|
||||||
|
|
||||||
|
- `files.second-brain.ru` — Bunny CDN → Backblaze B2. Вложения уроков, ДЗ, картинки комментариев. Кеш короткий, правка «на месте» доезжает за час.
|
||||||
|
- `filez.second-brain.ru` — Selectel CDN → origin на Hoster.kz `/root/filez-static/`. Бонусы, архивы, шаблоны и CSS курсов. ⚠️ Кеш до 30 дней, **query-параметры игнорируются**: чтобы обновление дошло сразу, публиковать под новым путём или именем, а не `?v=2`.
|
||||||
|
|
||||||
|
Домены различаются одной буквой — легко перепутать при диагностике.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Чек-лист перед каждым коммитом
|
## Чек-лист перед каждым коммитом
|
||||||
|
|
||||||
- [ ] `npm run lint` — нет ошибок ESLint
|
- [ ] `npm run lint` — нет ошибок ESLint
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ COPY . .
|
|||||||
# NEXT_PUBLIC_* vars are inlined at build time
|
# NEXT_PUBLIC_* vars are inlined at build time
|
||||||
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
ARG NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||||
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY
|
||||||
|
ARG NEXT_PUBLIC_METRIKA_COUNTER_ID
|
||||||
|
ENV NEXT_PUBLIC_METRIKA_COUNTER_ID=$NEXT_PUBLIC_METRIKA_COUNTER_ID
|
||||||
# Generate Prisma client before build
|
# Generate Prisma client before build
|
||||||
RUN npx prisma generate
|
RUN npx prisma generate
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|||||||
@@ -340,3 +340,6 @@
|
|||||||
- Записи открытых встреч и вебинаров
|
- Записи открытых встреч и вебинаров
|
||||||
- Календарь: предстоящие открытые уроки, запуски курсов, события
|
- Календарь: предстоящие открытые уроки, запуски курсов, события
|
||||||
- Возможно: публичный Obsidian-like граф знаний
|
- Возможно: публичный Obsidian-like граф знаний
|
||||||
|
|
||||||
|
- **Markdown-рендер описаний ДЗ** — сейчас `Homework.description` выводится как plain text (`whitespace-pre-wrap` в `src/components/student/homework-section.tsx`), поэтому markdown-разметка (списки, `**жирный**`, ссылки) показывается сырой. Перевести на markdown-рендер (react-markdown или общий рендерер контента урока), чтобы задания оформлялись аккуратно.
|
||||||
|
- Контекст: при миграции ДЗ из Emdesell были артефакты форматирования (оторванные маркеры списков `• \n`, пачки пустых строк). 17.06.2026 13 ДЗ (очаг — obsidian-ai) причёсаны regex'ом под текущий plain-text рендер (бэкап `Homework_bak_20260617`). Полноценное решение — markdown-рендер.
|
||||||
|
|||||||
@@ -171,6 +171,46 @@ CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aub
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
|
| `POST` | `/api/auth/[...all]` | Better Auth handler | Все |
|
||||||
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
|
| `POST` | `/api/admin/upload` | Загрузка файла в S3, возвращает `{ url, key }` | admin |
|
||||||
|
| `GET` | `/api/pdf` | Чистый PDF из URL (Bearer-ключ или сессия) — см. раздел ниже | платный студент, admin, curator |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чистый PDF (`/tools/clean-pdf`, `/api/pdf`)
|
||||||
|
|
||||||
|
Инструмент Obsidian Toolbox: превращает произвольный URL в чистый PDF (без рекламы и меню, с типографикой, оглавлением, A4/Letter, light/dark). Полный дизайн-документ: [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md).
|
||||||
|
|
||||||
|
**Пайплайн генерации:**
|
||||||
|
|
||||||
|
```
|
||||||
|
URL студента
|
||||||
|
→ browserless (Chromium по WebSocket, playwright-core) загружает страницу
|
||||||
|
→ Next.js забирает итоговый HTML
|
||||||
|
→ Defuddle (JSDOM) выделяет основной контент
|
||||||
|
→ HTML-шаблон (типографика, A4/Letter, light/dark, оглавление)
|
||||||
|
→ Playwright page.pdf() в том же browserless
|
||||||
|
→ application/pdf в ответе
|
||||||
|
```
|
||||||
|
|
||||||
|
Доступ: любой платный студент — есть `CourseEnrollment` на курс вне `FREE_COURSE_SLUG` и не истёкший (`expiresAt` пуст или в будущем); admin/curator проходят гейт без курсов. `banned` перекрывает всё, включая admin. «Без ограничений» — только про гейт доступа: квоты (burst 5/мин и `PDF_MONTHLY_LIMIT`) считаются по `userId` одинаково для всех ролей. Видимость страницы гейтится флагом `TOOLBOX_VISIBLE`, но сам `/api/pdf` работает независимо от него (доступ проверяется отдельно). Ключ для внешнего API — модель `PdfApiKey` (`sbpdf_<random>`, ленивая генерация, регенерация инвалидирует старый).
|
||||||
|
|
||||||
|
**Env-переменные:**
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `BROWSER_WS_URL` | WebSocket-адрес browserless (`ws://browserless:3000` в compose, `ws://localhost:3333` при туннеле локально) |
|
||||||
|
| `BROWSERLESS_TOKEN` | Секрет browserless (`TOKEN` в его env) — общий и для сервиса, и для клиента в LMS |
|
||||||
|
| `PDF_MONTHLY_LIMIT` | Лимит генераций в месяц на студента (по умолчанию `100`), без пересборки |
|
||||||
|
| `TOOLBOX_VISIBLE` | Видимость раздела `/tools/*` и ссылок на него. На `/api/pdf` **не влияет** |
|
||||||
|
| `FREE_COURSE_SLUG` | Какой курс считается бесплатным (дефолт `obsidian-start`) — от него зависит, что считать платным enrollment |
|
||||||
|
| `NEXT_PUBLIC_APP_URL` | Базовый адрес в Zotero-скрипте и curl-примере. ⚠️ `NEXT_PUBLIC_*` инлайнится **на сборке**, а задан только как runtime-env в compose → в образе работает фолбэк на прод-адрес (на staging скрипт выдаётся с прод-URL) |
|
||||||
|
|
||||||
|
Контейнер `browserless` (`ghcr.io/browserless/chromium`, образ **не запинен по версии**). На проде порт не публикуется вовсе — доступ только по внутренней сети compose. В dev и на staging порт публикуется на loopback хоста (`127.0.0.1:3333:3000`) — именно на этом держится SSH-туннель для интеграционного теста; наружу он при этом не торчит.
|
||||||
|
|
||||||
|
**SSRF-защита — честно про пределы:** исходный URL проверяется резолвом DNS (`assertPublicUrl`: блок приватных и зарезервированных диапазонов, включая v4-mapped IPv6), а внутри browserless запросы страницы фильтруются по `context.route` + `context.routeWebSocket`.
|
||||||
|
|
||||||
|
Предел у этой защиты шире, чем «DNS-rebind»: `assertPublicUrl` вызывается **ровно один раз — для исходного URL**, а browser-level фильтр хостнеймы **не резолвит вовсе** — сверяет только строки `localhost`/`*.local`/`*.internal` и литеральные IP. Значит без проверки уходит **любой новый хостнейм после первого перехода**: цель HTTP-редиректа, субресурс страницы, `fetch`/XHR из JS, `ws://`. Достаточно редиректа на внутреннее имя — DNS-rebind даже не требуется.
|
||||||
|
|
||||||
|
Закрывается это только на сетевом уровне. **Перед тем как поднять контейнер `browserless` на проде** (а не перед флагом `TOOLBOX_VISIBLE` — `/api/pdf` живёт независимо от него), нужно заблокировать исходящий трафик рендерера в `169.254.0.0/16` и RFC1918. Полный разбор — «Безопасность» и «Деплой» в [`docs/specs/20260706-clean-pdf-design.md`](docs/specs/20260706-clean-pdf-design.md) и процесс `SBT/02-Стандарты/Процессы/lms-deploy.md`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Third-Party Notices
|
||||||
|
|
||||||
|
This product bundles third-party software. Their licenses and copyright
|
||||||
|
notices are reproduced below as required.
|
||||||
|
|
||||||
|
## force-graph
|
||||||
|
|
||||||
|
Used client-side in the "Obsidian Wrapped" feature to render the vault graph
|
||||||
|
poster on an off-screen canvas. The library and its node names never leave the
|
||||||
|
browser.
|
||||||
|
|
||||||
|
- Version: 1.51.4
|
||||||
|
- License: MIT
|
||||||
|
- Copyright (c) 2018 Vasco Asturiano
|
||||||
|
- https://github.com/vasturiano/force-graph
|
||||||
|
|
||||||
|
```
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2018 Vasco Asturiano
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
|
|
||||||
|
force-graph depends on the d3-force family (ISC) by Mike Bostock for its
|
||||||
|
force simulation, bundled transitively.
|
||||||
|
|
||||||
|
## next/og (Satori + resvg)
|
||||||
|
|
||||||
|
Server-side share-card PNG rendering (`ImageResponse`) uses Vercel's `next/og`,
|
||||||
|
which bundles `satori` and `@resvg/resvg-wasm` (both MPL-2.0). These are part of
|
||||||
|
the `next` dependency and distributed under Next.js's own license terms.
|
||||||
|
|
||||||
|
- satori — MPL-2.0 — https://github.com/vercel/satori
|
||||||
|
- resvg-wasm — MPL-2.0 — https://github.com/yisibl/resvg-js
|
||||||
+21
-2
@@ -13,24 +13,43 @@ services:
|
|||||||
NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru"
|
NEXT_PUBLIC_APP_URL: "https://school.second-brain.ru"
|
||||||
RESEND_API_KEY: "${RESEND_API_KEY}"
|
RESEND_API_KEY: "${RESEND_API_KEY}"
|
||||||
EMAIL_FROM: "${EMAIL_FROM}"
|
EMAIL_FROM: "${EMAIL_FROM}"
|
||||||
|
BROWSER_WS_URL: "ws://browserless:3000"
|
||||||
|
BROWSERLESS_TOKEN: "${BROWSERLESS_TOKEN}"
|
||||||
|
PDF_MONTHLY_LIMIT: "${PDF_MONTHLY_LIMIT:-100}"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
browserless:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
# ⚠️ PG18 сменил раскладку: том монтируется в /var/lib/postgresql (НЕ .../data),
|
||||||
|
# кластер лежит в подкаталоге 18/docker. Мажор прода сделан 09.07.2026.
|
||||||
|
# Не откатывать тег и mount по отдельности — иначе initdb на непустом томе → рестарт-луп.
|
||||||
|
# В этом каталоге на Hetzner том lms-sb_postgres_data = hot-standby (тоже PG18).
|
||||||
|
image: postgres:18-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: lms_user
|
POSTGRES_USER: lms_user
|
||||||
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
POSTGRES_PASSWORD: "${DB_PASSWORD}"
|
||||||
POSTGRES_DB: lms_db
|
POSTGRES_DB: lms_db
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"]
|
test: ["CMD-SHELL", "pg_isready -U lms_user -d lms_db"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
|
browserless:
|
||||||
|
image: ghcr.io/browserless/chromium
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
TOKEN: "${BROWSERLESS_TOKEN}"
|
||||||
|
CONCURRENT: "2"
|
||||||
|
QUEUED: "10"
|
||||||
|
TIMEOUT: "120000"
|
||||||
|
mem_limit: 1g
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
+13
-3
@@ -1,15 +1,25 @@
|
|||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:18-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: lms_user
|
POSTGRES_USER: lms_user
|
||||||
POSTGRES_PASSWORD: lms_password
|
POSTGRES_PASSWORD: lms_password
|
||||||
POSTGRES_DB: lms_db
|
POSTGRES_DB: lms_db
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "127.0.0.1:5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql
|
||||||
|
|
||||||
|
browserless:
|
||||||
|
image: ghcr.io/browserless/chromium
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3333:3000"
|
||||||
|
environment:
|
||||||
|
CONCURRENT: "2"
|
||||||
|
QUEUED: "10"
|
||||||
|
TIMEOUT: "120000"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Чистый PDF — снимок состояния
|
||||||
|
|
||||||
|
**Дата снимка:** 20260912
|
||||||
|
**Фаза:** слито в `main`, работает на staging, на прод не выкачено
|
||||||
|
**Охват:** только фича «Чистый PDF». Состояние остальных частей LMS здесь не отражено.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Коротко
|
||||||
|
|
||||||
|
Инструмент `URL → чистый PDF` в Obsidian Toolbox: веб-форма в кабинете, персональный API-ключ + скрипт для Zotero (Actions & Tags), открытый curl API. Рендер — отдельный контейнер browserless (Chromium по CDP), контент выделяет Defuddle, печатает `page.pdf()`.
|
||||||
|
|
||||||
|
Сделано 20260706 по плану из 11 задач (субагентное исполнение с ревью после каждой). Merge `53caf54`, 21 коммит. Финальное whole-branch ревью — READY TO MERGE.
|
||||||
|
|
||||||
|
## Состояние сред (проверено 20260912)
|
||||||
|
|
||||||
|
| Среда | Состояние |
|
||||||
|
|---|---|
|
||||||
|
| `main` | Фича на месте и цела: файлы побайтово совпадают с merge `53caf54`; `/api/pdf` в `PUBLIC_ROUTES`, регистрация инструмента и модель `PdfApiKey` не пострадали от августовских коммитов |
|
||||||
|
| **Прод** (Hoster.kz) | **Выключено.** Контейнера `browserless` нет, `BROWSER_WS_URL`/`BROWSERLESS_TOKEN`/`TOOLBOX_VISIBLE` в `/root/lms-sb/.env` не заданы. Репозиторный `docker-compose.prod.yml` намеренно расходится с живым `/root/lms-sb/docker-compose.yml` |
|
||||||
|
| **Staging** (Hetzner) | **Работает.** Стенд стоит ровно на `53caf54` и отстал от `main` на августовские коммиты. `TOOLBOX_VISIBLE=true`, контейнеры `lms-staging-app-1` и `lms-staging-browserless-1` живы с 20260706 |
|
||||||
|
|
||||||
|
Проверено E2E на staging 20260706: 401 без ключа, 403 без платного доступа, 200 с реальным PDF (576 КБ, 4 страницы), 422 на SSRF-пробы (`192.168.x`, `localhost:5432`, `[::ffff:169.254.169.254]`). Страница и перевыпуск ключа проверены в живом браузере.
|
||||||
|
|
||||||
|
## Что где лежит
|
||||||
|
|
||||||
|
- Код: `src/lib/clean-pdf/` (`ssrf.ts`, `template.ts`, `generate.ts`, `access.ts`, `keys.ts`, `api-key.ts`, `zotero-script.ts`), роут `src/app/api/pdf/route.ts`, страница `src/app/(student)/tools/clean-pdf/`, действие `src/lib/actions/pdf-key-actions.ts`
|
||||||
|
- Данные: модель `PdfApiKey`, миграция `20260706120000_add_pdf_api_key`; учёт — существующая `ToolUsage`
|
||||||
|
- Документы: спека `docs/specs/20260706-clean-pdf-design.md`, план `docs/superpowers/plans/20260706-clean-pdf.md`, раздел в `TECHNICAL.md`
|
||||||
|
- Уклад и грабли: раздел «Грабли уклада» в `CLAUDE.md`
|
||||||
|
- В базе знаний: `SBT/00-Стек/Сервисы/clean-pdf.md`, `SBT/03-Опыт/Постмортемы/20260706-clean-pdf-ssrf-обходы.md`, гейт в `SBT/02-Стандарты/Процессы/lms-deploy.md`
|
||||||
|
|
||||||
|
## ⚠️ Гейт перед прод-релизом
|
||||||
|
|
||||||
|
**Закрыть исходящий трафик контейнера `browserless`** — заблокировать `169.254.0.0/16` и RFC1918 через `internal`-сеть compose или `DOCKER-USER`. На 20260912 не сделано ни в репозитории (у сервиса нет `networks`/`internal`), ни на хосте (`DOCKER-USER` пуста).
|
||||||
|
|
||||||
|
**Гейт привязан к рендереру, а не к флагу.** `TOOLBOX_VISIBLE` прячет только страницы `/tools/*`; `/api/pdf` лежит в `PUBLIC_ROUTES` и авторизуется сам, поэтому фича оживёт для любого платного студента в момент появления `browserless` и `BROWSER_WS_URL` — ещё до поднятия флага.
|
||||||
|
|
||||||
|
Причина: `assertPublicUrl` резолвит DNS **один раз, для исходного URL**, а фильтр внутри браузера хостнеймы не резолвит вовсе. Непроверенным уходит любой новый хостнейм после первого перехода (редирект, субресурс, `fetch`, `ws://`) — DNS-rebind для обхода не нужен.
|
||||||
|
|
||||||
|
## Хвосты (по убыванию важности)
|
||||||
|
|
||||||
|
1. **Egress-изоляция browserless** — гейт выше. Блокирует прод-релиз.
|
||||||
|
2. **Фикс WebSocket-обхода (`routeWebSocket`) не покрыт тестом.** Баг с v4-mapped IPv6 закрыт юнит-тестами `ssrf.test.ts`, а браузерный фильтр не тестируется вовсе (единственный тест генерации за `RUN_PDF_INTEGRATION`). Регрессия пройдёт молча.
|
||||||
|
3. **Образ browserless не запинен** — `ghcr.io/browserless/chromium` без тега в обоих compose. Контейнер исполняет контент атакующего; обновление меняет Chromium и поведение CDP-слоя, на котором держатся фильтры.
|
||||||
|
4. **`NEXT_PUBLIC_APP_URL` не доезжает до образа** — задан только как runtime-env в compose, а `NEXT_PUBLIC_*` инлайнятся на сборке. Работает захардкоженный фолбэк на прод-адрес: на staging студенту выдаются Zotero-скрипт и curl с **прод**-URL. Лечится `--build-arg`, как у `NEXT_PUBLIC_TURNSTILE_SITE_KEY`.
|
||||||
|
5. **Ключи `PdfApiKey` лежат в БД открытым текстом** (поиск прямым `findUnique`). Дампы, реплика на standby и PITR содержат рабочие ключи. Ущерб ограничен (только генерация в пределах лимита, доступ проверяется на каждый запрос), но при релизе стоит перейти на хеш.
|
||||||
|
6. **Лимит не расходуется неуспешными генерациями** — `ToolUsage` пишется строго после успешного рендера, поэтому ни месячная квота, ни burst не тратятся на отказы. Ограниченный self-DoS тяжёлыми URL. Рассмотреть подсчёт попыток.
|
||||||
|
7. **`RenderError` обёрнута только часть вызовов браузера** (`connectOverCDP`, `goto`, таймаут печати). `newContext`, `newPage`, `content`, `route`/`routeWebSocket` и reject самого `pdf()` дают сырую ошибку Playwright — её ловит catch-all роута и отдаёт 504.
|
||||||
|
|
||||||
|
Маркеров TODO/FIXME в коде фичи нет — хвосты живут в документах.
|
||||||
|
|
||||||
|
## Если возвращаемся к работе
|
||||||
|
|
||||||
|
1. Прочитать этот файл и «Грабли уклада» в `CLAUDE.md`.
|
||||||
|
2. Перед любым касанием прода — `SBT/02-Стандарты/Процессы/lms-deploy.md` (там гейт).
|
||||||
|
3. Staging отстал от `main` — перед проверками подтянуть ветку и передеплоить (`bash ~/Documents/Claude/scripts/deploy-staging.sh`; скрипт иногда не пересоздаёт контейнер, тогда вручную `docker compose up -d app`).
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
# Чистый PDF — инструмент «URL → PDF» в LMS
|
||||||
|
|
||||||
|
Дата: 20260706
|
||||||
|
Статус: дизайн утверждается
|
||||||
|
Референс: Clean PDF (`pdf.brainysnipe.ru`) из поста https://t.me/flowing_abyss/94, инструкция https://flowing-abyss.com/Clean-PDF-for-Zotero
|
||||||
|
|
||||||
|
## Что делаем
|
||||||
|
|
||||||
|
Инструмент для студентов школы: превращает любую веб-страницу в чистый PDF — без рекламы, меню и мусора, с нормальной типографикой, оглавлением, форматами A4/Letter и светлой/тёмной темой. Два сценария плюс открытый API:
|
||||||
|
|
||||||
|
1. **Веб-форма** в кабинете (`/tools/clean-pdf`): вставил URL — скачал PDF.
|
||||||
|
2. **Zotero**: персональный API-ключ + скрипт для плагина Actions & Tags — правый клик на записи → PDF генерируется и прикрепляется к ней.
|
||||||
|
3. **Открытый API** для продвинутых: `curl` с Bearer-ключом, документация на странице инструмента.
|
||||||
|
|
||||||
|
## Принятые решения
|
||||||
|
|
||||||
|
| Вопрос | Решение |
|
||||||
|
|---|---|
|
||||||
|
| Архитектура | Всё внутри LMS (подход B): страница и API в Next.js, рендер — отдельный контейнер browserless в том же compose |
|
||||||
|
| Кому доступ | Любой платный студент: есть `CourseEnrollment` на курс со slug ≠ `FREE_COURSE_SLUG`; admin/curator — без ограничений |
|
||||||
|
| Видимость | Часть Obsidian Toolbox, живёт под существующим флагом `TOOLBOX_VISIBLE`; включение на проде — отдельное решение позже |
|
||||||
|
| Лимит | 100 генераций/месяц на студента, env `PDF_MONTHLY_LIMIT` (без пересборки), плюс burst-лимит 5/мин |
|
||||||
|
|
||||||
|
## Пайплайн генерации
|
||||||
|
|
||||||
|
```
|
||||||
|
URL студента
|
||||||
|
→ Chromium (browserless) загружает страницу (JS отрабатывает)
|
||||||
|
→ Next.js забирает итоговый HTML
|
||||||
|
→ Defuddle (npm, через JSDOM) выделяет главный контент
|
||||||
|
→ чистый HTML-шаблон: типографика, A4/Letter, light/dark, оглавление из заголовков
|
||||||
|
→ Playwright page.pdf() в том же browserless
|
||||||
|
→ файл application/pdf в ответ
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Defuddle** — библиотека kepano (автор Obsidian), та же, что у референса: https://github.com/kepano/defuddle
|
||||||
|
- **browserless/chromium** — отдельный контейнер в docker-compose: `MAX_CONCURRENT_SESSIONS=2`, встроенная очередь, `mem_limit` ~1 ГБ, порт наружу не публикуется.
|
||||||
|
- Next.js подключается по WebSocket через `playwright-core` (только клиент, Chromium в образ LMS не попадает — образ не растёт).
|
||||||
|
- Таймаут всего пайплайна ~90 сек; понятные JSON-ошибки: URL недоступен, лимит исчерпан, нет платного доступа.
|
||||||
|
|
||||||
|
## Данные (Prisma)
|
||||||
|
|
||||||
|
Новая модель:
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model PdfApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
key String @unique // формат sbpdf_<random>, показывается студенту
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Ключ создаётся лениво при первом заходе на страницу инструмента.
|
||||||
|
- Перегенерация заменяет ключ (старый перестаёт работать сразу).
|
||||||
|
- Учёт использований — существующая `ToolUsage` с `tool = "clean-pdf-generate"`; месячный счётчик = COUNT за календарный месяц. Именно отдельный идентификатор: `CopyButton` на странице логирует копирования как `tool = "clean-pdf"`, и они не должны тратить лимит генераций.
|
||||||
|
- Проверка платного доступа выполняется на каждый запрос → рефанд или блокировка аккаунта автоматически отключает и веб, и ключ. Источник истины один — БД LMS.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
`GET /api/pdf?url=<...>&format=A4|Letter&theme=light|dark`
|
||||||
|
|
||||||
|
- **Авторизация, два пути**: заголовок `Authorization: Bearer sbpdf_...` (Zotero, curl) **или** активная сессия Better Auth (веб-форма — студенту ключ для веба не нужен).
|
||||||
|
- Роут добавляется в `PUBLIC_ROUTES` в `middleware.ts` (иначе 307 → /login — известные грабли).
|
||||||
|
- Ответ: `application/pdf` + заголовки `X-Uses-Count` / `X-Max-Uses` (их же показывает Zotero-скрипт).
|
||||||
|
- Ошибки: JSON `{ error }` c честными статусами (401 нет ключа/сессии, 403 нет платного доступа, 429 лимит, 422 плохой URL, 504 не отрендерилось).
|
||||||
|
|
||||||
|
## Страница /tools/clean-pdf
|
||||||
|
|
||||||
|
По образцу существующих инструментов тулбокса (реестр `TOOLS`, `ToolCard`, страница + клиентская форма):
|
||||||
|
|
||||||
|
1. **Форма**: поле URL, выбор формата и темы, кнопка «Скачать PDF», счётчик «использовано N из 100 в этом месяце».
|
||||||
|
2. **Блок «Zotero»**: персональный ключ (показать/скопировать/перегенерировать), пошаговая инструкция по Actions & Tags, готовый скрипт с уже подставленными адресом школы и ключом студента (адаптация скрипта референса: `SERVICE_URL = https://school.second-brain.ru`, endpoint `/api/pdf`).
|
||||||
|
3. **Блок «API»**: пример curl.
|
||||||
|
|
||||||
|
Дизайн — строго ДС-2 (перед вёрсткой перечитать `SBT/02-Стандарты/Дизайн-LMS/DESIGN.md`).
|
||||||
|
|
||||||
|
## Безопасность
|
||||||
|
|
||||||
|
Сервис скачивает произвольные URL изнутри прод-стенда — главный риск SSRF:
|
||||||
|
|
||||||
|
- Только `http://` и `https://`.
|
||||||
|
- Перед загрузкой — резолв DNS и блокировка приватных/зарезервированных диапазонов: localhost/127.x, 10.x, 172.16–31.x, 192.168.x, 169.254.x (метаданные облаков), ::1, fc00::/7, плюс docker-хостнеймы стенда (`db`, `app`, `browserless`).
|
||||||
|
- Внутри browserless — перехват сетевых запросов страницы (`context.route`/`context.routeWebSocket`): блокирует буквальные приватные/зарезервированные IP и хосты `localhost`/`*.local`/`*.internal`. **Честно про предел этой защиты:** `assertPublicUrl` резолвит DNS **ровно один раз — для исходного URL**, а browser-level фильтр хостнеймы не резолвит вовсе. Значит без проверки уходит **любой новый хостнейм после первого перехода**: цель редиректа, субресурс, `fetch` из JS, `ws://`. DNS-rebind для обхода даже не нужен — достаточно редиректа на внутреннее имя. Остаточный риск закрывается только на сетевом уровне — см. «Деплой». *(уточнено 20260912 аудитом кода)*
|
||||||
|
- Порт browserless наружу не публикуется, доступен только приложению по внутренней сети compose.
|
||||||
|
- Куки/учётные данные пользователя на целевую страницу не передаются.
|
||||||
|
- Санитайз имени файла в `Content-Disposition`.
|
||||||
|
- Лимиты: 100/мес (env) + burst 5/мин на пользователя; сверху — очередь browserless (2 конкурентных рендера).
|
||||||
|
- ⚠️ Follow-up (не блокирует релиз): оба счётчика лимита считают только **успешные** генерации (`ToolUsage` пишется после успешного рендера) — неудачные попытки (таймаут, 5xx с целевого сайта, зависший рендер) лимит не расходуют. Потенциальный ограниченный self-DoS повторными запросами к тяжёлым/неотвечающим URL. Рассмотреть подсчёт попыток, а не только успехов, отдельной задачей.
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
- `docker-compose.yml` (dev) и `docker-compose.prod.yml`: сервис `browserless` (образ `ghcr.io/browserless/chromium`), внутренняя сеть, лимиты памяти.
|
||||||
|
- env: `BROWSER_WS_URL`, `PDF_MONTHLY_LIMIT=100`, существующий `TOOLBOX_VISIBLE`.
|
||||||
|
- Prisma-миграция (`PdfApiKey`).
|
||||||
|
- Стандартная схема деплоя LMS: сборка на Hetzner → `docker save | ssh | docker load` на Hoster.kz; browserless на Hoster.kz — обычный `docker pull`.
|
||||||
|
- Hot-standby на Hetzner получает тот же compose (репликация БД уже покрывает `PdfApiKey` и `ToolUsage`).
|
||||||
|
|
||||||
|
> ⚠️ **Перед тем как поднять контейнер `browserless` на проде, обязательно захардить его сетевой egress** — заблокировать `169.254.0.0/16` (cloud-metadata) и RFC1918 (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`) на уровне хост-файрвола (`DOCKER-USER`) или выделенной internal-only docker-сети.
|
||||||
|
>
|
||||||
|
> **Гейт привязан к рендереру, а не к флагу** *(уточнено 20260912)*: `TOOLBOX_VISIBLE` прячет только страницы `/tools/*`, а роут `/api/pdf` лежит в `PUBLIC_ROUTES` и авторизуется сам — он станет рабочим для любого платного студента в момент появления `browserless` и `BROWSER_WS_URL`, ещё до поднятия флага. Именно это закрывает остаточный SSRF-риск из раздела «Безопасность»: browser-level фильтр его не закрывает, потому что не переразрешает хостнеймы.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
- Юнит: SSRF-валидатор (таблица адресов → допуск/блок), пайплайн Defuddle → HTML-шаблон на фикстурах.
|
||||||
|
- Интеграция на staging: рендер 3–5 реальных статей (лонгрид, статья с картинками, JS-тяжёлая страница), проверка лимитов и обоих путей авторизации.
|
||||||
|
- Zotero-скрипт — ручная проверка на живой библиотеке.
|
||||||
|
- Веб-форма — e2e через agent-browser в `--headed` (headless режет антиспам — известные грабли).
|
||||||
|
|
||||||
|
## Вне рамок (YAGNI)
|
||||||
|
|
||||||
|
- Отдельный домен `pdf.second-brain.ru` — не нужен, всё на `school.second-brain.ru`.
|
||||||
|
- Хранение сгенерированных PDF на сервере — файл отдаётся сразу и не сохраняется.
|
||||||
|
- Пер-курсовый гейтинг, тарифные лимиты, платные квоты — не сейчас.
|
||||||
|
- Освещение в рассылке/анонсы — после включения `TOOLBOX_VISIBLE` на проде, отдельной задачей.
|
||||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
transpilePackages: ["unified", "remark-parse"],
|
transpilePackages: ["unified", "remark-parse"],
|
||||||
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg"],
|
serverExternalPackages: ["@prisma/client", "@prisma/adapter-pg", "pg", "jsdom", "playwright-core", "defuddle"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+2775
-45
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -7,7 +7,8 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"type-check": "tsc --noEmit"
|
"type-check": "tsc --noEmit",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"seed": "ts-node --project tsconfig.json prisma/seed.ts"
|
"seed": "ts-node --project tsconfig.json prisma/seed.ts"
|
||||||
@@ -33,15 +34,22 @@
|
|||||||
"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",
|
||||||
|
"defuddle": "^0.19.1",
|
||||||
"disposable-email-domains": "^1.0.62",
|
"disposable-email-domains": "^1.0.62",
|
||||||
|
"force-graph": "^1.51.4",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"iconv-lite": "^0.7.2",
|
"iconv-lite": "^0.7.2",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"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",
|
||||||
|
"playwright-core": "^1.61.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-sanitize": "^6.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"resend": "^6.10.0",
|
"resend": "^6.10.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
@@ -52,6 +60,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/jsdom": "^28.0.3",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
@@ -62,6 +71,7 @@
|
|||||||
"prisma": "^7.6.0",
|
"prisma": "^7.6.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Тип второго мозга из freemium-квиза + UTM-метки для персонализации воронки
|
||||||
|
ALTER TABLE "User" ADD COLUMN "archetype" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "utmSource" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "utmMedium" TEXT;
|
||||||
|
ALTER TABLE "User" ADD COLUMN "utmCampaign" TEXT;
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Obsidian Wrapped — агрегаты прогона (без имён/текстов заметок)
|
||||||
|
CREATE TABLE "WrappedRun" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"period" TEXT NOT NULL DEFAULT 'year',
|
||||||
|
"noteCount" INTEGER NOT NULL,
|
||||||
|
"linkCount" INTEGER NOT NULL,
|
||||||
|
"tagCount" INTEGER NOT NULL,
|
||||||
|
"topTag" TEXT,
|
||||||
|
"archetype" TEXT,
|
||||||
|
"score" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"shareToken" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "WrappedRun_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "WrappedRun_shareToken_key" ON "WrappedRun"("shareToken");
|
||||||
|
CREATE INDEX "WrappedRun_userId_idx" ON "WrappedRun"("userId");
|
||||||
|
|
||||||
|
ALTER TABLE "WrappedRun" ADD CONSTRAINT "WrappedRun_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Obsidian Toolbox — факт использования генератора участником (аналитика)
|
||||||
|
CREATE TABLE "ToolUsage" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tool" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "ToolUsage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "ToolUsage_userId_idx" ON "ToolUsage"("userId");
|
||||||
|
CREATE INDEX "ToolUsage_tool_idx" ON "ToolUsage"("tool");
|
||||||
|
CREATE INDEX "ToolUsage_createdAt_idx" ON "ToolUsage"("createdAt");
|
||||||
|
|
||||||
|
ALTER TABLE "ToolUsage" ADD CONSTRAINT "ToolUsage_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- Чистый PDF — персональный API-ключ студента (Zotero/curl)
|
||||||
|
CREATE TABLE "PdfApiKey" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"key" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
CONSTRAINT "PdfApiKey_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "PdfApiKey_userId_key" ON "PdfApiKey"("userId");
|
||||||
|
CREATE UNIQUE INDEX "PdfApiKey_key_key" ON "PdfApiKey"("key");
|
||||||
|
|
||||||
|
ALTER TABLE "PdfApiKey" ADD CONSTRAINT "PdfApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Комментарии под уроками: метка «изменено» (ставится только при редактировании текста)
|
||||||
|
ALTER TABLE "LessonComment" ADD COLUMN "editedAt" TIMESTAMP(3);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Идентификатор клика Google Ads на пользователе.
|
||||||
|
-- Рекламу по обучающим запросам ведём на бесплатный вход в школу, поэтому
|
||||||
|
-- конверсию «регистрация» отправляем в Google серверно (src/lib/ads-conversion.ts),
|
||||||
|
-- а не тегом на странице: после регистрации человека сразу редиректит в кабинет.
|
||||||
|
--
|
||||||
|
-- IF NOT EXISTS — колонка уже заведена на проде вручную 20260813, чтобы включить
|
||||||
|
-- сбор до выката образа. На чистой базе миграция создаст её обычным порядком.
|
||||||
|
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gclid" TEXT;
|
||||||
+58
-3
@@ -25,6 +25,14 @@ model User {
|
|||||||
banExpires DateTime?
|
banExpires DateTime?
|
||||||
comment String?
|
comment String?
|
||||||
mustChangePassword Boolean @default(false)
|
mustChangePassword Boolean @default(false)
|
||||||
|
archetype String? // тип второго мозга из квиза: architect|gardener|librarian|pragmatist
|
||||||
|
utmSource String?
|
||||||
|
utmMedium String?
|
||||||
|
utmCampaign String?
|
||||||
|
// Идентификатор клика Google Ads. Нужен, чтобы отправить конверсию
|
||||||
|
// «регистрация» серверно: рекламный трафик ведём на бесплатный вход,
|
||||||
|
// и без него Google не узнает, какие клики дают студентов.
|
||||||
|
gclid String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -41,6 +49,52 @@ model User {
|
|||||||
questions StudentQuestion[]
|
questions StudentQuestion[]
|
||||||
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
|
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
|
||||||
questionMessages StudentQuestionMessage[]
|
questionMessages StudentQuestionMessage[]
|
||||||
|
wrappedRuns WrappedRun[]
|
||||||
|
toolUsages ToolUsage[]
|
||||||
|
pdfApiKey PdfApiKey?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obsidian Wrapped — агрегаты прогона (БЕЗ имён/текстов заметок; обработка волта 100% client-side)
|
||||||
|
model WrappedRun {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
period String @default("year") // year | quarter | all
|
||||||
|
noteCount Int
|
||||||
|
linkCount Int
|
||||||
|
tagCount Int
|
||||||
|
topTag String?
|
||||||
|
archetype String? // architect | gardener | librarian | pragmatist
|
||||||
|
score Int @default(0) // 0..100
|
||||||
|
shareToken String @unique // публичная ссылка /share/wrapped/[token]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obsidian Toolbox — факт использования генератора участником (для аналитики)
|
||||||
|
model ToolUsage {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
tool String // callout | frontmatter | theme | style-settings
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([tool])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Чистый PDF — персональный API-ключ студента (Zotero/curl)
|
||||||
|
model PdfApiKey {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
key String @unique // sbpdf_<random>, показывается студенту на /tools/clean-pdf
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
@@ -303,12 +357,13 @@ model HomeworkFeedback {
|
|||||||
// ─────────────────────────────────────────────
|
// ─────────────────────────────────────────────
|
||||||
|
|
||||||
model LessonComment {
|
model LessonComment {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
lessonId String
|
lessonId String
|
||||||
userId String
|
userId String
|
||||||
text String
|
text String
|
||||||
deleted Boolean @default(false)
|
deleted Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
editedAt DateTime?
|
||||||
parentId String?
|
parentId String?
|
||||||
|
|
||||||
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,66 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Generator: Adobe Illustrator 27.9.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||||
|
<svg version="1.1" id="Слой_3" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||||
|
viewBox="0 0 283.5 283.5" style="enable-background:new 0 0 283.5 283.5;" xml:space="preserve" fill="#323232">
|
||||||
|
<g>
|
||||||
|
<g>
|
||||||
|
<path d="M141.7,283.5C63.6,283.5,0,219.9,0,141.7C0,63.6,63.6,0,141.7,0s141.7,63.6,141.7,141.7S219.9,283.5,141.7,283.5z
|
||||||
|
M141.7,2.7c-76.7,0-139,62.4-139,139c0,76.7,62.4,139,139,139s139-62.4,139-139C280.8,65.1,218.4,2.7,141.7,2.7z"/>
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
<path d="M239.1,154.4c2.9-6.2,3.9-12.6,3.1-19c-1.2-8.9-5.8-15.8-10-20.4c0.5-5.1-0.3-10.2-2.4-15.3c-2.8-6.7-7-11.8-12.5-15.2
|
||||||
|
c-1.6-13.1-9.3-23.2-21.1-27.5c-4.1-9-12.3-15.4-22.2-17.5c-16.1-3.4-27.3,5.5-33.6,14.4c-5.6-8.6-16.1-17.7-31.4-14.5
|
||||||
|
c-9.7,2-17.3,8.5-20.8,17.5c-14.6,4.4-20.4,16.7-21.1,27.8c-5.9,3-10.3,8-13.1,14.8c-2.2,5.3-2.9,10.6-2,15.8
|
||||||
|
c-3.9,3.9-9.4,11-10.7,20.4c-0.9,6.6,0.4,13.2,3.9,19.5c-7.4,10.1-7.3,23.6,0.5,33.9c2.8,3.7,6.2,6.6,10.2,8.7
|
||||||
|
c-0.9,13.3,3.7,29.1,19.9,32.7c4.3,1,8.5,1.2,12.7,0.6c5.3,9.2,15.2,15.1,26,15.1c11,0,20.6-6,25.8-14.8
|
||||||
|
c5.9,8.9,16,14.8,27.4,14.8c10.8,0,21-5.4,27.1-14.2c3.6,0.3,7.2,0,11-0.9c16.9-3.8,22.7-19.1,22.5-33.1c3.4-2.1,6.4-4.9,8.8-8.2
|
||||||
|
C245.1,179.2,245.7,165.3,239.1,154.4z M134.9,146.4c0,5.5-5.6-8.7-9.8-12.9c-1.4-1.4-2.8-1.8-4.5-1.9c-1.1-3-2.8-7.5-5.2-9.8
|
||||||
|
c-3.4-3.2-1.5-7.8-1.4-10.2c0.1-2.4-3.5,1.8-3,5c0.5,3.2,0.6,4.7-0.6,9.7c-0.7,2.6,1.2,4.8,3.2,6.4c-1.9,0.4-4,0.7-6.2,0.7
|
||||||
|
c-8.1,0-9.1,14.9-7.8,17.5c1.3,2.6,5.8,8.1,5.8-0.6c0-8.7,12-12,17.5-7.9c5.5,4.1,4.9,15,5.2,22.4c0.3,7.4,6.9,1.6,6.9,11.3
|
||||||
|
c0,9.7-2,12.3-4,16.5c-1.9,4.2-17.8,7.1-14.6,9.4c3.2,2.3,18.5-1,18.5-1v12.2v0.1c0,18.1-21.1,21.7-28.9,20.7
|
||||||
|
c-7.8-1-11.6-10-9.4-14.9c2.3-4.9,10-2.9,11.6-5.5c1.6-2.6-5.5-0.3-11-5.8c-5.5-5.5-14.6-5.8-10.3-2.3c4.2,3.6,2.6,10.7-1.9,13.6
|
||||||
|
c-4.5,2.9-18.8,1.6-20.1-9.4c-1.3-11,4.2-14.6,6.1-18.1c1.9-3.6-2.3-14.2-2.3-14.2s-2.3,11-5.8,11.3c-3.6,0.3-11.6-5.5-11.9-12.9
|
||||||
|
c-0.2-7.4,3.8-10.3,4.7-13.3c1-2.9-1.9-4.9-4.5-20.1c-2.6-15.3,18.4-19.3,20.4-17.4c1.9,1.9,12.6,6.5,10.3,3.2
|
||||||
|
c-2.3-3.2-2.9-11-4.2-18.1c-1.3-7.1-7.4-4.2-8.7-0.3c-1.3,3.9-6.8,18.1-8.1,4.5c-1.3-13.6,8.4-23,12-25.6c3.6-2.6,3.2-8.1,6.5-15
|
||||||
|
c3.2-7,11.6-9.5,16.8-8.6c5.2,1,13.3-1.6,13.3-1.6c-1.9-3.2-10-4.5-11.3-4.9c-1.3-0.3,3.6-11,17.5-11.3
|
||||||
|
c13.9-0.3,18.1,11.6,19.1,18.8c1,7.1-6.5,10.3-6.5,10.3c5.5,3.9,4.5,23.6,4.5,23.6c0.4,12.3-11,14.9-11,14.9s13,5.2,13,9.7V146.4z
|
||||||
|
M196.7,100.9c3,0,5.5,2.5,5.5,5.5c0,3-2.5,5.5-5.5,5.5c-3,0-5.5-2.5-5.5-5.5C191.2,103.4,193.7,100.9,196.7,100.9z M169.2,75.6
|
||||||
|
c0,3-2.5,5.5-5.5,5.5c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5C166.8,70.1,169.2,72.6,169.2,75.6z M188.2,199.8
|
||||||
|
c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5c3,0,5.5,2.5,5.5,5.5C193.6,197.3,191.2,199.8,188.2,199.8z M230.4,184.7
|
||||||
|
c-2.3,3-5.1,5.5-8.4,7.1l-2.4,1.3l0.2,2.7c0.4,5.4,0.3,23.4-15.8,27c-3.7,0.8-7.2,1-10.7,0.5l-1.8-0.3v-17
|
||||||
|
c5.1-1.5,8.8-6.2,8.8-11.7c0-6.7-5.5-12.2-12.2-12.2s-12.2,5.5-12.2,12.2c0,5.6,3.7,10.3,8.8,11.7v24.8c-4.5,4.4-10.6,6.9-17,6.9
|
||||||
|
c-7.9,0-15-3.8-19.4-9.6l22.2-22.2v-26.8c5.1-1.5,8.8-6.2,8.8-11.7c0-6.7-5.5-12.2-12.2-12.2c-6.7,0-12.2,5.5-12.2,12.2
|
||||||
|
c0,5.6,3.7,10.3,8.8,11.7v24L144.9,222c-0.3-0.7-0.5-1.3-0.7-2c0.2-1.2,0.3-2.5,0.3-3.8v-69.4l15.3-15.3c4.6,2.6,10.6,1.9,14.5-2
|
||||||
|
c4.8-4.8,4.8-12.5,0-17.3c-4.8-4.8-12.5-4.8-17.3,0c-3.9,3.9-4.6,9.9-2,14.5l-10.5,10.5v-16.5v-0.3V63.3c2.7-5.5,8-12.4,15.9-15
|
||||||
|
l0,15.6c-5.1,1.5-8.8,6.2-8.8,11.7c0,6.7,5.5,12.2,12.2,12.2c6.7,0,12.2-5.5,12.2-12.2c0-5.6-3.7-10.3-8.8-11.7l0-16.7
|
||||||
|
c1.6,0,3.3,0.1,5.1,0.5c7.9,1.7,14.1,6.9,16.9,14.2l0.7,1.9l2,0.6c0.5,0.2,1,0.4,1.5,0.5l0,29.8c-5.1,1.5-8.8,6.2-8.8,11.7
|
||||||
|
c0,6.7,5.5,12.2,12.2,12.2c6.7,0,12.2-5.5,12.2-12.2c0-5.6-3.7-10.3-8.8-11.7l0-26.1c7.3,5.8,8.8,14.5,9,18.7l0.1,2.4l2.1,1.1
|
||||||
|
c4.7,2.4,8.3,6.5,10.6,12.1c1.8,4.3,2.3,8.6,1.6,12.8l-0.4,2.1l1.5,1.5c3.5,3.6,8,9.5,9.1,17.2c0,0.3,0.1,0.6,0.1,0.9h-28.6
|
||||||
|
c-1.5-5.1-6.2-8.8-11.7-8.8c-6.7,0-12.2,5.5-12.2,12.2c0,6.7,5.5,12.2,12.2,12.2c5.6,0,10.3-3.7,11.7-8.8h28.3
|
||||||
|
c-0.5,2.9-1.5,5.7-3.1,8.5l-1.3,2.3l1.6,2.2c2.2,3,3.6,6.4,4.1,9.8h-14.4c-1.5-5.1-6.2-8.8-11.7-8.8c-6.7,0-12.2,5.5-12.2,12.2
|
||||||
|
c0,6.7,5.5,12.2,12.2,12.2c5.6,0,10.3-3.7,11.7-8.8h14.4C234.5,177.5,233,181.3,230.4,184.7z M167.2,172.9c-3,0-5.5-2.5-5.5-5.5
|
||||||
|
c0-3,2.5-5.5,5.5-5.5c3,0,5.5,2.5,5.5,5.5C172.6,170.4,170.2,172.9,167.2,172.9z M161.8,117c2.1-2.1,5.6-2.1,7.7,0
|
||||||
|
c2.1,2.1,2.1,5.6,0,7.7c-2.1,2.1-5.6,2.1-7.7,0C159.7,122.6,159.7,119.1,161.8,117z M199.1,140.8c0,3-2.5,5.5-5.5,5.5
|
||||||
|
c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5C196.7,135.3,199.1,137.8,199.1,140.8z M214.4,170.4c0,3-2.5,5.5-5.5,5.5
|
||||||
|
c-3,0-5.5-2.5-5.5-5.5c0-3,2.5-5.5,5.5-5.5C211.9,164.9,214.4,167.4,214.4,170.4z"/>
|
||||||
|
<path d="M125.6,90.7c-0.8-4.9,0.5-16.5-3.9-15.5c-4.4,1-1.6,6.4-2.6,7.8c-2.4,3.2-0.5,3.7,2.7,7.1c3.2,3.4,3.1,10.5-1,12.8
|
||||||
|
c-4,2.3-3.8,7.6-1.8,6.8c2.1-0.8,5-3.7,6.4-7.4C127,98.5,126.4,95.6,125.6,90.7z"/>
|
||||||
|
<path d="M71.9,169.5c-1.8-2.9-3.3,3.7-6.7-0.2c-3.4-3.9-4.4-4.7-6-3.2c-3.9,3.5-1.9,8,1.5,10.9c3.4,2.9,2.9,0,2.7-3.1
|
||||||
|
c-0.2-3.1,1.8-1.8,5.2-0.6C72,174.3,73.8,172.4,71.9,169.5z"/>
|
||||||
|
<path d="M114.2,186.3c-3.4,2.6-5,6.1-1.5,6c3.6-0.2,3.2-5.3,6.1-4.7c2.9,0.6,1.7,5.7,4.7,3.6c3-2.1,5.4-11.1,2.8-14.2
|
||||||
|
c-2.6-3.1-4.8,3.2-5.7,3.4C116.7,181.2,117.6,183.7,114.2,186.3z"/>
|
||||||
|
<path d="M111,222.4c1.2,1.7,3.4,3.6,9.5,1.2c6.1-2.4,2.3-8.5-1.5-6.5C114.2,219.6,109.8,220.7,111,222.4z"/>
|
||||||
|
<path d="M116.6,98.2c-1.9-0.7-8.7-7.8-8.7-12.4c0-4.6-5.8,5.8-6.1,8.7c-0.2,2.9-5.3,5.8-9.6,7.3c-4.3,1.5-6.6,5.3-6.4,5.8
|
||||||
|
c0.2,0.5,3.4-0.2,6.4,0c3,0.2,2.6,6.5,2.4,13.8c-0.2,7.3-3.2,18.7-14.6,17.2c-11.4-1.5-11.4,7.8-13.3,15.8c-1.9,8,9,10.4,11.2,6.8
|
||||||
|
c2.2-3.6,6.5-7.3,11.2-4c0,0,3.4-5.7,0-7.6c-3.4-1.9-13.1,5.8-13.6,1c-0.5-4.9,4.6-3.4,14.1-8.2c9.5-4.8,9.2-19.2,10.5-22.6
|
||||||
|
c1.3-3.4-1.1-10.9,0-14.3c1.1-3.4,4.7-6.1,8.6-5.1C112.5,101.3,118.5,98.9,116.6,98.2z"/>
|
||||||
|
<path d="M122.4,59.4c-4.2-5.4-4.1,5.8-1.2,9C124.1,71.5,125.8,63.7,122.4,59.4z"/>
|
||||||
|
<path d="M86.3,95c3.2-3.2,1.3-3.4,6.7-4.6c5.4-1.2,4.4-4.9,7-7.8c2.6-2.9,8.6-9.2,3.5-10.2c-5.1-1-12.6-4.6-13.1,0
|
||||||
|
c-0.5,4.6,5.6,1.9,7.5,3.4c1.9,1.5-3.3,7.1-4.9,8.8c-1.6,1.6-6.7,3.3-6.7,3.3C82.4,91.9,83.1,98.2,86.3,95z"/>
|
||||||
|
<path d="M83.4,171.2c-0.5,4.9,8.8,9,8.8,9s0.3,0.2,0.7,0.6c-2.7,0.8-5.8,2.5-6.2,3.7c-0.6,1.9-9.1,10.2-12.5,12.2
|
||||||
|
c-3.4,2-2.7,7.7,1.1,12.2c3.9,4.5,7.6,5,8.4,3.6c0.8-1.5-5.6-4.2-6.7-8.9c-1.1-4.7,5.2-8.2,8.1-9.9c2.9-1.6,6.5-10.7,13.4-8
|
||||||
|
c2.4,1.9,4.1,9.5,4.1,9.5c2.2,3.2,2.7,1.2,1.9-4.1c-0.7-5.3,0-8.7,3.9-11.2c3.9-2.4,6.1-7,7-13.1c1-6.1,2.2-8.5,0-9.5
|
||||||
|
c-2.2-1.1-2.4,8.8-4.6,12.6c-2.1,3.8-6.8,5.4-14.1,6.1C89.4,176.8,83.8,166.3,83.4,171.2z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 6.5 KiB |
@@ -0,0 +1,133 @@
|
|||||||
|
-- ⚠️ СПРАВОЧНЫЙ ФАЙЛ — НЕ запускать `psql -f` на проде!
|
||||||
|
-- CUID здесь синтетические (могут не пройти валидацию схемы Prisma).
|
||||||
|
-- Создавать курсы biz-brain-base/premium + module-* ТОЛЬКО через LMS Admin UI.
|
||||||
|
-- Этот SQL — справка по составу/связям курсов.
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- WS6: Создание курсов-носителей «Второй мозг для бизнеса»
|
||||||
|
-- ============================================================
|
||||||
|
-- ВАЖНО: Выполнять НА ПРОДЕ только после проверки на staging.
|
||||||
|
-- Запускать через SSH: psql <db_url> -f create_biz_brain_courses.sql
|
||||||
|
-- Идемпотентно: INSERT IGNORE по slug (уникальный ключ).
|
||||||
|
--
|
||||||
|
-- Курсы-носители (5 штук):
|
||||||
|
-- biz-brain-base — основной курс (ZIP хранилища + видео-онбординг)
|
||||||
|
-- biz-brain-premium — премиум-раздел (консультации, инструкции по расширениям)
|
||||||
|
-- module-caldav — расширение CalDAV-календарь
|
||||||
|
-- module-ai-pdf — расширение AI-разбор PDF
|
||||||
|
-- module-legacy — расширение Legacy-миграция
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- Функция для генерации cuid-подобного ID (через gen_random_uuid как fallback)
|
||||||
|
-- В реальности Prisma генерирует cuid автоматически — используем это свойство.
|
||||||
|
-- Если у вас Postgres 14+: gen_random_uuid() дает UUID, но Prisma ожидает cuid.
|
||||||
|
-- Безопаснее создать через Prisma API или LMS-Admin.
|
||||||
|
|
||||||
|
-- Проверяем что slug ещё не существует перед вставкой
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- biz-brain-base
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'biz-brain-base') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clbizbrainbase0000001',
|
||||||
|
'biz-brain-base',
|
||||||
|
'Второй мозг для бизнеса — Базовый',
|
||||||
|
'Готовое хранилище Obsidian для собственника: CRM-воронка, финансовый кокпит, журнал решений, витрина команды. Разовый платёж, бессрочный доступ.',
|
||||||
|
false, -- опубликовать вручную после наполнения контентом
|
||||||
|
false,
|
||||||
|
100,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс biz-brain-base';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс biz-brain-base уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- biz-brain-premium
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'biz-brain-premium') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clbizbrainprem0000002',
|
||||||
|
'biz-brain-premium',
|
||||||
|
'Второй мозг для бизнеса — Премиум (инструкции и консультации)',
|
||||||
|
'Раздел для премиум-покупателей: инструкции по установке расширений, запись на 2 персональные консультации, ответы на частые вопросы.',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
101,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс biz-brain-premium';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс biz-brain-premium уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- module-caldav
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-caldav') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clmodulecaldav00000003',
|
||||||
|
'module-caldav',
|
||||||
|
'Расширение: CalDAV-календарь',
|
||||||
|
'Двусторонняя синхронизация задач и событий Obsidian с Google Calendar / Apple Calendar через CalDAV. Плагин Full Calendar Remastered v0.13.2.',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
200,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс module-caldav';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс module-caldav уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- module-ai-pdf
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-ai-pdf') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clmoduleaipdf000000004',
|
||||||
|
'module-ai-pdf',
|
||||||
|
'Расширение: AI-разбор PDF и исследований',
|
||||||
|
'Промпты А1/А2/А3 для структурированного разбора PDF-документов, статей и исследований с помощью AI. Включает эталонный прогон и демо-PDF.',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
201,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс module-ai-pdf';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс module-ai-pdf уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- module-legacy
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM "Course" WHERE slug = 'module-legacy') THEN
|
||||||
|
INSERT INTO "Course" (id, slug, title, description, published, "allowAudio", "order", "createdAt", "updatedAt")
|
||||||
|
VALUES (
|
||||||
|
'clmodulelegacy00000005',
|
||||||
|
'module-legacy',
|
||||||
|
'Расширение: Legacy-миграция из Evernote / Notion',
|
||||||
|
'Пошаговые инструкции и чеклист пост-обработки для переноса данных из Evernote и Notion в хранилище «Второй мозг для бизнеса».',
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
202,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
RAISE NOTICE 'Создан курс module-legacy';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Курс module-legacy уже существует — пропуск';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Проверка результата
|
||||||
|
SELECT slug, title, published, "order" FROM "Course"
|
||||||
|
WHERE slug IN ('biz-brain-base', 'biz-brain-premium', 'module-caldav', 'module-ai-pdf', 'module-legacy')
|
||||||
|
ORDER BY "order";
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ============================================================
|
||||||
|
# WS6: Проверка /api/internal/grant для biz-brain-* курсов
|
||||||
|
# ============================================================
|
||||||
|
# Использование:
|
||||||
|
# LMS_URL=https://school.second-brain.ru \
|
||||||
|
# LMS_SECRET=<secret_из_env> \
|
||||||
|
# TEST_EMAIL=laukhin@dementiy.com \
|
||||||
|
# bash verify_biz_brain_grant.sh
|
||||||
|
#
|
||||||
|
# Проверяет:
|
||||||
|
# 1. Grant base-курса (biz-brain-base)
|
||||||
|
# 2. Grant premium-бандла (все 5 курсов)
|
||||||
|
# 3. Идемпотентность (повтор того же order_id)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LMS_URL="${LMS_URL:-https://school.second-brain.ru}"
|
||||||
|
LMS_SECRET="${LMS_SECRET:?Задай переменную LMS_SECRET}"
|
||||||
|
TEST_EMAIL="${TEST_EMAIL:-laukhin@dementiy.com}"
|
||||||
|
|
||||||
|
echo "=== WS6: Проверка grant-эндпоинта LMS ==="
|
||||||
|
echo " LMS: $LMS_URL"
|
||||||
|
echo " Email: $TEST_EMAIL"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Тест 1: Базовый бандл ────────────────────────────────────────────────────
|
||||||
|
echo "[1] Grant biz-brain-base..."
|
||||||
|
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-internal-secret: $LMS_SECRET" \
|
||||||
|
-d '{
|
||||||
|
"email": "'"$TEST_EMAIL"'",
|
||||||
|
"name": "WS6 Test",
|
||||||
|
"course_slugs": ["biz-brain-base"],
|
||||||
|
"order_id": "ws6-test-base-001",
|
||||||
|
"payment_system": "robokassa"
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo " Ответ: $RESPONSE"
|
||||||
|
|
||||||
|
GRANTED=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('granted',''))")
|
||||||
|
if [[ "$GRANTED" == *"biz-brain-base"* ]]; then
|
||||||
|
echo " ✅ biz-brain-base выдан"
|
||||||
|
else
|
||||||
|
IDEMPOTENT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('idempotent',''))")
|
||||||
|
if [[ "$IDEMPOTENT" == "True" ]]; then
|
||||||
|
echo " ℹ️ Идемпотентный ответ (уже выдан ранее)"
|
||||||
|
else
|
||||||
|
echo " ❌ ОШИБКА: biz-brain-base не выдан. Ответ: $RESPONSE"
|
||||||
|
echo " Убедись что курс biz-brain-base создан в LMS!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Тест 2: Премиум-бандл ────────────────────────────────────────────────────
|
||||||
|
echo "[2] Grant biz-brain-premium (5 курсов)..."
|
||||||
|
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-internal-secret: $LMS_SECRET" \
|
||||||
|
-d '{
|
||||||
|
"email": "'"$TEST_EMAIL"'",
|
||||||
|
"name": "WS6 Test",
|
||||||
|
"course_slugs": ["biz-brain-base", "biz-brain-premium", "module-caldav", "module-ai-pdf", "module-legacy"],
|
||||||
|
"order_id": "ws6-test-premium-001",
|
||||||
|
"payment_system": "robokassa"
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo " Ответ: $RESPONSE"
|
||||||
|
|
||||||
|
UNRESOLVED=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('unresolved',[]))")
|
||||||
|
if [[ "$UNRESOLVED" == "[]" ]]; then
|
||||||
|
echo " ✅ Все 5 курсов разрешены и выданы"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Не разрешены: $UNRESOLVED"
|
||||||
|
echo " Создай недостающие курсы в LMS (slug'и должны точно совпадать)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Тест 3: Идемпотентность ──────────────────────────────────────────────────
|
||||||
|
echo "[3] Повторный grant того же order_id (должен вернуть idempotent=true)..."
|
||||||
|
RESPONSE=$(curl -sf -X POST "$LMS_URL/api/internal/grant" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "x-internal-secret: $LMS_SECRET" \
|
||||||
|
-d '{
|
||||||
|
"email": "'"$TEST_EMAIL"'",
|
||||||
|
"name": "WS6 Test",
|
||||||
|
"course_slugs": ["biz-brain-base"],
|
||||||
|
"order_id": "ws6-test-base-001",
|
||||||
|
"payment_system": "robokassa"
|
||||||
|
}')
|
||||||
|
|
||||||
|
IDEMPOTENT=$(echo "$RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('idempotent',''))" 2>/dev/null || echo "")
|
||||||
|
if [[ "$IDEMPOTENT" == "True" ]]; then
|
||||||
|
echo " ✅ Идемпотентность работает (повторный вызов вернул idempotent=true)"
|
||||||
|
else
|
||||||
|
echo " ⚠️ Ожидался idempotent=true, получено: $RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Проверка завершена ==="
|
||||||
|
echo ""
|
||||||
|
echo "Следующий шаг: опубликовать курсы в admin (published=true) после наполнения контентом."
|
||||||
|
echo " school.second-brain.ru/admin/courses"
|
||||||
@@ -2,13 +2,20 @@ import { redirect } from "next/navigation";
|
|||||||
import { getSettings } from "@/lib/settings";
|
import { getSettings } from "@/lib/settings";
|
||||||
import { RegisterForm } from "./register-form";
|
import { RegisterForm } from "./register-form";
|
||||||
|
|
||||||
export default async function RegisterPage() {
|
export default async function RegisterPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
|
||||||
|
}) {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
|
||||||
if (settings.registrationEnabled !== "true") {
|
if (settings.registrationEnabled !== "true") {
|
||||||
redirect("/login?notice=registration_closed");
|
redirect("/login?notice=registration_closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sp = await searchParams;
|
||||||
|
const str = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v) ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
|
||||||
<div className="w-full max-w-sm">
|
<div className="w-full max-w-sm">
|
||||||
@@ -26,6 +33,11 @@ export default async function RegisterPage() {
|
|||||||
privacyPolicyUrl={settings.privacyPolicyUrl}
|
privacyPolicyUrl={settings.privacyPolicyUrl}
|
||||||
termsUrl={settings.termsUrl}
|
termsUrl={settings.termsUrl}
|
||||||
offerUrl={settings.offerUrl}
|
offerUrl={settings.offerUrl}
|
||||||
|
archetype={str(sp.archetype)}
|
||||||
|
utmSource={str(sp.utm_source)}
|
||||||
|
utmMedium={str(sp.utm_medium)}
|
||||||
|
utmCampaign={str(sp.utm_campaign)}
|
||||||
|
gclid={str(sp.gclid)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
ym?: (counterId: number, action: string, goal: string) => void;
|
||||||
turnstile?: {
|
turnstile?: {
|
||||||
render: (
|
render: (
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
@@ -25,9 +26,14 @@ interface Props {
|
|||||||
privacyPolicyUrl: string;
|
privacyPolicyUrl: string;
|
||||||
termsUrl: string;
|
termsUrl: string;
|
||||||
offerUrl: string;
|
offerUrl: string;
|
||||||
|
archetype?: string;
|
||||||
|
utmSource?: string;
|
||||||
|
utmMedium?: string;
|
||||||
|
utmCampaign?: string;
|
||||||
|
gclid?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: Props) {
|
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl, archetype, utmSource, utmMedium, utmCampaign, gclid }: Props) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
@@ -118,6 +124,11 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
|
|||||||
password,
|
password,
|
||||||
website: honeypot,
|
website: honeypot,
|
||||||
cfTurnstileResponse: turnstileToken,
|
cfTurnstileResponse: turnstileToken,
|
||||||
|
archetype,
|
||||||
|
utmSource,
|
||||||
|
utmMedium,
|
||||||
|
utmCampaign,
|
||||||
|
gclid,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
@@ -153,6 +164,11 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Яндекс.Метрика: цель «бесплатная регистрация»
|
||||||
|
const mc = Number(process.env.NEXT_PUBLIC_METRIKA_COUNTER_ID);
|
||||||
|
if (mc && typeof window !== "undefined" && typeof window.ym === "function") {
|
||||||
|
window.ym(mc, "reachGoal", "signup_free");
|
||||||
|
}
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
"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, parentId?: 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";
|
|
||||||
const isCurator = session.user.role === "curator";
|
|
||||||
if (!isAdmin && !isCurator) {
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parentId) {
|
|
||||||
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
|
||||||
const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } });
|
|
||||||
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.lessonComment.create({
|
|
||||||
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
|
||||||
});
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ import { HomeworkSection } from "@/components/student/homework-section";
|
|||||||
import { QuizSection } from "@/components/student/quiz-section";
|
import { QuizSection } from "@/components/student/quiz-section";
|
||||||
import { LessonComments } from "@/components/student/lesson-comments";
|
import { LessonComments } from "@/components/student/lesson-comments";
|
||||||
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
||||||
|
import { TripwireGateBanner } from "@/components/student/tripwire-gate-banner";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ slug: string; lessonId: string }>;
|
params: Promise<{ slug: string; lessonId: string }>;
|
||||||
@@ -90,6 +91,8 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
if (!lesson || lesson.module.course.slug !== slug) notFound();
|
if (!lesson || lesson.module.course.slug !== slug) notFound();
|
||||||
|
|
||||||
const isCompleted = !!progress;
|
const isCompleted = !!progress;
|
||||||
|
const showTripwireGate =
|
||||||
|
!isAdmin && isCompleted && lessonId === process.env.WOW_MOMENT_LESSON_ID;
|
||||||
|
|
||||||
// Build ordered flat list of all lessons for prev/next
|
// Build ordered flat list of all lessons for prev/next
|
||||||
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
|
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
|
||||||
@@ -146,9 +149,8 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
{lesson.files.map((file) => (
|
{lesson.files.map((file) => (
|
||||||
<a
|
<a
|
||||||
key={file.id}
|
key={file.id}
|
||||||
href={file.url}
|
href={`/api/lesson-files/${file.id}/download`}
|
||||||
target="_blank"
|
download={file.name}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
||||||
style={{ border: "2px solid var(--border)" }}
|
style={{ border: "2px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
@@ -267,6 +269,8 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showTripwireGate && <TripwireGateBanner />}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
{session && (
|
{session && (
|
||||||
<div
|
<div
|
||||||
@@ -285,6 +289,7 @@ export default async function LessonPage({ params }: Props) {
|
|||||||
comments={comments}
|
comments={comments}
|
||||||
currentUserId={session.user.id}
|
currentUserId={session.user.id}
|
||||||
currentUserRole={session.user.role ?? "student"}
|
currentUserRole={session.user.role ?? "student"}
|
||||||
|
imageHost={process.env.S3_CDN_URL ?? ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,22 +3,20 @@ import { auth } from "@/lib/auth";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ArchetypeBanner } from "@/components/student/archetype-banner";
|
||||||
// Продаваемая линейка курсов → лендинг продукта. Курсы из этого списка,
|
import { ToolboxPromoCard } from "@/components/tools/ToolboxPromoCard";
|
||||||
// которых нет у студента, показываются на дашборде заблюренными карточками
|
import { StoreShowcase } from "@/components/student/store-showcase";
|
||||||
// со ссылкой на лендинг (апселл). Легаси/обычные версии сюда не входят.
|
import { STORE_CATALOG } from "@/lib/store-catalog";
|
||||||
const STORE_LINKS: Record<string, string> = {
|
|
||||||
"second-brain-vault": "https://second-brain.ru/sb-vault",
|
|
||||||
"obsidian-full": "https://obsidian.second-brain.ru/",
|
|
||||||
"claude-obsidian": "https://second-brain.ru/claude-obsidian",
|
|
||||||
"zotero-full": "https://zotero.second-brain.ru/",
|
|
||||||
"obsidian-ai": "https://obsidianai.second-brain.ru/",
|
|
||||||
};
|
|
||||||
|
|
||||||
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 dbUser = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { archetype: true },
|
||||||
|
});
|
||||||
|
|
||||||
const enrollments = await prisma.courseEnrollment.findMany({
|
const enrollments = await prisma.courseEnrollment.findMany({
|
||||||
where: { userId: session.user.id },
|
where: { userId: session.user.id },
|
||||||
include: {
|
include: {
|
||||||
@@ -63,7 +61,7 @@ export default async function StudentDashboard() {
|
|||||||
"zotero-full": "zotero", // ВВ Zotero ← обычный Zotero
|
"zotero-full": "zotero", // ВВ Zotero ← обычный Zotero
|
||||||
};
|
};
|
||||||
const storeCourses = await prisma.course.findMany({
|
const storeCourses = await prisma.course.findMany({
|
||||||
where: { slug: { in: Object.keys(STORE_LINKS) }, published: true },
|
where: { slug: { in: Object.keys(STORE_CATALOG) }, published: true },
|
||||||
select: { id: true, slug: true, title: true, description: true, coverImage: true },
|
select: { id: true, slug: true, title: true, description: true, coverImage: true },
|
||||||
});
|
});
|
||||||
const locked = storeCourses.filter(
|
const locked = storeCourses.filter(
|
||||||
@@ -79,6 +77,8 @@ export default async function StudentDashboard() {
|
|||||||
{active.length} активных курсов
|
{active.length} активных курсов
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{dbUser?.archetype && <ArchetypeBanner archetype={dbUser.archetype} />}
|
||||||
|
|
||||||
{active.length === 0 ? (
|
{active.length === 0 ? (
|
||||||
<div className="card-aubade p-12 text-center">
|
<div className="card-aubade p-12 text-center">
|
||||||
<p className="text-4xl mb-3">📚</p>
|
<p className="text-4xl mb-3">📚</p>
|
||||||
@@ -159,58 +159,19 @@ export default async function StudentDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{locked.length > 0 && (
|
{process.env.TOOLBOX_VISIBLE === "true" && (
|
||||||
<div className="mt-10">
|
<div className="mt-8">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
<ToolboxPromoCard />
|
||||||
Другие курсы Second Brain
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
|
||||||
{locked.map((course) => (
|
|
||||||
<a
|
|
||||||
key={course.id}
|
|
||||||
href={STORE_LINKS[course.slug]}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="card-aubade p-0 overflow-hidden flex flex-col relative group"
|
|
||||||
>
|
|
||||||
{/* Overlay: замок + призыв, поверх размытого контента */}
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 transition-opacity"
|
|
||||||
style={{ backgroundColor: "color-mix(in srgb, var(--background) 55%, transparent)" }}
|
|
||||||
>
|
|
||||||
<span className="text-2xl">🔒</span>
|
|
||||||
<span
|
|
||||||
className="text-sm font-bold px-4 py-2"
|
|
||||||
style={{ border: "2px solid var(--foreground)", backgroundColor: "var(--accent)", color: "var(--foreground)" }}
|
|
||||||
>
|
|
||||||
Открыть курс →
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Размытый контент карточки */}
|
|
||||||
<div className="flex flex-col flex-1" style={{ filter: "blur(1.8px)", opacity: 0.82, pointerEvents: "none" }}>
|
|
||||||
{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-lg leading-tight">{course.title}</h2>
|
|
||||||
{course.description && (
|
|
||||||
<p className="text-sm line-clamp-3" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
{course.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{locked.length > 0 && (
|
||||||
|
<StoreShowcase
|
||||||
|
courses={locked}
|
||||||
|
buyer={{ name: session.user.name, email: session.user.email }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{expired.length > 0 && (
|
{expired.length > 0 && (
|
||||||
<div className="mt-10">
|
<div className="mt-10">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ export default async function StudentLayout({ children }: { children: React.Reac
|
|||||||
{schoolName}
|
{schoolName}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/wrapped" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Wrapped
|
||||||
|
</Link>
|
||||||
|
{process.env.TOOLBOX_VISIBLE === "true" && (
|
||||||
|
<Link href="/tools" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Инструменты
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
<Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Вопросы
|
Вопросы
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { buildBases, type BaseViewType, type BaseFilterScope } from "@/lib/tools/bases";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const VIEW_TYPES: BaseViewType[] = ["table", "cards", "list", "map"];
|
||||||
|
const SCOPES: { id: BaseFilterScope; label: string }[] = [
|
||||||
|
{ id: "none", label: "без фильтра" },
|
||||||
|
{ id: "folder", label: "по папке" },
|
||||||
|
{ id: "tag", label: "по тегу" },
|
||||||
|
{ id: "property", label: "по свойству (выражение)" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
const SCOPE_PLACEHOLDER: Record<BaseFilterScope, string> = {
|
||||||
|
none: "",
|
||||||
|
folder: "Projects",
|
||||||
|
tag: "#проект",
|
||||||
|
property: 'status == "done"',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BasesForm() {
|
||||||
|
const [viewType, setViewType] = useState<BaseViewType>("table");
|
||||||
|
const [viewName, setViewName] = useState("Мои заметки");
|
||||||
|
const [propsText, setPropsText] = useState("file.name, status");
|
||||||
|
const [filterScope, setFilterScope] = useState<BaseFilterScope>("folder");
|
||||||
|
const [filterValue, setFilterValue] = useState("Projects");
|
||||||
|
const [sortBy, setSortBy] = useState("file.name");
|
||||||
|
const [sortDir, setSortDir] = useState<"ASC" | "DESC">("ASC");
|
||||||
|
const [groupBy, setGroupBy] = useState("");
|
||||||
|
const [limit, setLimit] = useState("");
|
||||||
|
|
||||||
|
const yaml = useMemo(
|
||||||
|
() => buildBases({
|
||||||
|
viewType, viewName,
|
||||||
|
properties: propsText.split(",").map((s) => s.trim()).filter(Boolean),
|
||||||
|
filterScope, filterValue,
|
||||||
|
sortBy, sortDir, groupBy,
|
||||||
|
limit: limit ? Number(limit) : undefined,
|
||||||
|
}),
|
||||||
|
[viewType, viewName, propsText, filterScope, filterValue, sortBy, sortDir, groupBy, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Тип представления
|
||||||
|
<select className={field} style={fieldStyle} value={viewType} onChange={(e) => setViewType(e.target.value as BaseViewType)}>
|
||||||
|
{VIEW_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Имя представления
|
||||||
|
<input className={field} style={fieldStyle} value={viewName} onChange={(e) => setViewName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Свойства / колонки (через запятую)
|
||||||
|
<input className={field} style={fieldStyle} placeholder="file.name, status, …" value={propsText} onChange={(e) => setPropsText(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<label className="text-sm flex-1">Фильтр
|
||||||
|
<select className={field} style={fieldStyle} value={filterScope} onChange={(e) => setFilterScope(e.target.value as BaseFilterScope)}>
|
||||||
|
{SCOPES.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{filterScope !== "none" && (
|
||||||
|
<input className={field} style={fieldStyle} placeholder={SCOPE_PLACEHOLDER[filterScope]} value={filterValue} onChange={(e) => setFilterValue(e.target.value)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<label className="text-sm flex-1">Сортировка (свойство)
|
||||||
|
<input className={field} style={fieldStyle} placeholder="не сортировать" value={sortBy} onChange={(e) => setSortBy(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<select className={field} style={{ ...fieldStyle, width: "auto" }} value={sortDir} onChange={(e) => setSortDir(e.target.value as "ASC" | "DESC")}>
|
||||||
|
<option value="ASC">ASC</option>
|
||||||
|
<option value="DESC">DESC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm">Группировка (свойство)
|
||||||
|
<input className={field} style={fieldStyle} placeholder="без группировки" value={groupBy} onChange={(e) => setGroupBy(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Лимит
|
||||||
|
<input type="number" min={0} className={field} style={fieldStyle} placeholder="без лимита" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={yaml} tool="bases" label="Содержимое .base-файла" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { BasesForm } from "./BasesForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор Bases — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function BasesToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор Bases</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Базы Obsidian: представление-таблица с фильтрами и сортировкой (.base-файл).</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<BasesForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { buildCalloutCss } from "@/lib/tools/callout";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
export function CalloutForm() {
|
||||||
|
const [id, setId] = useState("idea");
|
||||||
|
const [title, setTitle] = useState("Идея");
|
||||||
|
const [color, setColor] = useState("#7C3AED");
|
||||||
|
const [icon, setIcon] = useState("lightbulb");
|
||||||
|
|
||||||
|
const result = useMemo(() => {
|
||||||
|
try { return buildCalloutCss({ id, title, color, icon }); }
|
||||||
|
catch { return null; }
|
||||||
|
}, [id, title, color, icon]);
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Тип (id)
|
||||||
|
<input className={field} style={fieldStyle} value={id} onChange={(e) => setId(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Заголовок
|
||||||
|
<input className={field} style={fieldStyle} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Цвет (HEX)
|
||||||
|
<input className={field} style={fieldStyle} value={color} onChange={(e) => setColor(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Иконка (lucide-id)
|
||||||
|
<input className={field} style={fieldStyle} value={icon} onChange={(e) => setIcon(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{result ? (
|
||||||
|
<>
|
||||||
|
<CodeOutput code={result.css} tool="callout" label="CSS-snippet" />
|
||||||
|
<CodeOutput code={result.markdown} tool="callout" label="Пример разметки" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Проверь поля: id — латиница и цифры (напр. <code>idea</code>), цвет — корректный HEX (напр. <code>#7C3AED</code>).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { CalloutForm } from "./CalloutForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор callout-CSS — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function CalloutToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор callout-CSS</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Кастомные коллауты Obsidian: тип, заголовок, цвет, иконка.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<CalloutForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function CleanPdfForm({ initialUsed, limit }: { initialUsed: number; limit: number }) {
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [format, setFormat] = useState<"A4" | "Letter">("A4");
|
||||||
|
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [used, setUsed] = useState(initialUsed);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!url.trim() || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ url: url.trim(), format, theme });
|
||||||
|
const res = await fetch(`/api/pdf?${params}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ error: `Ошибка ${res.status}` }));
|
||||||
|
setError(body.error ?? `Ошибка ${res.status}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const usesCount = res.headers.get("X-Uses-Count");
|
||||||
|
if (usesCount) setUsed(Number(usesCount));
|
||||||
|
|
||||||
|
const blob = await res.blob();
|
||||||
|
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||||
|
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
|
||||||
|
const filename = match ? decodeURIComponent(match[1]) : "document.pdf";
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
} catch {
|
||||||
|
setError("Сеть недоступна, попробуйте ещё раз");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Адрес статьи
|
||||||
|
<input className={field} style={fieldStyle} type="url" required placeholder="https://…"
|
||||||
|
value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<label className="text-sm">Формат
|
||||||
|
<select className={field} style={fieldStyle} value={format} onChange={(e) => setFormat(e.target.value as "A4" | "Letter")}>
|
||||||
|
<option value="A4">A4</option>
|
||||||
|
<option value="Letter">Letter</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">Тема
|
||||||
|
<select className={field} style={fieldStyle} value={theme} onChange={(e) => setTheme(e.target.value as "light" | "dark")}>
|
||||||
|
<option value="light">Светлая</option>
|
||||||
|
<option value="dark">Тёмная</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<button type="submit" disabled={busy} className="btn-aubade px-4 py-2 text-sm font-bold disabled:opacity-50">
|
||||||
|
{busy ? "Генерируем… (до минуты)" : "Скачать PDF"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>Использовано {used} из {limit} в этом месяце</span>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
import { regeneratePdfApiKey } from "@/lib/actions/pdf-key-actions";
|
||||||
|
import { buildZoteroScript } from "@/lib/clean-pdf/zotero-script";
|
||||||
|
|
||||||
|
export function ZoteroSection({ apiKey, baseUrl }: { apiKey: string; baseUrl: string }) {
|
||||||
|
const [key, setKey] = useState(apiKey);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const shownKey = revealed ? key : key.slice(0, 8) + "…" + key.slice(-4);
|
||||||
|
const curlExample = `curl -H "Authorization: Bearer ${key}" \\\n "${baseUrl}/api/pdf?url=https://example.com/article&format=A4&theme=light" \\\n -o article.pdf`;
|
||||||
|
const script = buildZoteroScript({ apiKey: key, baseUrl });
|
||||||
|
|
||||||
|
function regenerate() {
|
||||||
|
if (!confirm("Старый ключ перестанет работать (в том числе в Zotero). Продолжить?")) return;
|
||||||
|
setError(null);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
const res = await regeneratePdfApiKey();
|
||||||
|
if (res.ok && res.key) {
|
||||||
|
setKey(res.key);
|
||||||
|
setRevealed(true);
|
||||||
|
} else {
|
||||||
|
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось перевыпустить ключ, попробуйте ещё раз");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-bold" style={{ color: "var(--foreground)" }}>Zotero и API</h2>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>ВАШ КЛЮЧ</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="p-2 text-sm" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>{shownKey}</code>
|
||||||
|
<button type="button" className="text-xs underline" onClick={() => setRevealed((v) => !v)}>
|
||||||
|
{revealed ? "скрыть" : "показать"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="text-xs underline" onClick={() => navigator.clipboard.writeText(key)}>скопировать</button>
|
||||||
|
<button type="button" className="text-xs underline" disabled={pending} onClick={regenerate}>
|
||||||
|
{pending ? "меняем…" : "перевыпустить"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Ключ персональный — не публикуйте его. Если ключ утёк, перевыпустите: старый сразу отключится.
|
||||||
|
</p>
|
||||||
|
{error && <p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 text-sm" style={{ color: "var(--foreground)" }}>
|
||||||
|
<div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>НАСТРОЙКА ZOTERO</div>
|
||||||
|
<ol className="list-decimal pl-5 flex flex-col gap-1">
|
||||||
|
<li>Установите плагин <a className="underline" href="https://github.com/windingwind/zotero-actions-tags/releases" target="_blank" rel="noreferrer">Actions & Tags</a>.</li>
|
||||||
|
<li>В настройках плагина нажмите «+» и создайте действие: Name — <b>Чистый PDF</b>, Operation — <b>Script</b>.</li>
|
||||||
|
<li>В поле Data вставьте скрипт ниже (ключ уже подставлен).</li>
|
||||||
|
<li>Menu Label — <b>_чистый PDF</b>, поставьте галочку <b>In item Menu</b>, нажмите Save.</li>
|
||||||
|
<li>Готово: правый клик на записи с URL → «_чистый PDF» — файл прикрепится к записи.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CodeOutput code={script} tool="clean-pdf" label="СКРИПТ ДЛЯ ACTIONS & TAGS" />
|
||||||
|
<CodeOutput code={curlExample} tool="clean-pdf" label="ПРИМЕР ДЛЯ API (CURL)" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { getMonthlyLimit, getMonthlyUsage, hasPaidAccess } from "@/lib/clean-pdf/access";
|
||||||
|
import { getOrCreatePdfKey } from "@/lib/clean-pdf/keys";
|
||||||
|
import { CleanPdfForm } from "./CleanPdfForm";
|
||||||
|
import { ZoteroSection } from "./ZoteroSection";
|
||||||
|
|
||||||
|
export const metadata = { title: "Чистый PDF — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default async function CleanPdfToolPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
const userId = session?.user.id;
|
||||||
|
const paid = userId ? await hasPaidAccess(userId) : false;
|
||||||
|
|
||||||
|
if (!userId || !paid) {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Инструмент доступен студентам платных курсов школы. Если у вас есть купленный курс — проверьте, что вы вошли в нужный аккаунт.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [key, used] = await Promise.all([getOrCreatePdfKey(userId), getMonthlyUsage(userId)]);
|
||||||
|
const limit = getMonthlyLimit();
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "https://school.second-brain.ru";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Чистый PDF</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Превращает любую веб-страницу в аккуратный PDF: без рекламы, меню и мусора — только текст, картинки и оглавление.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<CleanPdfForm initialUsed={used} limit={limit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<ZoteroSection apiKey={key} baseUrl={baseUrl} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { buildDataview, type DataviewColumn, type DataviewQueryType } from "@/lib/tools/dataview";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const QUERY_TYPES: DataviewQueryType[] = ["TABLE", "LIST", "TASK", "CALENDAR"];
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function DataviewForm() {
|
||||||
|
const [queryType, setQueryType] = useState<DataviewQueryType>("TABLE");
|
||||||
|
const [withoutId, setWithoutId] = useState(false);
|
||||||
|
const [columns, setColumns] = useState<DataviewColumn[]>([
|
||||||
|
{ expr: "file.name", alias: "Заметка" },
|
||||||
|
{ expr: "file.mtime", alias: "Изменено" },
|
||||||
|
]);
|
||||||
|
const [from, setFrom] = useState("#project");
|
||||||
|
const [where, setWhere] = useState("");
|
||||||
|
const [sortField, setSortField] = useState("file.mtime");
|
||||||
|
const [sortDir, setSortDir] = useState<"ASC" | "DESC">("DESC");
|
||||||
|
const [limit, setLimit] = useState("");
|
||||||
|
|
||||||
|
const query = useMemo(
|
||||||
|
() => buildDataview({
|
||||||
|
queryType, withoutId, columns,
|
||||||
|
from, where, sortField, sortDir,
|
||||||
|
limit: limit ? Number(limit) : undefined,
|
||||||
|
}),
|
||||||
|
[queryType, withoutId, columns, from, where, sortField, sortDir, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const showColumns = queryType !== "TASK";
|
||||||
|
const colLabel = queryType === "CALENDAR" ? "поле даты" : queryType === "LIST" ? "поле (берётся первое)" : "колонки";
|
||||||
|
|
||||||
|
function updateCol(i: number, patch: Partial<DataviewColumn>) {
|
||||||
|
setColumns((prev) => prev.map((c, idx) => (idx === i ? { ...c, ...patch } : c)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Тип запроса
|
||||||
|
<select className={field} style={fieldStyle} value={queryType} onChange={(e) => setQueryType(e.target.value as DataviewQueryType)}>
|
||||||
|
{QUERY_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(queryType === "TABLE" || queryType === "LIST") && (
|
||||||
|
<label className="text-sm flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={withoutId} onChange={(e) => setWithoutId(e.target.checked)} />
|
||||||
|
WITHOUT ID (скрыть колонку-ссылку)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showColumns && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<span className="text-sm">{colLabel}</span>
|
||||||
|
{columns.map((c, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-2">
|
||||||
|
<input className={field} style={fieldStyle} placeholder="выражение (file.name)" value={c.expr} onChange={(e) => updateCol(i, { expr: e.target.value })} />
|
||||||
|
{queryType === "TABLE" && (
|
||||||
|
<input className={field} style={fieldStyle} placeholder="алиас" value={c.alias ?? ""} onChange={(e) => updateCol(i, { alias: e.target.value })} />
|
||||||
|
)}
|
||||||
|
<button type="button" onClick={() => setColumns((p) => p.filter((_, idx) => idx !== i))} aria-label="Удалить" className="btn-aubade inline-flex items-center justify-center p-2">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{queryType === "TABLE" && (
|
||||||
|
<button type="button" onClick={() => setColumns((p) => [...p, { expr: "" }])} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
|
||||||
|
<Plus size={16} /> Колонка
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="text-sm">FROM (источник)
|
||||||
|
<input className={field} style={fieldStyle} placeholder='#tag или "Папка" или [[Заметка]]' value={from} onChange={(e) => setFrom(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="text-sm">WHERE (условие)
|
||||||
|
<input className={field} style={fieldStyle} placeholder="!completed" value={where} onChange={(e) => setWhere(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<label className="text-sm flex-1">SORT (поле)
|
||||||
|
<input className={field} style={fieldStyle} value={sortField} onChange={(e) => setSortField(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
<select className={field} style={{ ...fieldStyle, width: "auto" }} value={sortDir} onChange={(e) => setSortDir(e.target.value as "ASC" | "DESC")}>
|
||||||
|
<option value="DESC">DESC</option>
|
||||||
|
<option value="ASC">ASC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="text-sm">LIMIT
|
||||||
|
<input type="number" min={0} className={field} style={fieldStyle} placeholder="без лимита" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={query} tool="dataview" label="Запрос Dataview (вставь в заметку)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { DataviewForm } from "./DataviewForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор Dataview — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function DataviewToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор Dataview</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Запросы DQL: таблицы, списки, задачи и календарь по заметкам.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<DataviewForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { buildFrontmatter, type PropField, type PropType } from "@/lib/tools/frontmatter";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const TYPES: PropType[] = ["text", "number", "checkbox", "date", "list", "tags"];
|
||||||
|
const TYPE_RU: Record<PropType, string> = {
|
||||||
|
text: "текст", number: "число", checkbox: "флажок", date: "дата", list: "список", tags: "теги",
|
||||||
|
};
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function FrontmatterForm() {
|
||||||
|
const [fields, setFields] = useState<PropField[]>([
|
||||||
|
{ key: "title", type: "text", value: "Моя заметка" },
|
||||||
|
{ key: "tags", type: "tags", value: "obsidian, pkm" },
|
||||||
|
{ key: "created", type: "date", value: "2026-06-23" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const yaml = useMemo(() => buildFrontmatter(fields), [fields]);
|
||||||
|
|
||||||
|
function update(i: number, patch: Partial<PropField>) {
|
||||||
|
setFields((prev) => prev.map((f, idx) => (idx === i ? { ...f, ...patch } : f)));
|
||||||
|
}
|
||||||
|
function addRow() {
|
||||||
|
setFields((prev) => [...prev, { key: "", type: "text", value: "" }]);
|
||||||
|
}
|
||||||
|
function removeRow(i: number) {
|
||||||
|
setFields((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{fields.map((f, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-2 p-2" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
className={field} style={fieldStyle} placeholder="имя свойства"
|
||||||
|
value={f.key} onChange={(e) => update(i, { key: e.target.value })}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button" onClick={() => removeRow(i)} aria-label="Удалить свойство"
|
||||||
|
className="btn-aubade inline-flex items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
className={field} style={fieldStyle}
|
||||||
|
value={f.type} onChange={(e) => update(i, { type: e.target.value as PropType })}
|
||||||
|
>
|
||||||
|
{TYPES.map((t) => <option key={t} value={t}>{TYPE_RU[t]}</option>)}
|
||||||
|
</select>
|
||||||
|
{f.type === "checkbox" ? (
|
||||||
|
<select className={field} style={fieldStyle} value={f.value ?? "false"} onChange={(e) => update(i, { value: e.target.value })}>
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false">false</option>
|
||||||
|
</select>
|
||||||
|
) : f.type === "date" ? (
|
||||||
|
<input
|
||||||
|
type="date" className={field} style={fieldStyle}
|
||||||
|
value={f.value ?? ""} onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className={field} style={fieldStyle}
|
||||||
|
placeholder={f.type === "list" || f.type === "tags" ? "через запятую" : "значение"}
|
||||||
|
value={f.value ?? ""} onChange={(e) => update(i, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={addRow} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
|
||||||
|
<Plus size={16} /> Добавить свойство
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={yaml} tool="frontmatter" label="YAML-frontmatter" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { FrontmatterForm } from "./FrontmatterForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор YAML-frontmatter — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function FrontmatterToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор YAML-frontmatter</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Шаблон свойств заметки: имя, тип, значение по умолчанию.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<FrontmatterForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
// Toolbox ещё в доработке: на проде скрыт (флаг не задан) — прямой заход на
|
||||||
|
// /tools/* уводит на дашборд. На staging выставлен TOOLBOX_VISIBLE=true.
|
||||||
|
export default function ToolsLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
if (process.env.TOOLBOX_VISIBLE !== "true") redirect("/dashboard");
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { TOOLS } from "@/lib/tools/_shared/types";
|
||||||
|
import { ToolCard } from "@/components/tools/ToolCard";
|
||||||
|
|
||||||
|
export const metadata = { title: "Инструменты — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function ToolsIndexPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Инструменты</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Генераторы синтаксиса Obsidian для участников школы.</p>
|
||||||
|
<div className="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
{TOOLS.map((t) => <ToolCard key={t.id} tool={t} />)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import { buildStyleSettings, type SettingItem, type SettingType } from "@/lib/tools/style-settings";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const TYPES: SettingType[] = [
|
||||||
|
"heading", "class-toggle", "class-select",
|
||||||
|
"variable-text", "variable-number", "variable-number-slider",
|
||||||
|
"variable-select", "variable-color", "info-text",
|
||||||
|
];
|
||||||
|
|
||||||
|
const field = "w-full p-2 text-sm";
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
function hasDefault(t: SettingType) { return t !== "heading" && t !== "info-text"; }
|
||||||
|
function hasOptions(t: SettingType) { return t === "variable-select" || t === "class-select"; }
|
||||||
|
|
||||||
|
export function StyleSettingsForm() {
|
||||||
|
const [pluginName, setPluginName] = useState("Моя тема");
|
||||||
|
const [settings, setSettings] = useState<SettingItem[]>([
|
||||||
|
{ id: "section", title: "Цвета", type: "heading" },
|
||||||
|
{ id: "accent", title: "Акцент", type: "variable-color", default: "#7C3AED", format: "hex" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = useMemo(() => buildStyleSettings({ pluginName, settings }), [pluginName, settings]);
|
||||||
|
|
||||||
|
function update(i: number, patch: Partial<SettingItem>) {
|
||||||
|
setSettings((prev) => prev.map((s, idx) => (idx === i ? { ...s, ...patch } : s)));
|
||||||
|
}
|
||||||
|
function addRow() {
|
||||||
|
setSettings((prev) => [...prev, { id: "", title: "", type: "variable-text", default: "" }]);
|
||||||
|
}
|
||||||
|
function removeRow(i: number) {
|
||||||
|
setSettings((prev) => prev.filter((_, idx) => idx !== i));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<label className="text-sm">Имя темы / плагина
|
||||||
|
<input className={field} style={fieldStyle} value={pluginName} onChange={(e) => setPluginName(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
{settings.map((s, i) => (
|
||||||
|
<div key={i} className="flex flex-col gap-2 p-2" style={{ border: "1px solid var(--border)", borderRadius: "2px" }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input className={field} style={fieldStyle} placeholder="id" value={s.id} onChange={(e) => update(i, { id: e.target.value })} />
|
||||||
|
<button type="button" onClick={() => removeRow(i)} aria-label="Удалить настройку" className="btn-aubade inline-flex items-center justify-center p-2">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input className={field} style={fieldStyle} placeholder="заголовок" value={s.title} onChange={(e) => update(i, { title: e.target.value })} />
|
||||||
|
<select className={field} style={fieldStyle} value={s.type} onChange={(e) => update(i, { type: e.target.value as SettingType })}>
|
||||||
|
{TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
{hasDefault(s.type) && (
|
||||||
|
<input className={field} style={fieldStyle} placeholder="значение по умолчанию" value={s.default ?? ""} onChange={(e) => update(i, { default: e.target.value })} />
|
||||||
|
)}
|
||||||
|
{hasOptions(s.type) && (
|
||||||
|
<input className={field} style={fieldStyle} placeholder="варианты через запятую" value={s.options ?? ""} onChange={(e) => update(i, { options: e.target.value })} />
|
||||||
|
)}
|
||||||
|
{s.type === "variable-color" && (
|
||||||
|
<select className={field} style={fieldStyle} value={s.format ?? "hex"} onChange={(e) => update(i, { format: e.target.value })}>
|
||||||
|
{["hex", "rgb", "hsl"].map((fmt) => <option key={fmt} value={fmt}>{fmt}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={addRow} className="btn-aubade inline-flex items-center justify-center gap-2 text-sm">
|
||||||
|
<Plus size={16} /> Добавить настройку
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={output} tool="style-settings" label="Блок Style Settings (в начало CSS-сниппета)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { StyleSettingsForm } from "./StyleSettingsForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор Style Settings — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function StyleSettingsToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор Style Settings</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Комментарии-настройки для плагина Style Settings.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<StyleSettingsForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { buildThemeCss, type ThemeInput } from "@/lib/tools/theme";
|
||||||
|
import { CodeOutput } from "@/components/tools/CodeOutput";
|
||||||
|
|
||||||
|
const FIELDS: { key: keyof ThemeInput; label: string }[] = [
|
||||||
|
{ key: "background", label: "Фон" },
|
||||||
|
{ key: "text", label: "Текст" },
|
||||||
|
{ key: "accent", label: "Акцент" },
|
||||||
|
{ key: "link", label: "Ссылки" },
|
||||||
|
{ key: "border", label: "Рамка" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const fieldStyle = { border: "1px solid var(--border)", borderRadius: "2px", backgroundColor: "var(--background)", color: "var(--foreground)" } as const;
|
||||||
|
|
||||||
|
export function ThemeForm() {
|
||||||
|
const [theme, setTheme] = useState<ThemeInput>({
|
||||||
|
background: "#0F0F0F", text: "#EAEAEA", accent: "#7C3AED", link: "#4EA1FF", border: "#333333",
|
||||||
|
});
|
||||||
|
|
||||||
|
const css = useMemo(() => buildThemeCss(theme), [theme]);
|
||||||
|
|
||||||
|
function update(key: keyof ThemeInput, value: string) {
|
||||||
|
setTheme((prev) => ({ ...prev, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{FIELDS.map((f) => (
|
||||||
|
<label key={f.key} className="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span>{f.label}</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="color" value={theme[f.key]} onChange={(e) => update(f.key, e.target.value)}
|
||||||
|
aria-label={f.label} style={{ width: 36, height: 28, border: "1px solid var(--border)", borderRadius: 2, padding: 0, background: "none" }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-28 p-2 text-sm" style={fieldStyle}
|
||||||
|
value={theme[f.key]} onChange={(e) => update(f.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<div
|
||||||
|
className="mt-2 p-4 text-sm"
|
||||||
|
style={{ backgroundColor: theme.background, color: theme.text, border: `2px solid ${theme.border}`, borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
Превью темы. <a href="#" onClick={(e) => e.preventDefault()} style={{ color: theme.link }}>ссылка</a>,{" "}
|
||||||
|
<span style={{ color: theme.accent, fontWeight: 700 }}>акцент</span>.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CodeOutput code={css} tool="theme" label="CSS-переменные темы" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { ThemeForm } from "./ThemeForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Генератор темы — Obsidian Toolbox" };
|
||||||
|
|
||||||
|
export default function ThemeToolPage() {
|
||||||
|
return (
|
||||||
|
<main className="mx-auto w-full max-w-5xl px-6 py-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>Генератор темы / CSS-переменных</h1>
|
||||||
|
<p className="mt-1 text-sm" style={{ color: "var(--muted-foreground)" }}>Палитра → набор CSS-переменных темы Obsidian.</p>
|
||||||
|
<div className="mt-6 card-aubade p-4">
|
||||||
|
<ThemeForm />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
|
import type { Stats, RawNote, Period, RunPayload } from "./_client/types";
|
||||||
|
import { supportsFSA, readViaFSA, readViaInput, readViaDrop } from "./_client/reader";
|
||||||
|
|
||||||
|
type Screen = "intro" | "loading" | "result" | "error";
|
||||||
|
type View = "card" | "graph";
|
||||||
|
|
||||||
|
const ARCHETYPE_RU: Record<string, string> = {
|
||||||
|
architect: "🏛 Архитектор",
|
||||||
|
gardener: "🌱 Садовник",
|
||||||
|
librarian: "📚 Библиотекарь",
|
||||||
|
pragmatist: "⚡ Прагматик",
|
||||||
|
};
|
||||||
|
const PERIOD_RU: Record<Period, string> = { year: "год", quarter: "квартал", all: "всё время" };
|
||||||
|
|
||||||
|
export function WrappedClient({ userName }: { userName: string }) {
|
||||||
|
const [screen, setScreen] = useState<Screen>("intro");
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [period, setPeriod] = useState<Period>("year");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [shareUrl, setShareUrl] = useState("");
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
const [view, setView] = useState<View>("card");
|
||||||
|
const [graphUrl, setGraphUrl] = useState("");
|
||||||
|
const [graphLabels, setGraphLabels] = useState(false);
|
||||||
|
const [graphBusy, setGraphBusy] = useState(false);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const notesRef = useRef<RawNote[]>([]);
|
||||||
|
|
||||||
|
// Сбросить отрисованный граф (при новых данных / смене периода).
|
||||||
|
const resetGraph = useCallback(() => {
|
||||||
|
setView("card");
|
||||||
|
setGraphUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Отрисовать граф-постер в офф-скрин Canvas → blob URL. force-graph грузим лениво.
|
||||||
|
const renderGraph = useCallback(
|
||||||
|
async (labels: boolean) => {
|
||||||
|
if (!stats || !stats.graph.nodes.length) return;
|
||||||
|
setGraphBusy(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { renderGraphPoster } = await import("./_client/graphPoster");
|
||||||
|
const blob = await renderGraphPoster(stats.graph, labels);
|
||||||
|
setGraphUrl((prev) => {
|
||||||
|
if (prev) URL.revokeObjectURL(prev);
|
||||||
|
return URL.createObjectURL(blob);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось построить граф");
|
||||||
|
}
|
||||||
|
setGraphBusy(false);
|
||||||
|
},
|
||||||
|
[stats],
|
||||||
|
);
|
||||||
|
|
||||||
|
const runWorker = useCallback((notes: RawNote[], p: Period) => {
|
||||||
|
setScreen("loading");
|
||||||
|
const worker = new Worker(new URL("./_client/parse.worker.ts", import.meta.url), { type: "module" });
|
||||||
|
worker.onmessage = (e: MessageEvent<Stats>) => {
|
||||||
|
setStats(e.data);
|
||||||
|
resetGraph();
|
||||||
|
setScreen("result");
|
||||||
|
worker.terminate();
|
||||||
|
};
|
||||||
|
worker.onerror = () => {
|
||||||
|
setError("Не удалось обработать хранилище");
|
||||||
|
setScreen("error");
|
||||||
|
worker.terminate();
|
||||||
|
};
|
||||||
|
worker.postMessage({ notes, period: p });
|
||||||
|
}, [resetGraph]);
|
||||||
|
|
||||||
|
const handleNotes = useCallback(
|
||||||
|
(notes: RawNote[]) => {
|
||||||
|
if (!notes.length) {
|
||||||
|
setError("В выбранной папке не нашлось .md заметок");
|
||||||
|
setScreen("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notesRef.current = notes;
|
||||||
|
runWorker(notes, period);
|
||||||
|
},
|
||||||
|
[period, runWorker],
|
||||||
|
);
|
||||||
|
|
||||||
|
async function pickFSA() {
|
||||||
|
try {
|
||||||
|
handleNotes(await readViaFSA());
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== "AbortError") {
|
||||||
|
setError("Не удалось прочитать папку");
|
||||||
|
setScreen("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (e.target.files) handleNotes(await readViaInput(e.target.files));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(e: React.DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.dataTransfer.items) handleNotes(await readViaDrop(e.dataTransfer.items));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changePeriod(p: Period) {
|
||||||
|
setPeriod(p);
|
||||||
|
if (notesRef.current.length) runWorker(notesRef.current, p);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function share() {
|
||||||
|
if (!stats || sharing) return;
|
||||||
|
setSharing(true);
|
||||||
|
setError("");
|
||||||
|
const payload: RunPayload = {
|
||||||
|
period: stats.period,
|
||||||
|
noteCount: stats.noteCount,
|
||||||
|
linkCount: stats.linkCount,
|
||||||
|
tagCount: stats.tagCount,
|
||||||
|
topTag: stats.topTags[0]?.tag ?? null,
|
||||||
|
archetype: stats.archetype,
|
||||||
|
score: stats.score,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/wrapped/run", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const data = (await res.json()) as { shareToken?: string };
|
||||||
|
if (data.shareToken) setShareUrl(`${location.origin}/share/wrapped/${data.shareToken}`);
|
||||||
|
else setError("Не удалось создать ссылку");
|
||||||
|
} catch {
|
||||||
|
setError("Не удалось создать ссылку");
|
||||||
|
}
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="max-w-2xl mx-auto px-6 py-12 w-full">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Obsidian Wrapped · 2026
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{screen === "intro" && (
|
||||||
|
<div className="card-aubade p-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-3">Твой год в заметках, {userName.split(" ")[0]}</h1>
|
||||||
|
<p className="text-sm mb-6" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Выбери папку своего хранилища — всё считается прямо в браузере, заметки никуда не загружаются.
|
||||||
|
На выходе — карточка «твой год в заметках», которой можно поделиться.
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
onDrop={onDrop}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
className="p-8 text-center mb-4"
|
||||||
|
style={{ border: "2px dashed var(--border)", borderRadius: 2 }}
|
||||||
|
>
|
||||||
|
<p className="text-3xl mb-2">📂</p>
|
||||||
|
{supportsFSA() ? (
|
||||||
|
<button onClick={pickFSA} className="btn-aubade btn-aubade-accent text-sm">
|
||||||
|
Выбрать папку хранилища
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => inputRef.current?.click()} className="btn-aubade btn-aubade-accent text-sm">
|
||||||
|
Выбрать папку хранилища
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<p className="text-xs mt-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
или перетащи папку сюда
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
// @ts-expect-error webkitdirectory не в типах
|
||||||
|
webkitdirectory=""
|
||||||
|
directory=""
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
onChange={onInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
🔒 Приватность: тексты заметок остаются в браузере. На сервер уходят только числа (сколько заметок, связей, тегов).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === "loading" && (
|
||||||
|
<div className="card-aubade p-12 text-center">
|
||||||
|
<p className="text-3xl mb-3">⏳</p>
|
||||||
|
<p className="font-medium">Считаю твой год…</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === "error" && (
|
||||||
|
<div className="card-aubade p-8 text-center">
|
||||||
|
<p className="text-sm mb-4" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>
|
||||||
|
<button onClick={() => { setScreen("intro"); setError(""); }} className="btn-aubade text-sm">
|
||||||
|
Попробовать снова
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{screen === "result" && stats && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* переключатель периода */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{(["year", "quarter", "all"] as Period[]).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => changePeriod(p)}
|
||||||
|
className="text-xs px-3 py-1.5"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
borderRadius: 2,
|
||||||
|
background: period === p ? "var(--accent)" : "var(--background)",
|
||||||
|
fontWeight: period === p ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PERIOD_RU[p]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{/* переключатель вида: карточка / граф */}
|
||||||
|
<div className="flex gap-2 ml-auto">
|
||||||
|
{(["card", "graph"] as View[]).map((v) => (
|
||||||
|
<button
|
||||||
|
key={v}
|
||||||
|
onClick={() => {
|
||||||
|
setView(v);
|
||||||
|
if (v === "graph" && !graphUrl && !graphBusy) renderGraph(graphLabels);
|
||||||
|
}}
|
||||||
|
className="text-xs px-3 py-1.5"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
borderRadius: 2,
|
||||||
|
background: view === v ? "var(--accent)" : "var(--background)",
|
||||||
|
fontWeight: view === v ? 700 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{v === "card" ? "Карточка" : "Граф"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* граф-постер хранилища */}
|
||||||
|
{view === "graph" && (
|
||||||
|
<div className="card-aubade p-6 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<h2 className="font-bold text-xl">Граф твоего хранилища</h2>
|
||||||
|
<label className="text-xs flex items-center gap-1.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={graphLabels}
|
||||||
|
onChange={(e) => { setGraphLabels(e.target.checked); renderGraph(e.target.checked); }}
|
||||||
|
/>
|
||||||
|
подписи узлов
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{stats.graph.nodes.length} самых связанных заметок из твоего хранилища. Имена остаются в браузере.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-center" style={{ border: "2px solid var(--border)", borderRadius: 2, minHeight: 280, background: "#F5F5F0" }}>
|
||||||
|
{graphBusy ? (
|
||||||
|
<p className="text-sm py-12" style={{ color: "var(--muted-foreground)" }}>Строю граф…</p>
|
||||||
|
) : graphUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={graphUrl} alt="Граф хранилища" className="w-full h-auto" style={{ borderRadius: 2 }} />
|
||||||
|
) : stats.graph.nodes.length === 0 ? (
|
||||||
|
<p className="text-sm py-12 px-4 text-center" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Слишком мало связей между заметками, чтобы построить граф.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{graphUrl && (
|
||||||
|
<a href={graphUrl} download="obsidian-graph.png" className="btn-aubade text-sm inline-block">
|
||||||
|
Скачать граф (PNG)
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* DOM-карточка результата (ДС-2) */}
|
||||||
|
{view === "card" && (
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<h2 className="font-bold text-xl mb-1">Ты — {ARCHETYPE_RU[stats.archetype]}</h2>
|
||||||
|
<p className="text-xs uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
зрелость {stats.score}/100 · период: {PERIOD_RU[stats.period]}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||||
|
{[
|
||||||
|
{ n: stats.noteCount, l: "заметок" },
|
||||||
|
{ n: stats.linkCount, l: "связей" },
|
||||||
|
{ n: stats.tagCount, l: "тегов" },
|
||||||
|
].map((m) => (
|
||||||
|
<div key={m.l} className="p-3 text-center" style={{ border: "2px solid var(--border)", boxShadow: "4px 4px 0 0 var(--border)" }}>
|
||||||
|
<p className="text-2xl font-bold">{m.n}</p>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{m.l}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{stats.topTags.length > 0 && (
|
||||||
|
<p className="text-sm mb-2">
|
||||||
|
<span className="text-xs uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Топ-теги: </span>
|
||||||
|
{stats.topTags.map((t) => `#${t.tag}`).join(" ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{stats.achievements.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
|
{stats.achievements.map((a) => (
|
||||||
|
<span key={a} className="text-xs px-2 py-1" style={{ background: "var(--accent)", border: "2px solid var(--foreground)", borderRadius: 2 }}>
|
||||||
|
{a}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* действия */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<button onClick={share} disabled={sharing} className="btn-aubade btn-aubade-accent text-sm flex-1">
|
||||||
|
{sharing ? "Создаю ссылку…" : "Поделиться карточкой"}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setScreen("intro"); setStats(null); setShareUrl(""); resetGraph(); }} className="btn-aubade text-sm flex-1">
|
||||||
|
Другое хранилище
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareUrl && (
|
||||||
|
<div className="card-aubade p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Публичная ссылка</p>
|
||||||
|
<a href={shareUrl} target="_blank" rel="noopener noreferrer" className="text-sm underline break-all" style={{ color: "var(--foreground)" }}>
|
||||||
|
{shareUrl}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a href={`${shareUrl}/opengraph-image`} download className="btn-aubade text-sm inline-block">
|
||||||
|
Скачать картинку (PNG)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Граф-постер: force-graph (Canvas) в офф-скрин контейнере → PNG. ДС-2-окрас.
|
||||||
|
// 100% client-side — имена узлов остаются в браузере, на сервер не уходят.
|
||||||
|
|
||||||
|
import ForceGraph from "force-graph";
|
||||||
|
import type { GraphData } from "./types";
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
const SIZE = 1080;
|
||||||
|
const BG = "#F5F5F0";
|
||||||
|
const NODE = "#323232";
|
||||||
|
const LINK = "#AAAAAA";
|
||||||
|
|
||||||
|
export async function renderGraphPoster(graph: GraphData, labels: boolean): Promise<Blob> {
|
||||||
|
const el = document.createElement("div");
|
||||||
|
el.style.cssText = "position:fixed;left:-99999px;top:0;width:1080px;height:1080px;";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fg = (ForceGraph as any)()(el)
|
||||||
|
.width(SIZE)
|
||||||
|
.height(SIZE)
|
||||||
|
.backgroundColor(BG)
|
||||||
|
.graphData({
|
||||||
|
nodes: graph.nodes.map((n) => ({ ...n })),
|
||||||
|
links: graph.links.map((l) => ({ ...l })),
|
||||||
|
})
|
||||||
|
.nodeId("id")
|
||||||
|
.nodeRelSize(labels ? 3 : 2.5)
|
||||||
|
.nodeVal((n: any) => 1 + Math.sqrt(n.deg ?? 1))
|
||||||
|
.nodeColor(() => NODE)
|
||||||
|
.linkColor(() => LINK)
|
||||||
|
.linkWidth(0.4)
|
||||||
|
.enableNodeDrag(false)
|
||||||
|
.enablePointerInteraction(false)
|
||||||
|
.cooldownTime(3500);
|
||||||
|
|
||||||
|
if (labels) {
|
||||||
|
fg.nodeCanvasObjectMode(() => "after").nodeCanvasObject((node: any, ctx: CanvasRenderingContext2D, scale: number) => {
|
||||||
|
if ((node.deg ?? 0) >= 6) {
|
||||||
|
const fontSize = 11 / scale;
|
||||||
|
ctx.font = `${fontSize}px "Fira Mono", monospace`;
|
||||||
|
ctx.fillStyle = NODE;
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(String(node.id), node.x + 2 + Math.sqrt(node.deg) / scale, node.y);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// дождаться остановки симуляции (или таймаут)
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
const finish = () => { if (!done) { done = true; resolve(); } };
|
||||||
|
fg.onEngineStop(finish);
|
||||||
|
setTimeout(finish, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
fg.zoomToFit(0, 50);
|
||||||
|
await new Promise((r) => setTimeout(r, 400));
|
||||||
|
|
||||||
|
const canvas = el.querySelector("canvas") as HTMLCanvasElement | null;
|
||||||
|
if (!canvas) throw new Error("canvas not found");
|
||||||
|
return await new Promise<Blob>((resolve, reject) =>
|
||||||
|
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error("toBlob failed"))), "image/png"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
document.body.removeChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
// Web Worker: парсит .md-тексты в агрегаты (off-main, без фриза UI).
|
||||||
|
// Получает RawNote[], возвращает Stats. Тексты заметок дальше воркера не идут.
|
||||||
|
|
||||||
|
import type { RawNote, Stats, Period, Archetype, GraphData } from "./types";
|
||||||
|
|
||||||
|
const WIKILINK = /\[\[([^\]|#]+)/g;
|
||||||
|
const TAG = /(^|\s)#([\p{L}\d_/-]+)/gu;
|
||||||
|
const DAY = 864e5;
|
||||||
|
|
||||||
|
function filterByPeriod(notes: RawNote[], period: Period): RawNote[] {
|
||||||
|
if (period === "all") return notes;
|
||||||
|
const cutoff = Date.now() - (period === "year" ? 365 * DAY : 90 * DAY);
|
||||||
|
return notes.filter((n) => n.mtime >= cutoff);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGraph(degree: Map<string, number>, edges: { source: string; target: string }[]): GraphData {
|
||||||
|
const TOP = 150;
|
||||||
|
const top = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP);
|
||||||
|
const nodes = top.map(([id, deg]) => ({ id, deg }));
|
||||||
|
const ids = new Set(nodes.map((n) => n.id));
|
||||||
|
const links = edges.filter((e) => ids.has(e.source) && ids.has(e.target)).slice(0, 600);
|
||||||
|
return { nodes, links };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStats(
|
||||||
|
notes: RawNote[],
|
||||||
|
degree: Map<string, number>,
|
||||||
|
tags: Map<string, number>,
|
||||||
|
linkCount: number,
|
||||||
|
period: Period,
|
||||||
|
edges: { source: string; target: string }[],
|
||||||
|
): Stats {
|
||||||
|
const noteCount = notes.length;
|
||||||
|
const topTags = [...tags.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, n]) => ({ tag, n }));
|
||||||
|
const topLinked = [...degree.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5).map(([name, d]) => ({ name, degree: d }));
|
||||||
|
|
||||||
|
// Сирота — заметка, на которую никто не ссылается (грубая оценка по имени).
|
||||||
|
const linkedTargets = new Set(degree.keys());
|
||||||
|
const orphans = notes.filter((n) => !linkedTargets.has(n.name.replace(/\.md$/i, ""))).length;
|
||||||
|
|
||||||
|
const months = new Map<string, number>();
|
||||||
|
for (const n of notes) {
|
||||||
|
const d = new Date(n.mtime);
|
||||||
|
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||||
|
months.set(k, (months.get(k) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const busiestMonth = [...months.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
|
||||||
|
|
||||||
|
const linkDensity = noteCount ? linkCount / noteCount : 0;
|
||||||
|
const tagDensity = noteCount ? tags.size / noteCount : 0;
|
||||||
|
const orphanRatio = noteCount ? orphans / noteCount : 1;
|
||||||
|
|
||||||
|
let archetype: Archetype;
|
||||||
|
if (linkDensity >= 2) archetype = "architect";
|
||||||
|
else if (tagDensity >= 0.5) archetype = "librarian";
|
||||||
|
else if (orphanRatio >= 0.4) archetype = "gardener";
|
||||||
|
else archetype = "pragmatist";
|
||||||
|
|
||||||
|
const score = Math.max(0, Math.min(100, Math.round(
|
||||||
|
Math.min(linkDensity / 3, 1) * 45 + Math.min(tagDensity, 1) * 25 + Math.min(noteCount / 300, 1) * 30,
|
||||||
|
)));
|
||||||
|
|
||||||
|
const achievements: string[] = [];
|
||||||
|
if (noteCount >= 100) achievements.push(`${noteCount} заметок`);
|
||||||
|
if (linkCount >= 200) achievements.push(`${linkCount} связей`);
|
||||||
|
if (tags.size >= 30) achievements.push(`${tags.size} тегов`);
|
||||||
|
if (linkDensity >= 3) achievements.push("Плотная сеть знаний");
|
||||||
|
if (orphanRatio < 0.1 && noteCount >= 50) achievements.push("Почти нет сирот");
|
||||||
|
|
||||||
|
return { noteCount, linkCount, tagCount: tags.size, topTags, topLinked, orphans, busiestMonth, achievements, archetype, score, period, graph: buildGraph(degree, edges) };
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (e: MessageEvent<{ notes: RawNote[]; period: Period }>) => {
|
||||||
|
const { notes, period } = e.data;
|
||||||
|
const filtered = filterByPeriod(notes, period);
|
||||||
|
const degree = new Map<string, number>();
|
||||||
|
const tags = new Map<string, number>();
|
||||||
|
let linkCount = 0;
|
||||||
|
const edges: { source: string; target: string }[] = [];
|
||||||
|
for (const n of filtered) {
|
||||||
|
const src = n.name.replace(/\.md$/i, "");
|
||||||
|
for (const m of n.text.matchAll(WIKILINK)) {
|
||||||
|
linkCount++;
|
||||||
|
const t = m[1].trim();
|
||||||
|
if (t) {
|
||||||
|
degree.set(t, (degree.get(t) ?? 0) + 1);
|
||||||
|
edges.push({ source: src, target: t });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const m of n.text.matchAll(TAG)) {
|
||||||
|
const t = m[2];
|
||||||
|
tags.set(t, (tags.get(t) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.postMessage(buildStats(filtered, degree, tags, linkCount, period, edges));
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Чтение хранилища в браузере — три пути ввода, все → RawNote[].
|
||||||
|
// Содержимое заметок остаётся в браузере (приватность-моат).
|
||||||
|
|
||||||
|
import type { RawNote } from "./types";
|
||||||
|
|
||||||
|
const SKIP = ["/.obsidian/", "/.trash/", "/.git/"];
|
||||||
|
function isMd(path: string): boolean {
|
||||||
|
return path.toLowerCase().endsWith(".md") && !SKIP.some((s) => path.includes(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
export function supportsFSA(): boolean {
|
||||||
|
return typeof window !== "undefined" && "showDirectoryPicker" in window;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. File System Access API (Chromium: Chrome/Edge/Brave)
|
||||||
|
export async function readViaFSA(): Promise<RawNote[]> {
|
||||||
|
const dir: any = await (window as any).showDirectoryPicker();
|
||||||
|
const notes: RawNote[] = [];
|
||||||
|
async function walk(handle: any, prefix: string): Promise<void> {
|
||||||
|
for await (const [name, h] of handle.entries()) {
|
||||||
|
const path = prefix + "/" + name;
|
||||||
|
if (h.kind === "directory") {
|
||||||
|
if (name.startsWith(".")) continue;
|
||||||
|
await walk(h, path);
|
||||||
|
} else if (isMd(path)) {
|
||||||
|
const file: File = await h.getFile();
|
||||||
|
notes.push({ name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await walk(dir, "");
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. <input type="file" webkitdirectory> (Safari / Firefox)
|
||||||
|
export async function readViaInput(files: FileList): Promise<RawNote[]> {
|
||||||
|
const notes: RawNote[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
const path = (file as any).webkitRelativePath || file.name;
|
||||||
|
if (!isMd(path)) continue;
|
||||||
|
notes.push({ name: file.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
|
||||||
|
}
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. drag-drop папки (webkitGetAsEntry)
|
||||||
|
export async function readViaDrop(items: DataTransferItemList): Promise<RawNote[]> {
|
||||||
|
const notes: RawNote[] = [];
|
||||||
|
const roots: any[] = [];
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
const entry = (item as any).webkitGetAsEntry?.();
|
||||||
|
if (entry) roots.push(entry);
|
||||||
|
}
|
||||||
|
async function readEntry(entry: any, prefix: string): Promise<void> {
|
||||||
|
const path = prefix + "/" + entry.name;
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
if (entry.name.startsWith(".")) return;
|
||||||
|
const reader = entry.createReader();
|
||||||
|
const children: any[] = await new Promise((resolve) => {
|
||||||
|
const all: any[] = [];
|
||||||
|
const readBatch = () =>
|
||||||
|
reader.readEntries((batch: any[]) => {
|
||||||
|
if (!batch.length) return resolve(all);
|
||||||
|
all.push(...batch);
|
||||||
|
readBatch();
|
||||||
|
});
|
||||||
|
readBatch();
|
||||||
|
});
|
||||||
|
for (const c of children) await readEntry(c, path);
|
||||||
|
} else if (isMd(path)) {
|
||||||
|
const file: File = await new Promise((resolve, reject) => entry.file(resolve, reject));
|
||||||
|
notes.push({ name: entry.name, text: await file.text(), ctime: file.lastModified, mtime: file.lastModified });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const e of roots) await readEntry(e, "");
|
||||||
|
return notes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Сквозные типы Obsidian Wrapped (общие: reader, worker, рендер, API).
|
||||||
|
// Приватность: имена/тексты заметок живут ТОЛЬКО в браузере; в БД/сеть уходят
|
||||||
|
// лишь числовые/строковые агрегаты (RunPayload).
|
||||||
|
|
||||||
|
export type Archetype = "architect" | "gardener" | "librarian" | "pragmatist";
|
||||||
|
export type Period = "year" | "quarter" | "all";
|
||||||
|
|
||||||
|
// Граф для постера (топ-N узлов по степени). Имена живут только в браузере.
|
||||||
|
export interface GraphData {
|
||||||
|
nodes: { id: string; deg: number }[];
|
||||||
|
links: { source: string; target: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Stats {
|
||||||
|
noteCount: number;
|
||||||
|
linkCount: number;
|
||||||
|
tagCount: number;
|
||||||
|
topTags: { tag: string; n: number }[]; // top-5
|
||||||
|
topLinked: { name: string; degree: number }[]; // top-5 самых связанных
|
||||||
|
orphans: number;
|
||||||
|
busiestMonth: string; // "2026-03"
|
||||||
|
achievements: string[];
|
||||||
|
archetype: Archetype;
|
||||||
|
score: number; // 0..100
|
||||||
|
period: Period;
|
||||||
|
graph: GraphData; // топ-N узлов/рёбер для граф-постера (не уходит в БД)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сырьё — НИКОГДА не покидает браузер.
|
||||||
|
export interface RawNote {
|
||||||
|
name: string;
|
||||||
|
text: string;
|
||||||
|
ctime: number;
|
||||||
|
mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// То, что реально уходит в POST /api/wrapped/run — БЕЗ имён/текстов.
|
||||||
|
export interface RunPayload {
|
||||||
|
period: Period;
|
||||||
|
noteCount: number;
|
||||||
|
linkCount: number;
|
||||||
|
tagCount: number;
|
||||||
|
topTag: string | null;
|
||||||
|
archetype: Archetype;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { WrappedClient } from "./WrappedClient";
|
||||||
|
|
||||||
|
// Авторизованный генератор Obsidian Wrapped. Гость → /register (приток из публичной карточки).
|
||||||
|
export default async function WrappedPage() {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) redirect("/register");
|
||||||
|
return <WrappedClient userName={session.user.name} />;
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ export default async function AdminDashboard() {
|
|||||||
value={homeworkPending}
|
value={homeworkPending}
|
||||||
sub={`${homeworkTotal} всего сдано`}
|
sub={`${homeworkTotal} всего сдано`}
|
||||||
subAccent={homeworkPending > 0}
|
subAccent={homeworkPending > 0}
|
||||||
href="/curator/homework"
|
href="/curator/homework?status=pending"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -200,6 +200,7 @@ export async function applyImport(
|
|||||||
data: {
|
data: {
|
||||||
name: row.name,
|
name: row.name,
|
||||||
email: row.email,
|
email: row.email,
|
||||||
|
phone: row.phone || null,
|
||||||
emailVerified: options.autoVerifyEmail,
|
emailVerified: options.autoVerifyEmail,
|
||||||
role: "student",
|
role: "student",
|
||||||
},
|
},
|
||||||
@@ -231,10 +232,16 @@ export async function applyImport(
|
|||||||
|
|
||||||
created++;
|
created++;
|
||||||
} else if (row.status === "update" && row.existingId) {
|
} else if (row.status === "update" && row.existingId) {
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { id: row.existingId },
|
||||||
|
select: { phone: true },
|
||||||
|
});
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: row.existingId },
|
where: { id: row.existingId },
|
||||||
data: {
|
data: {
|
||||||
name: row.name || undefined,
|
name: row.name || undefined,
|
||||||
|
// don't overwrite an already-filled phone
|
||||||
|
phone: existing?.phone ? undefined : row.phone || undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export async function POST(request: NextRequest) {
|
|||||||
let body: {
|
let body: {
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
phone?: string;
|
||||||
course_slugs?: string[];
|
course_slugs?: string[];
|
||||||
order_id?: string;
|
order_id?: string;
|
||||||
};
|
};
|
||||||
@@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const email = (body.email ?? "").trim().toLowerCase();
|
const email = (body.email ?? "").trim().toLowerCase();
|
||||||
const name = (body.name ?? "").trim();
|
const name = (body.name ?? "").trim();
|
||||||
|
const phone = (body.phone ?? "").trim().slice(0, 30);
|
||||||
const slugs = Array.isArray(body.course_slugs)
|
const slugs = Array.isArray(body.course_slugs)
|
||||||
? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
|
? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
|
||||||
: [];
|
: [];
|
||||||
@@ -66,7 +68,7 @@ export async function POST(request: NextRequest) {
|
|||||||
// Find or create user
|
// Find or create user
|
||||||
let user = await prisma.user.findFirst({
|
let user = await prisma.user.findFirst({
|
||||||
where: { email: { equals: email, mode: "insensitive" } },
|
where: { email: { equals: email, mode: "insensitive" } },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true, email: true, phone: true },
|
||||||
});
|
});
|
||||||
let tempPassword: string | null = null;
|
let tempPassword: string | null = null;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
@@ -76,14 +78,25 @@ export async function POST(request: NextRequest) {
|
|||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
name: name || email,
|
name: name || email,
|
||||||
|
phone: phone || null,
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
accounts: {
|
accounts: {
|
||||||
create: { accountId: email, providerId: "credential", password: hash },
|
create: { accountId: email, providerId: "credential", password: hash },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true, email: true, phone: true },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Enrich existing user from the order, never overwriting meaningful data:
|
||||||
|
// name only if the current one is empty or an email placeholder, phone only if empty.
|
||||||
|
const enrich: { name?: string; phone?: string } = {};
|
||||||
|
if (name && (!user.name || user.name === user.email)) enrich.name = name;
|
||||||
|
if (phone && !user.phone) enrich.phone = phone;
|
||||||
|
if (Object.keys(enrich).length > 0) {
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: enrich });
|
||||||
|
if (enrich.name) user.name = enrich.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enroll + audit log
|
// Enroll + audit log
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"] as const;
|
||||||
|
type Archetype = (typeof ARCHETYPES)[number];
|
||||||
|
|
||||||
|
interface ListmonkSubscriber {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListmonkSearchResult {
|
||||||
|
data?: {
|
||||||
|
results?: ListmonkSubscriber[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuth(): string {
|
||||||
|
return (
|
||||||
|
"Basic " +
|
||||||
|
Buffer.from(
|
||||||
|
`${process.env.LISTMONK_USER}:${process.env.LISTMONK_TOKEN}`,
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listmonkUrl(path: string): string {
|
||||||
|
return `${process.env.LISTMONK_URL}${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getListId(): number {
|
||||||
|
return Number(process.env.LISTMONK_QUIZ_LIST_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidEmail(email: string): boolean {
|
||||||
|
return /^[^@]+@[^@]+\.[^@]+$/.test(email);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidArchetype(value: string): value is Archetype {
|
||||||
|
return (ARCHETYPES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/internal/quiz-lead
|
||||||
|
*
|
||||||
|
* Receives a completed Typebot quiz lead and idempotently upserts
|
||||||
|
* the subscriber in Listmonk with archetype + score attributes.
|
||||||
|
*
|
||||||
|
* Auth: x-quiz-secret header must match QUIZ_LEAD_SECRET env var.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request): Promise<NextResponse> {
|
||||||
|
// --- Auth ---
|
||||||
|
const secret = req.headers.get("x-quiz-secret");
|
||||||
|
if (!secret || secret !== process.env.QUIZ_LEAD_SECRET) {
|
||||||
|
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Parse body ---
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Validate fields ---
|
||||||
|
const raw = body as Record<string, unknown>;
|
||||||
|
const email = String(raw.email ?? "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const archetype = String(raw.archetype ?? "");
|
||||||
|
const score = Number(raw.score);
|
||||||
|
|
||||||
|
if (!isValidEmail(email) || !isValidArchetype(archetype) || !Number.isFinite(score)) {
|
||||||
|
return NextResponse.json({ error: "invalid fields" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const attribs = {
|
||||||
|
archetype,
|
||||||
|
score,
|
||||||
|
utm_source: raw.utm_source != null ? String(raw.utm_source) : null,
|
||||||
|
utm_medium: raw.utm_medium != null ? String(raw.utm_medium) : null,
|
||||||
|
utm_campaign: raw.utm_campaign != null ? String(raw.utm_campaign) : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const auth = buildAuth();
|
||||||
|
const listId = getListId();
|
||||||
|
|
||||||
|
// --- Upsert to Listmonk ---
|
||||||
|
try {
|
||||||
|
const createRes = await fetch(listmonkUrl("/api/subscribers"), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
name: email.split("@")[0],
|
||||||
|
lists: [listId],
|
||||||
|
attribs,
|
||||||
|
status: "enabled",
|
||||||
|
preconfirm_subscriptions: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createRes.status === 409) {
|
||||||
|
// Subscriber exists — look up by email and update
|
||||||
|
const searchRes = await fetch(
|
||||||
|
listmonkUrl("/api/subscribers?query=") +
|
||||||
|
encodeURIComponent(`subscribers.email='${email}'`),
|
||||||
|
{ headers: { Authorization: auth } },
|
||||||
|
);
|
||||||
|
const searchJson = (await searchRes.json()) as ListmonkSearchResult;
|
||||||
|
const existingId = searchJson?.data?.results?.[0]?.id;
|
||||||
|
|
||||||
|
if (!existingId) {
|
||||||
|
console.error("quiz-lead: subscriber lookup failed for", email);
|
||||||
|
return NextResponse.json({ error: "lookup failed" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateRes = await fetch(listmonkUrl(`/api/subscribers/${existingId}`), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
name: email.split("@")[0],
|
||||||
|
lists: [listId],
|
||||||
|
attribs,
|
||||||
|
status: "enabled",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updateRes.ok) {
|
||||||
|
console.error(
|
||||||
|
"quiz-lead: subscriber update failed",
|
||||||
|
updateRes.status,
|
||||||
|
await updateRes.text(),
|
||||||
|
);
|
||||||
|
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||||
|
}
|
||||||
|
} else if (!createRes.ok) {
|
||||||
|
console.error(
|
||||||
|
"quiz-lead: listmonk create failed",
|
||||||
|
createRes.status,
|
||||||
|
await createRes.text(),
|
||||||
|
);
|
||||||
|
return NextResponse.json({ error: "listmonk" }, { status: 502 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("quiz-lead: upstream error", e);
|
||||||
|
return NextResponse.json({ error: "upstream" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Streams a lesson material from the CDN with a forced attachment header and a
|
||||||
|
// human-readable filename. Direct CDN links open the PDF inline in a new tab
|
||||||
|
// (no Content-Disposition), which reads as "can't download" for users with a
|
||||||
|
// popup blocker or on mobile. This route makes the browser save the file.
|
||||||
|
|
||||||
|
const CONTENT_TYPES: Record<string, string> = {
|
||||||
|
pdf: "application/pdf",
|
||||||
|
zip: "application/zip",
|
||||||
|
doc: "application/msword",
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
ppt: "application/vnd.ms-powerpoint",
|
||||||
|
pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
xls: "application/vnd.ms-excel",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
webp: "image/webp",
|
||||||
|
};
|
||||||
|
|
||||||
|
function extFromUrl(url: string): string {
|
||||||
|
const clean = url.split("?")[0];
|
||||||
|
const dot = clean.lastIndexOf(".");
|
||||||
|
return dot >= 0 ? clean.slice(dot + 1).toLowerCase() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types a browser renders inline (so the direct link "opens in a tab" instead of
|
||||||
|
// downloading). Only these need proxying with a forced attachment header. ZIP,
|
||||||
|
// DOCX, etc. already download natively → redirect straight to the CDN.
|
||||||
|
const INLINE_EXT = new Set([
|
||||||
|
"pdf", "png", "jpg", "jpeg", "webp", "gif", "svg", "avif",
|
||||||
|
"txt", "htm", "html", "mp4", "mov", "webm", "mp3", "wav",
|
||||||
|
]);
|
||||||
|
// Above this, don't proxy through the app — redirect so the file streams from
|
||||||
|
// the CDN/Caddy with Range support (e.g. the 157 MB Second Brain Vault archive).
|
||||||
|
const PROXY_MAX_BYTES = 50 * 1024 * 1024;
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
_req: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const file = await prisma.lessonFile.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { lesson: { include: { module: { select: { courseId: true } } } } },
|
||||||
|
});
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json({ error: "not_found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access: staff always; students must be enrolled in the file's course.
|
||||||
|
const role = session.user.role;
|
||||||
|
if (role !== "admin" && role !== "curator") {
|
||||||
|
const enrollment = await prisma.courseEnrollment.findFirst({
|
||||||
|
where: { userId: session.user.id, courseId: file.lesson.module.courseId },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
if (!enrollment) {
|
||||||
|
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = extFromUrl(file.url);
|
||||||
|
|
||||||
|
// Non-inline types download natively — send the browser straight to the CDN
|
||||||
|
// (native download, Range/resume, no app load). Auth is already enforced above.
|
||||||
|
if (!INLINE_EXT.has(ext)) {
|
||||||
|
return NextResponse.redirect(file.url, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstream = await fetch(file.url);
|
||||||
|
if (!upstream.ok || !upstream.body) {
|
||||||
|
return NextResponse.json({ error: "file_unavailable" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard: a very large inline file still shouldn't stream through the app.
|
||||||
|
const upstreamLen = Number(upstream.headers.get("content-length") ?? "0");
|
||||||
|
if (upstreamLen > PROXY_MAX_BYTES) {
|
||||||
|
await upstream.body.cancel();
|
||||||
|
return NextResponse.redirect(file.url, 302);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType =
|
||||||
|
upstream.headers.get("content-type") ??
|
||||||
|
CONTENT_TYPES[ext] ??
|
||||||
|
"application/octet-stream";
|
||||||
|
|
||||||
|
// Build a readable filename: "<name>.<ext>" (avoid doubling the extension).
|
||||||
|
let downloadName = file.name.trim();
|
||||||
|
if (ext && !downloadName.toLowerCase().endsWith(`.${ext}`)) {
|
||||||
|
downloadName += `.${ext}`;
|
||||||
|
}
|
||||||
|
const asciiName = downloadName.replace(/[^\x20-\x7E]+/g, "_").replace(/"/g, "");
|
||||||
|
const disposition =
|
||||||
|
`attachment; filename="${asciiName}"; ` +
|
||||||
|
`filename*=UTF-8''${encodeURIComponent(downloadName)}`;
|
||||||
|
|
||||||
|
const respHeaders: Record<string, string> = {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": disposition,
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
};
|
||||||
|
const len = upstream.headers.get("content-length");
|
||||||
|
if (len) respHeaders["Content-Length"] = len;
|
||||||
|
|
||||||
|
return new NextResponse(upstream.body, { status: 200, headers: respHeaders });
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
BURST_LIMIT, PDF_TOOL_ID, getBurstUsage, getMonthlyLimit, getMonthlyUsage, hasPaidAccess,
|
||||||
|
} from "@/lib/clean-pdf/access";
|
||||||
|
import { BlockedUrlError } from "@/lib/clean-pdf/ssrf";
|
||||||
|
import { EmptyContentError, RenderError, generateCleanPdf } from "@/lib/clean-pdf/generate";
|
||||||
|
import { safePdfFilename } from "@/lib/clean-pdf/template";
|
||||||
|
import { PDF_KEY_PREFIX } from "@/lib/clean-pdf/api-key";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const querySchema = z.object({
|
||||||
|
url: z.string().min(1).max(2000),
|
||||||
|
format: z.enum(["A4", "Letter"]).default("A4"),
|
||||||
|
theme: z.enum(["light", "dark"]).default("light"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function jsonError(status: number, error: string, extra?: Record<string, string>) {
|
||||||
|
return NextResponse.json({ error }, { status, headers: extra });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveUserId(req: NextRequest): Promise<string | null> {
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (authHeader?.startsWith("Bearer ")) {
|
||||||
|
const key = authHeader.slice("Bearer ".length).trim();
|
||||||
|
if (!key.startsWith(PDF_KEY_PREFIX)) return null;
|
||||||
|
const record = await prisma.pdfApiKey.findUnique({ where: { key }, select: { userId: true } });
|
||||||
|
return record?.userId ?? null;
|
||||||
|
}
|
||||||
|
const session = await auth.api.getSession({ headers: req.headers });
|
||||||
|
return session?.user.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const userId = await resolveUserId(req);
|
||||||
|
if (!userId) return jsonError(401, "Нужен API-ключ или вход в аккаунт школы");
|
||||||
|
|
||||||
|
if (!(await hasPaidAccess(userId))) {
|
||||||
|
return jsonError(403, "Инструмент доступен студентам платных курсов школы");
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = querySchema.safeParse(Object.fromEntries(req.nextUrl.searchParams));
|
||||||
|
if (!parsed.success) return jsonError(422, "Проверьте параметры: url, format (A4|Letter), theme (light|dark)");
|
||||||
|
|
||||||
|
if ((await getBurstUsage(userId)) >= BURST_LIMIT) {
|
||||||
|
return jsonError(429, "Слишком часто: подождите минуту", { "X-Max-Uses": String(getMonthlyLimit()) });
|
||||||
|
}
|
||||||
|
const limit = getMonthlyLimit();
|
||||||
|
const used = await getMonthlyUsage(userId);
|
||||||
|
if (used >= limit) {
|
||||||
|
return jsonError(429, `Лимит ${limit} PDF в месяц исчерпан`, {
|
||||||
|
"X-Uses-Count": String(used), "X-Max-Uses": String(limit),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pdf, title } = await generateCleanPdf(parsed.data);
|
||||||
|
await prisma.toolUsage.create({ data: { userId, tool: PDF_TOOL_ID } });
|
||||||
|
|
||||||
|
const filename = safePdfFilename(title);
|
||||||
|
return new NextResponse(new Uint8Array(pdf), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="document.pdf"; filename*=UTF-8''${encodeURIComponent(filename)}.pdf`,
|
||||||
|
"X-Uses-Count": String(used + 1),
|
||||||
|
"X-Max-Uses": String(limit),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof BlockedUrlError || e instanceof EmptyContentError) {
|
||||||
|
return jsonError(422, e.message);
|
||||||
|
}
|
||||||
|
if (e instanceof RenderError) {
|
||||||
|
console.error("[clean-pdf] render", e);
|
||||||
|
return jsonError(504, "Не получилось отрендерить страницу, попробуйте позже");
|
||||||
|
}
|
||||||
|
console.error("[clean-pdf]", e);
|
||||||
|
return jsonError(504, "Не получилось сгенерировать PDF, попробуйте позже");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { createHmac } from "crypto";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход в конструктор планнеров (planner.second-brain.ru).
|
||||||
|
*
|
||||||
|
* Своей авторизации у конструктора нет: школа проверяет сессию и доступ,
|
||||||
|
* подписывает короткоживущий токен, конструктор его сверяет тем же секретом.
|
||||||
|
* Формат токена: base64url(payload).base64url(hmacSha256(payload)) —
|
||||||
|
* зеркало packages/web/src/auth.mjs в репозитории планнера. Менять оба конца.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PLANNER_URL = process.env.PLANNER_URL ?? "https://planner.second-brain.ru";
|
||||||
|
const COURSE_SLUG = "planner";
|
||||||
|
const TTL_SECONDS = 900;
|
||||||
|
|
||||||
|
const b64 = (input: Buffer | string) => Buffer.from(input).toString("base64url");
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const secret = process.env.PLANNER_TOKEN_SECRET;
|
||||||
|
if (!secret) {
|
||||||
|
// Лучше честная ошибка, чем редирект в конструктор с заведомо негодным
|
||||||
|
// токеном: там человек увидит невнятное «подпись не сходится».
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "PLANNER_TOKEN_SECRET не задан" },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session?.user) {
|
||||||
|
return NextResponse.redirect(new URL("/login", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const enrolled = await prisma.courseEnrollment.findFirst({
|
||||||
|
where: { userId: session.user.id, course: { slug: COURSE_SLUG } },
|
||||||
|
select: { expiresAt: true },
|
||||||
|
});
|
||||||
|
if (!enrolled) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||||
|
}
|
||||||
|
if (enrolled.expiresAt && enrolled.expiresAt < new Date()) {
|
||||||
|
return NextResponse.redirect(new URL("/dashboard", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
userId: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
exp: Math.floor(Date.now() / 1000) + TTL_SECONDS,
|
||||||
|
};
|
||||||
|
const body = b64(JSON.stringify(payload));
|
||||||
|
const signature = b64(createHmac("sha256", secret).update(body).digest());
|
||||||
|
|
||||||
|
// Токен во фрагменте, а не в строке запроса: фрагмент не попадает ни в
|
||||||
|
// логи сервера, ни в заголовок Referer при переходах со страницы.
|
||||||
|
return NextResponse.redirect(`${PLANNER_URL}/build.html#token=${body}.${signature}`);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { headers } from "next/headers";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
|
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
|
||||||
|
import { isAllowedPublicUrl } from "@/lib/s3";
|
||||||
|
|
||||||
interface FileAttachment {
|
interface FileAttachment {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,13 +11,6 @@ interface FileAttachment {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildS3Prefix(): string {
|
|
||||||
const endpoint = process.env.S3_ENDPOINT ?? "";
|
|
||||||
const bucket = process.env.S3_BUCKET ?? "";
|
|
||||||
// e.g. https://fsn1.your-objectstorage.com/lms-uploads/
|
|
||||||
return `${endpoint}/${bucket}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function POST(
|
export async function POST(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
@@ -52,14 +46,11 @@ export async function POST(
|
|||||||
return NextResponse.json({ error: "text is required" }, { status: 400 });
|
return NextResponse.json({ error: "text is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const s3Prefix = buildS3Prefix();
|
|
||||||
const safeFiles = files
|
const safeFiles = files
|
||||||
?.filter(
|
?.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
typeof f.name === "string" &&
|
typeof f.name === "string" &&
|
||||||
typeof f.url === "string" &&
|
isAllowedPublicUrl(f.url) &&
|
||||||
f.url.startsWith("https://") &&
|
|
||||||
f.url.startsWith(s3Prefix) &&
|
|
||||||
typeof f.size === "number"
|
typeof f.size === "number"
|
||||||
)
|
)
|
||||||
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
||||||
@@ -87,6 +78,8 @@ export async function POST(
|
|||||||
question.user.name,
|
question.user.name,
|
||||||
question.title,
|
question.title,
|
||||||
id,
|
id,
|
||||||
|
text.trim(),
|
||||||
|
Boolean(safeFiles?.length),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const staff = await prisma.user.findMany({
|
const staff = await prisma.user.findMany({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { headers } from "next/headers";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { sendQuestionCreatedEmail } from "@/lib/email";
|
import { sendQuestionCreatedEmail } from "@/lib/email";
|
||||||
|
import { isAllowedPublicUrl } from "@/lib/s3";
|
||||||
|
|
||||||
interface FileAttachment {
|
interface FileAttachment {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -10,12 +11,6 @@ interface FileAttachment {
|
|||||||
size: number;
|
size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildS3Prefix(): string {
|
|
||||||
const endpoint = process.env.S3_ENDPOINT ?? "";
|
|
||||||
const bucket = process.env.S3_BUCKET ?? "";
|
|
||||||
return `${endpoint}/${bucket}/`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
@@ -82,14 +77,11 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "title and text are required" }, { status: 400 });
|
return NextResponse.json({ error: "title and text are required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const s3Prefix = buildS3Prefix();
|
|
||||||
const safeFiles = files
|
const safeFiles = files
|
||||||
?.filter(
|
?.filter(
|
||||||
(f) =>
|
(f) =>
|
||||||
typeof f.name === "string" &&
|
typeof f.name === "string" &&
|
||||||
typeof f.url === "string" &&
|
isAllowedPublicUrl(f.url) &&
|
||||||
f.url.startsWith("https://") &&
|
|
||||||
f.url.startsWith(s3Prefix) &&
|
|
||||||
typeof f.size === "number"
|
typeof f.size === "number"
|
||||||
)
|
)
|
||||||
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendRegistrationConversion } from "@/lib/ads-conversion";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const disposableDomains = require("disposable-email-domains") as string[];
|
const disposableDomains = require("disposable-email-domains") as string[];
|
||||||
@@ -37,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return jsonError("Неверный формат запроса", 400);
|
return jsonError("Неверный формат запроса", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { name, email, password, website, cfTurnstileResponse } = body;
|
const { name, email, password, website, cfTurnstileResponse, archetype, utmSource, utmMedium, utmCampaign, gclid } = body;
|
||||||
|
|
||||||
// Honeypot — bots fill this field, humans don't see it
|
// Honeypot — bots fill this field, humans don't see it
|
||||||
if (website) {
|
if (website) {
|
||||||
@@ -102,6 +103,20 @@ export async function POST(request: NextRequest) {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
// Тип второго мозга из квиза + UTM — сохраняем на юзере для персонализации
|
||||||
|
const VALID_ARCHETYPES = ["architect", "gardener", "librarian", "pragmatist"];
|
||||||
|
if (user && (archetype || utmSource || utmMedium || utmCampaign || gclid)) {
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
archetype: VALID_ARCHETYPES.includes(String(archetype)) ? String(archetype) : null,
|
||||||
|
utmSource: utmSource ? String(utmSource) : null,
|
||||||
|
utmMedium: utmMedium ? String(utmMedium) : null,
|
||||||
|
utmCampaign: utmCampaign ? String(utmCampaign) : null,
|
||||||
|
gclid: gclid ? String(gclid) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (user && course) {
|
if (user && course) {
|
||||||
await prisma.courseEnrollment.upsert({
|
await prisma.courseEnrollment.upsert({
|
||||||
where: { userId_courseId: { userId: user.id, courseId: course.id } },
|
where: { userId_courseId: { userId: user.id, courseId: course.id } },
|
||||||
@@ -116,6 +131,10 @@ export async function POST(request: NextRequest) {
|
|||||||
method: "free-signup",
|
method: "free-signup",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Конверсия в Google Ads — фоном, регистрацию не задерживаем и не роняем.
|
||||||
|
if (gclid) {
|
||||||
|
void sendRegistrationConversion(String(gclid));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("free-enroll failed", e);
|
console.error("free-enroll failed", e);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
// Images only: comments render them inline, and anything else would just be an
|
||||||
|
// unlabelled blob in the middle of a discussion.
|
||||||
|
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/gif", "image/webp"]);
|
||||||
|
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
|
||||||
|
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB — enough for a screenshot
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "Изображение слишком большое (макс. 5 МБ)" }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_TYPES.has(file.type)) {
|
||||||
|
return NextResponse.json({ error: "Разрешены только изображения: jpg, png, gif, webp" }, { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
||||||
|
if (!ALLOWED_EXTS.has(ext)) {
|
||||||
|
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `comments/${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 });
|
||||||
|
}
|
||||||
@@ -4,10 +4,25 @@ import { auth } from "@/lib/auth";
|
|||||||
import { uploadFile } from "@/lib/s3";
|
import { uploadFile } from "@/lib/s3";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
// Расширение — основной критерий: Windows не знает MIME-тип для .md и отдаёт
|
||||||
|
// пустую строку или application/octet-stream, из-за чего заметки не прикреплялись.
|
||||||
|
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
|
||||||
|
|
||||||
|
// MIME проверяем дополнительно — но только когда браузер его вообще прислал
|
||||||
|
// и это не обобщённый «просто файл».
|
||||||
const ALLOWED_TYPES = new Set([
|
const ALLOWED_TYPES = new Set([
|
||||||
"image/jpeg", "image/png", "image/gif", "image/webp",
|
"image/jpeg", "image/png", "image/gif", "image/webp",
|
||||||
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
|
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
|
||||||
]);
|
]);
|
||||||
|
const GENERIC_TYPES = new Set(["", "application/octet-stream", "binary/octet-stream"]);
|
||||||
|
|
||||||
|
// Для отдачи файла браузеру подставляем корректный тип, если своего не было
|
||||||
|
const EXT_TO_TYPE: Record<string, string> = {
|
||||||
|
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png",
|
||||||
|
gif: "image/gif", webp: "image/webp", pdf: "application/pdf",
|
||||||
|
md: "text/markdown", txt: "text/plain",
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -22,22 +37,29 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
|
return NextResponse.json({ error: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ALLOWED_TYPES.has(file.type)) {
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
if (!ALLOWED_EXTS.has(ext)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Разрешены только jpg, png, pdf, md" },
|
{ error: "Разрешены только jpg, png, gif, webp, pdf, md, txt" },
|
||||||
{ status: 415 }
|
{ status: 415 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
const declaredType = (file.type || "").toLowerCase();
|
||||||
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
|
if (!GENERIC_TYPES.has(declaredType) && !ALLOWED_TYPES.has(declaredType)) {
|
||||||
if (!ALLOWED_EXTS.has(ext)) {
|
return NextResponse.json(
|
||||||
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
|
{ error: "Разрешены только jpg, png, gif, webp, pdf, md, txt" },
|
||||||
|
{ status: 415 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contentType = GENERIC_TYPES.has(declaredType)
|
||||||
|
? (EXT_TO_TYPE[ext] ?? "application/octet-stream")
|
||||||
|
: declaredType;
|
||||||
|
|
||||||
const key = `questions/${session.user.id}/${randomUUID()}.${ext}`;
|
const key = `questions/${session.user.id}/${randomUUID()}.${ext}`;
|
||||||
const buffer = Buffer.from(await file.arrayBuffer());
|
const buffer = Buffer.from(await file.arrayBuffer());
|
||||||
const url = await uploadFile(key, buffer, file.type);
|
const url = await uploadFile(key, buffer, contentType);
|
||||||
|
|
||||||
return NextResponse.json({ name: file.name, url, size: file.size });
|
return NextResponse.json({ name: file.name, url, size: file.size });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
|
||||||
|
// Пишет агрегаты прогона Wrapped. userId — строго из сессии, не из тела.
|
||||||
|
// Имена/тексты заметок сюда не приходят (только числа/строки-агрегаты).
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
|
||||||
|
let b: {
|
||||||
|
period?: string;
|
||||||
|
noteCount?: number;
|
||||||
|
linkCount?: number;
|
||||||
|
tagCount?: number;
|
||||||
|
topTag?: string | null;
|
||||||
|
archetype?: string | null;
|
||||||
|
score?: number;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
b = await req.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "bad json" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const period = ["year", "quarter", "all"].includes(String(b.period)) ? String(b.period) : "year";
|
||||||
|
const VALID = ["architect", "gardener", "librarian", "pragmatist"];
|
||||||
|
|
||||||
|
const run = await prisma.wrappedRun.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user.id,
|
||||||
|
period,
|
||||||
|
noteCount: Number(b.noteCount) || 0,
|
||||||
|
linkCount: Number(b.linkCount) || 0,
|
||||||
|
tagCount: Number(b.tagCount) || 0,
|
||||||
|
topTag: b.topTag ? String(b.topTag).slice(0, 60) : null,
|
||||||
|
archetype: VALID.includes(String(b.archetype)) ? String(b.archetype) : null,
|
||||||
|
score: Math.max(0, Math.min(100, Number(b.score) || 0)),
|
||||||
|
shareToken: randomBytes(9).toString("base64url"),
|
||||||
|
},
|
||||||
|
select: { shareToken: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ shareToken: run.shareToken });
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export default async function CuratorDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pending > 0 ? (
|
{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">
|
<Link href="/curator/homework?status=pending" className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 px-5 py-2.5 text-sm">
|
||||||
Перейти к проверке ({pending}) →
|
Перейти к проверке ({pending}) →
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -108,6 +108,51 @@ export async function setReviewing(submissionId: string) {
|
|||||||
revalidatePath(`/curator/homework/${submissionId}`);
|
revalidatePath(`/curator/homework/${submissionId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принять ДЗ без письменного ответа: ставим APPROVED и засчитываем урок,
|
||||||
|
* фидбек не создаём и студенту письмо не шлём (тихое принятие).
|
||||||
|
*/
|
||||||
|
export async function approveWithoutFeedback(submissionId: string) {
|
||||||
|
await requireCurator();
|
||||||
|
|
||||||
|
const submission = await prisma.homeworkSubmission.findUnique({
|
||||||
|
where: { id: submissionId },
|
||||||
|
include: {
|
||||||
|
homework: {
|
||||||
|
include: {
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
module: { select: { course: { select: { slug: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!submission) throw new Error("Submission not found");
|
||||||
|
|
||||||
|
const lesson = submission.homework.lesson;
|
||||||
|
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.homeworkSubmission.update({
|
||||||
|
where: { id: submissionId },
|
||||||
|
data: { status: "APPROVED", statusAt: new Date() },
|
||||||
|
});
|
||||||
|
await tx.lessonProgress.upsert({
|
||||||
|
where: { userId_lessonId: { userId: submission.userId, lessonId: lesson.id } },
|
||||||
|
create: { userId: submission.userId, lessonId: lesson.id },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/curator/homework");
|
||||||
|
revalidatePath(`/curator/homework/${submissionId}`);
|
||||||
|
revalidatePath(`/courses/${lesson.module.course.slug}/lessons/${lesson.id}`);
|
||||||
|
revalidatePath(`/courses/${lesson.module.course.slug}`);
|
||||||
|
revalidatePath("/dashboard");
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteSubmission(submissionId: string) {
|
export async function deleteSubmission(submissionId: string) {
|
||||||
await requireCurator();
|
await requireCurator();
|
||||||
await prisma.homeworkSubmission.delete({ where: { id: submissionId } });
|
await prisma.homeworkSubmission.delete({ where: { id: submissionId } });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useTransition, useRef } from "react";
|
import { useState, useTransition, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { submitFeedback, setReviewing } from "./actions";
|
import { submitFeedback, setReviewing, approveWithoutFeedback } from "./actions";
|
||||||
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
@@ -69,6 +69,13 @@ export function FeedbackForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleApproveNoFeedback() {
|
||||||
|
startTransition(async () => {
|
||||||
|
await approveWithoutFeedback(submissionId);
|
||||||
|
router.push("/curator/homework");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const isWorking = pending || uploading;
|
const isWorking = pending || uploading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -164,6 +171,17 @@ export function FeedbackForm({
|
|||||||
{pending ? "Отправка..." : "Отправить ответ"}
|
{pending ? "Отправка..." : "Отправить ответ"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleApproveNoFeedback}
|
||||||
|
disabled={isWorking}
|
||||||
|
className="btn-aubade px-4 py-2 text-sm"
|
||||||
|
style={{ opacity: isWorking ? 0.5 : 1 }}
|
||||||
|
title="Засчитать ДЗ без письменного ответа"
|
||||||
|
>
|
||||||
|
Принять и отметить выполненным
|
||||||
|
</button>
|
||||||
|
|
||||||
{currentStatus !== "REVIEWING" && (
|
{currentStatus !== "REVIEWING" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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 { linkify } from "@/lib/linkify";
|
||||||
import { FeedbackForm } from "./feedback-form";
|
import { FeedbackForm } from "./feedback-form";
|
||||||
import { ContentTabs } from "./content-tabs";
|
import { ContentTabs } from "./content-tabs";
|
||||||
import { DeleteSubmissionButton } from "./delete-button";
|
import { DeleteSubmissionButton } from "./delete-button";
|
||||||
@@ -151,7 +152,7 @@ export default async function SubmissionPage({ params }: Props) {
|
|||||||
className="px-4 py-3 text-sm whitespace-pre-wrap"
|
className="px-4 py-3 text-sm whitespace-pre-wrap"
|
||||||
style={{ border: "2px solid var(--border)" }}
|
style={{ border: "2px solid var(--border)" }}
|
||||||
>
|
>
|
||||||
{submission.text}
|
{linkify(submission.text)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
|||||||
@@ -166,9 +166,22 @@ export default async function HomeworkListPage({ searchParams }: Props) {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{submissions.map((s) => {
|
{submissions.map((s) => {
|
||||||
const hasReview = s.feedbacks.length > 0;
|
const hasReview = s.feedbacks.length > 0;
|
||||||
const reviewBadge = hasReview
|
// Метка по статусу проверки, а не только по наличию письменного отзыва:
|
||||||
? { label: "С отзывом", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" }
|
// принятое кнопкой ДЗ (APPROVED без фидбека) — это «Принято», не «Без ответа».
|
||||||
: { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
|
let reviewBadge: { label: string; bg: string; color: string };
|
||||||
|
if (s.status === "APPROVED") {
|
||||||
|
reviewBadge = {
|
||||||
|
label: hasReview ? "С отзывом" : "Принято",
|
||||||
|
bg: "oklch(0.88 0.1 145)",
|
||||||
|
color: "oklch(0.35 0.15 145)",
|
||||||
|
};
|
||||||
|
} else if (s.status === "REJECTED") {
|
||||||
|
reviewBadge = { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)" };
|
||||||
|
} else if (s.status === "REVIEWING") {
|
||||||
|
reviewBadge = { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)" };
|
||||||
|
} else {
|
||||||
|
reviewBadge = { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={s.id}
|
key={s.id}
|
||||||
|
|||||||
@@ -241,3 +241,60 @@
|
|||||||
border-left-color: #E8F0D8;
|
border-left-color: #E8F0D8;
|
||||||
background-color: var(--sidebar-surface);
|
background-color: var(--sidebar-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Markdown-разметка в комментариях под уроками (react-markdown) */
|
||||||
|
/* Таблицы GFM: широкая таблица прокручивается внутри себя, а не растягивает страницу */
|
||||||
|
.comment-md table {
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
.comment-md th,
|
||||||
|
.comment-md td {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.comment-md th { font-weight: 700; background: var(--color-surface); }
|
||||||
|
.comment-md del { opacity: 0.6; }
|
||||||
|
.comment-md img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.comment-md > :first-child { margin-top: 0; }
|
||||||
|
.comment-md > :last-child { margin-bottom: 0; }
|
||||||
|
.comment-md p { margin: 0 0 0.5rem; }
|
||||||
|
.comment-md strong { font-weight: 700; }
|
||||||
|
.comment-md em { font-style: italic; }
|
||||||
|
.comment-md a { color: var(--foreground); text-decoration: underline; text-underline-offset: 2px; }
|
||||||
|
.comment-md ul { list-style: disc; padding-left: 1.4rem; margin: 0 0 0.5rem; }
|
||||||
|
.comment-md ol { list-style: decimal; padding-left: 1.4rem; margin: 0 0 0.5rem; }
|
||||||
|
.comment-md li { margin: 0.15rem 0; }
|
||||||
|
.comment-md blockquote {
|
||||||
|
border-left: 2px solid var(--border);
|
||||||
|
padding-left: 0.75rem;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
.comment-md code {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 0.05rem 0.3rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.comment-md pre {
|
||||||
|
background: var(--color-surface);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
.comment-md pre code { background: none; padding: 0; }
|
||||||
|
|||||||
+6
-6
@@ -2,6 +2,7 @@ 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";
|
import { getSettings } from "@/lib/settings";
|
||||||
|
import { HtmlInjector } from "@/components/layout/html-injector";
|
||||||
|
|
||||||
const firaMono = Fira_Mono({
|
const firaMono = Fira_Mono({
|
||||||
weight: ["400", "500", "700"],
|
weight: ["400", "500", "700"],
|
||||||
@@ -28,14 +29,13 @@ export default async function RootLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
|
<html lang="ru" className={`${firaMono.variable} h-full antialiased`}>
|
||||||
{settings.headCode ? (
|
|
||||||
<head dangerouslySetInnerHTML={{ __html: settings.headCode }} />
|
|
||||||
) : null}
|
|
||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
|
{/* Код внедрения инжектится через DOM после гидрации (HtmlInjector),
|
||||||
|
а НЕ через <head dangerouslySetInnerHTML> — иначе ручной <head>
|
||||||
|
затирает хойстнутый React'ом <link rel="stylesheet"> и слетает дизайн. */}
|
||||||
|
{settings.headCode ? <HtmlInjector html={settings.headCode} target="head" /> : null}
|
||||||
{children}
|
{children}
|
||||||
{settings.bodyCode ? (
|
{settings.bodyCode ? <HtmlInjector html={settings.bodyCode} target="body" /> : null}
|
||||||
<div dangerouslySetInnerHTML={{ __html: settings.bodyCode }} />
|
|
||||||
) : null}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
|
import { WelcomePage } from "@/components/welcome/welcome-page";
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
redirect("/login");
|
return <WelcomePage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = session.user.role;
|
const role = session.user.role;
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { ImageResponse } from "next/og";
|
||||||
|
import { readFile } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { wrappedCard } from "@/lib/wrapped-card";
|
||||||
|
import type { Archetype, Period } from "@/app/(student)/wrapped/_client/types";
|
||||||
|
|
||||||
|
export const size = { width: 1080, height: 1920 };
|
||||||
|
export const contentType = "image/png";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
// Серверный PNG 1080×1920 карточки Wrapped по shareToken (для OG-превью в соцсетях
|
||||||
|
// и для скачивания участником). Рендерится через next/og (Satori + resvg-wasm).
|
||||||
|
export default async function OG({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await params;
|
||||||
|
const run = await prisma.wrappedRun.findUnique({ where: { shareToken: token } });
|
||||||
|
|
||||||
|
const [reg, med] = await Promise.all([
|
||||||
|
readFile(path.join(process.cwd(), "public/fonts/FiraMono-Regular.ttf")),
|
||||||
|
readFile(path.join(process.cwd(), "public/fonts/FiraMono-Medium.ttf")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = run
|
||||||
|
? {
|
||||||
|
noteCount: run.noteCount,
|
||||||
|
linkCount: run.linkCount,
|
||||||
|
tagCount: run.tagCount,
|
||||||
|
topTag: run.topTag,
|
||||||
|
archetype: run.archetype as Archetype | null,
|
||||||
|
score: run.score,
|
||||||
|
period: run.period as Period,
|
||||||
|
}
|
||||||
|
: { noteCount: 0, linkCount: 0, tagCount: 0, topTag: null, archetype: null, score: 0, period: "year" as Period };
|
||||||
|
|
||||||
|
return new ImageResponse(wrappedCard(data), {
|
||||||
|
...size,
|
||||||
|
fonts: [
|
||||||
|
{ name: "Fira Mono", data: reg, weight: 400, style: "normal" as const },
|
||||||
|
{ name: "Fira Mono", data: med, weight: 500, style: "normal" as const },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
const ARCHETYPE_RU: Record<string, string> = {
|
||||||
|
architect: "Архитектор",
|
||||||
|
gardener: "Садовник",
|
||||||
|
librarian: "Библиотекарь",
|
||||||
|
pragmatist: "Прагматик",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }: { params: Promise<{ token: string }> }): Promise<Metadata> {
|
||||||
|
const { token } = await params;
|
||||||
|
return {
|
||||||
|
title: "Моя карта знаний в Obsidian — Second Brain",
|
||||||
|
description: "Я собрал статистику своего хранилища знаний. Сделай свою карточку.",
|
||||||
|
openGraph: {
|
||||||
|
title: "Моя карта знаний в Obsidian",
|
||||||
|
description: "Statистика хранилища в Obsidian — Second Brain Wrapped.",
|
||||||
|
images: [{ url: `/share/wrapped/${token}/opengraph-image`, width: 1080, height: 1920 }],
|
||||||
|
},
|
||||||
|
twitter: { card: "summary_large_image" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Публичная страница карточки (без сессии). Показывает только агрегаты — никаких имён заметок.
|
||||||
|
export default async function SharePage({ params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await params;
|
||||||
|
const run = await prisma.wrappedRun.findUnique({ where: { shareToken: token } });
|
||||||
|
if (!run) notFound();
|
||||||
|
|
||||||
|
const metric = (n: number, l: string) => (
|
||||||
|
<div
|
||||||
|
key={l}
|
||||||
|
style={{ display: "flex", flexDirection: "column", alignItems: "center", padding: "16px 12px", background: "#FFFFFF", border: "2px solid var(--border)", boxShadow: "4px 4px 0 0 var(--border)" }}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: 32, fontWeight: 700 }}>{n}</span>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--muted-foreground)", textTransform: "uppercase", letterSpacing: 1 }}>{l}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ background: "var(--background)", color: "var(--foreground)", minHeight: "100vh", display: "flex", flexDirection: "column", alignItems: "center", padding: "48px 24px" }}>
|
||||||
|
<div style={{ width: "100%", maxWidth: 480 }}>
|
||||||
|
<p style={{ fontSize: 12, fontWeight: 700, textTransform: "uppercase", letterSpacing: 3, color: "var(--muted-foreground)", marginBottom: 8 }}>
|
||||||
|
Obsidian Wrapped · 2026
|
||||||
|
</p>
|
||||||
|
<h1 style={{ fontSize: 28, fontWeight: 700, margin: "0 0 4px" }}>
|
||||||
|
{run.archetype ? ARCHETYPE_RU[run.archetype] : "Карта знаний"}
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: 13, textTransform: "uppercase", letterSpacing: 2, color: "var(--muted-foreground)", margin: "0 0 24px" }}>
|
||||||
|
зрелость {run.score}/100
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 12, marginBottom: 20 }}>
|
||||||
|
{metric(run.noteCount, "заметок")}
|
||||||
|
{metric(run.linkCount, "связей")}
|
||||||
|
{metric(run.tagCount, "тегов")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{run.topTag && (
|
||||||
|
<div style={{ padding: "16px 20px", background: "var(--accent)", border: "2px solid var(--foreground)", marginBottom: 24, fontSize: 16 }}>
|
||||||
|
Топ-тег: #{run.topTag}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
|
<Link
|
||||||
|
href="/register"
|
||||||
|
className="btn-aubade btn-aubade-accent"
|
||||||
|
style={{ textAlign: "center", fontSize: 15, fontWeight: 700, padding: "14px" }}
|
||||||
|
>
|
||||||
|
Сделать свою карточку →
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`/share/wrapped/${token}/opengraph-image`}
|
||||||
|
download={`obsidian-wrapped-${token}.png`}
|
||||||
|
className="btn-aubade"
|
||||||
|
style={{ textAlign: "center", fontSize: 14, padding: "12px" }}
|
||||||
|
>
|
||||||
|
Скачать картинку (1080×1920)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 12, color: "var(--muted-foreground)", marginTop: 28, textAlign: "center" }}>
|
||||||
|
🔒 Здесь только числа. Содержимое заметок остаётся на устройстве автора.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -274,6 +274,11 @@ export function SettingsForm({ initial }: { initial: Settings }) {
|
|||||||
checked={bool("notifyOnHomework")}
|
checked={bool("notifyOnHomework")}
|
||||||
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
|
onChange={(v) => set("notifyOnHomework", v ? "true" : "false")}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Уведомлять о новом комментарии под уроком"
|
||||||
|
checked={bool("notifyOnComment")}
|
||||||
|
onChange={(v) => set("notifyOnComment", v ? "true" : "false")}
|
||||||
|
/>
|
||||||
<Toggle
|
<Toggle
|
||||||
label="Уведомлять о новой регистрации ученика"
|
label="Уведомлять о новой регистрации ученика"
|
||||||
checked={bool("notifyOnRegistration")}
|
checked={bool("notifyOnRegistration")}
|
||||||
@@ -284,6 +289,11 @@ export function SettingsForm({ initial }: { initial: Settings }) {
|
|||||||
checked={bool("notifyStudentOnFeedback")}
|
checked={bool("notifyStudentOnFeedback")}
|
||||||
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
|
onChange={(v) => set("notifyStudentOnFeedback", v ? "true" : "false")}
|
||||||
/>
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Уведомлять ученика об ответе на его комментарий"
|
||||||
|
checked={bool("notifyStudentOnCommentReply")}
|
||||||
|
onChange={(v) => set("notifyStudentOnCommentReply", v ? "true" : "false")}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Безопасно инжектит произвольный HTML (код внедрения из Настроек) в <head> или
|
||||||
|
* <body> ЧЕРЕЗ DOM после гидрации.
|
||||||
|
*
|
||||||
|
* Почему не <head dangerouslySetInnerHTML>: в App Router + React 19 ручной <head>
|
||||||
|
* при гидрации затирает хойстнутый React'ом <link rel="stylesheet"> → весь дизайн
|
||||||
|
* слетает. Этот компонент добавляет узлы программно, не трогая управление head,
|
||||||
|
* поэтому стили остаются на месте. <script> из innerHTML не исполняются — поэтому
|
||||||
|
* пересоздаём их как настоящие script-элементы.
|
||||||
|
*/
|
||||||
|
export function HtmlInjector({ html, target }: { html: string; target: "head" | "body" }) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!html) return;
|
||||||
|
const dest = target === "head" ? document.head : document.body;
|
||||||
|
const tpl = document.createElement("template");
|
||||||
|
tpl.innerHTML = html;
|
||||||
|
|
||||||
|
const added: Node[] = [];
|
||||||
|
Array.from(tpl.content.childNodes).forEach((node) => {
|
||||||
|
let toAppend: Node;
|
||||||
|
if (node.nodeName === "SCRIPT") {
|
||||||
|
const orig = node as HTMLScriptElement;
|
||||||
|
const s = document.createElement("script");
|
||||||
|
for (const attr of Array.from(orig.attributes)) s.setAttribute(attr.name, attr.value);
|
||||||
|
s.textContent = orig.textContent;
|
||||||
|
toAppend = s;
|
||||||
|
} else {
|
||||||
|
toAppend = node.cloneNode(true);
|
||||||
|
}
|
||||||
|
dest.appendChild(toAppend);
|
||||||
|
added.push(toAppend);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
added.forEach((n) => n.parentNode?.removeChild(n));
|
||||||
|
};
|
||||||
|
}, [html, target]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// Персональный блок «твой путь» по типу второго мозга из квиза.
|
||||||
|
// Server component, без клиентского кода. ДС-2 (Aubade).
|
||||||
|
|
||||||
|
const TRIPWIRE_URL = "https://second-brain.ru/entrepreneur";
|
||||||
|
|
||||||
|
const ARCHETYPE_INFO: Record<
|
||||||
|
string,
|
||||||
|
{ emoji: string; name: string; desc: string; cta: string; href: string }
|
||||||
|
> = {
|
||||||
|
architect: {
|
||||||
|
emoji: "🏛",
|
||||||
|
name: "Архитектор",
|
||||||
|
desc: "Ты мыслишь системами: структура, иерархия, порядок. Тебе подойдёт системный путь.",
|
||||||
|
cta: "Полный курс «Obsidian»",
|
||||||
|
href: "https://obsidian.second-brain.ru/",
|
||||||
|
},
|
||||||
|
gardener: {
|
||||||
|
emoji: "🌱",
|
||||||
|
name: "Садовник",
|
||||||
|
desc: "Ты выращиваешь идеи и связи. Тебе подойдёт работа с мышлением и AI.",
|
||||||
|
cta: "Курс «Obsidian + AI»",
|
||||||
|
href: "https://obsidianai.second-brain.ru/",
|
||||||
|
},
|
||||||
|
librarian: {
|
||||||
|
emoji: "📚",
|
||||||
|
name: "Библиотекарь",
|
||||||
|
desc: "Ты собираешь и хранишь надёжно. Начни с готового хранилища и руководств.",
|
||||||
|
cta: "Демо-хранилище",
|
||||||
|
href: "https://second-brain.ru/sb-vault",
|
||||||
|
},
|
||||||
|
pragmatist: {
|
||||||
|
emoji: "⚡",
|
||||||
|
name: "Прагматик",
|
||||||
|
desc: "Тебе важен результат: заметки работают на дело, а не ради заметок.",
|
||||||
|
cta: "Второй мозг для предпринимателя",
|
||||||
|
href: TRIPWIRE_URL,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ArchetypeBanner({ archetype }: { archetype: string }) {
|
||||||
|
const info = ARCHETYPE_INFO[archetype];
|
||||||
|
if (!info) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card-aubade p-5 mb-8" style={{ background: "var(--accent)" }}>
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-1"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Твой тип второго мозга
|
||||||
|
</p>
|
||||||
|
<h2 className="font-bold text-lg mb-1">
|
||||||
|
{info.emoji} {info.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mb-3" style={{ color: "var(--foreground)" }}>
|
||||||
|
{info.desc} Начни с бесплатного курса «Obsidian. Старт» ниже — а когда захочешь дальше, тебе откроется:
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={info.href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-aubade text-sm inline-block"
|
||||||
|
>
|
||||||
|
{info.cta} →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ym?: (counterId: number, action: string, goal: string) => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фиксирует показ gate-лестницы в Яндекс.Метрике (цель gate_view).
|
||||||
|
export function GateViewTracker() {
|
||||||
|
useEffect(() => {
|
||||||
|
const c = Number(process.env.NEXT_PUBLIC_METRIKA_COUNTER_ID);
|
||||||
|
if (c && typeof window !== "undefined" && typeof window.ym === "function") {
|
||||||
|
window.ym(c, "reachGoal", "gate_view");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { submitHomework } from "@/lib/actions/student-actions";
|
import { submitHomework } from "@/lib/actions/student-actions";
|
||||||
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
import { AudioRecorder } from "@/components/curator/audio-recorder";
|
||||||
|
import { linkify } from "@/lib/linkify";
|
||||||
|
|
||||||
interface HWFile { name: string; url: string; size: number }
|
interface HWFile { name: string; url: string; size: number }
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ interface Submission {
|
|||||||
files: HWFile[];
|
files: HWFile[];
|
||||||
audioUrl?: string | null;
|
audioUrl?: string | null;
|
||||||
submittedAt: Date;
|
submittedAt: Date;
|
||||||
|
status: string;
|
||||||
feedbacks: Feedback[];
|
feedbacks: Feedback[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
|||||||
const [editing, setEditing] = useState(!submission);
|
const [editing, setEditing] = useState(!submission);
|
||||||
|
|
||||||
const isReviewed = submission && submission.feedbacks.length > 0;
|
const isReviewed = submission && submission.feedbacks.length > 0;
|
||||||
|
const isApproved = submission?.status === "APPROVED";
|
||||||
|
// Финализировано: есть фидбек ИЛИ ДЗ принято кнопкой без ответа. Нельзя пересдавать.
|
||||||
|
const isLocked = Boolean(isReviewed || isApproved);
|
||||||
|
|
||||||
const inputStyle = {
|
const inputStyle = {
|
||||||
border: "2px solid var(--border)",
|
border: "2px solid var(--border)",
|
||||||
@@ -95,7 +100,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>Ваш ответ</p>
|
<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)" }}>
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
||||||
{submission!.text || "—"}
|
{submission!.text ? linkify(submission!.text) : "—"}
|
||||||
</div>
|
</div>
|
||||||
{submission!.audioUrl && (
|
{submission!.audioUrl && (
|
||||||
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
||||||
@@ -114,7 +119,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
|||||||
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
|
<p className="text-sm whitespace-pre-wrap">{linkify(fb.text)}</p>
|
||||||
{fbFiles.length > 0 && (
|
{fbFiles.length > 0 && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{fbFiles.map((f) => (
|
{fbFiles.map((f) => (
|
||||||
@@ -138,8 +143,45 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Принято без письменного ответа */}
|
||||||
|
{isApproved && !isReviewed && submission && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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(--foreground)", background: "var(--accent)" }}>
|
||||||
|
ДЗ принято ✓
|
||||||
|
</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{submission.text && (
|
||||||
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap opacity-70" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
{linkify(submission.text)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{submission.audioUrl && (
|
||||||
|
<div className="px-4 py-2" style={{ border: "1px solid var(--border)" }}>
|
||||||
|
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>Аудио-ответ:</p>
|
||||||
|
<audio controls src={submission.audioUrl} style={{ height: 32, width: "100%" }} />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Submitted, pending review */}
|
{/* Submitted, pending review */}
|
||||||
{submission && !isReviewed && !editing && (
|
{submission && !isLocked && !editing && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -156,7 +198,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
|||||||
</div>
|
</div>
|
||||||
{submission.text && (
|
{submission.text && (
|
||||||
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
|
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
|
||||||
{submission.text}
|
{linkify(submission.text)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{submission.audioUrl && (
|
{submission.audioUrl && (
|
||||||
@@ -182,7 +224,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
{editing && !isReviewed && (
|
{editing && !isLocked && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useRef, useState, useTransition, type ReactNode } from "react";
|
||||||
import { addComment, deleteComment } from "@/lib/actions/student-actions";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||||
|
import { addComment, deleteComment, editComment } from "@/lib/actions/student-actions";
|
||||||
|
|
||||||
|
const MAX_LEN = 10000;
|
||||||
|
|
||||||
type Reply = {
|
type Reply = {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
editedAt: Date | null;
|
||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -16,6 +22,7 @@ type Comment = {
|
|||||||
text: string;
|
text: string;
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
editedAt: Date | null;
|
||||||
user: { id: string; name: string };
|
user: { id: string; name: string };
|
||||||
replies: Reply[];
|
replies: Reply[];
|
||||||
};
|
};
|
||||||
@@ -26,6 +33,8 @@ interface Props {
|
|||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
currentUserId: string;
|
currentUserId: string;
|
||||||
currentUserRole: string;
|
currentUserRole: string;
|
||||||
|
/** Public host of our own file storage (S3_CDN_URL) — see CommentBody. */
|
||||||
|
imageHost: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: Date) {
|
function formatDate(date: Date) {
|
||||||
@@ -38,10 +47,234 @@ function formatDate(date: Date) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole }: Props) {
|
// The default schema drops `alt`, so uploaded images would lose their caption.
|
||||||
|
const sanitizeSchema = {
|
||||||
|
...defaultSchema,
|
||||||
|
attributes: {
|
||||||
|
...defaultSchema.attributes,
|
||||||
|
img: [...(defaultSchema.attributes?.img ?? []), "alt"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Renders comment text as sanitized Markdown (no raw HTML, safe links).
|
||||||
|
// `imageHost` is our own file storage: images from anywhere else are shown as a
|
||||||
|
// plain link instead of being loaded, since a third-party image would fetch —
|
||||||
|
// and thus expose the address — for every reader of the lesson.
|
||||||
|
function CommentBody({ text, imageHost }: { text: string; imageHost: string }) {
|
||||||
|
return (
|
||||||
|
<div className="comment-md text-sm leading-relaxed break-words">
|
||||||
|
<ReactMarkdown
|
||||||
|
// GFM: голый адрес сам становится ссылкой. Без него студент пишет
|
||||||
|
// https://… и получает мёртвый текст — разметку никто не проставляет.
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={[[rehypeSanitize, sanitizeSchema]]}
|
||||||
|
components={{
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- отбрасываем служебный node от react-markdown, чтобы он не протекал в DOM
|
||||||
|
a: ({ node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
img: ({ node, src, alt, ...props }) => {
|
||||||
|
const url = typeof src === "string" ? src : "";
|
||||||
|
if (!imageHost || !url.startsWith(`${imageHost}/`)) {
|
||||||
|
return (
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
{alt || url}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- пользовательская картинка произвольного размера, next/image здесь не подходит
|
||||||
|
<img {...props} src={url} alt={alt ?? ""} loading="lazy" />
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarBtnStyle = {
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
background: "var(--background)",
|
||||||
|
minWidth: "30px",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ToolbarButton({
|
||||||
|
title,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="text-xs px-2 py-1 leading-none transition-colors"
|
||||||
|
style={toolbarBtnStyle}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--accent)")}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = "var(--background)")}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditorProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
rows?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textarea + Markdown formatting toolbar + character counter. Reused for the
|
||||||
|
// new comment, replies, and inline editing.
|
||||||
|
function CommentEditor({ value, onChange, placeholder, disabled, rows = 3 }: EditorProps) {
|
||||||
|
const ref = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Insert text at the caret (used for uploaded images).
|
||||||
|
function insertAtCursor(snippet: string) {
|
||||||
|
const el = ref.current;
|
||||||
|
const at = el ? el.selectionStart : value.length;
|
||||||
|
const next = value.slice(0, at) + snippet + value.slice(at);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(at + snippet.length, at + snippet.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = ""; // allow picking the same file twice in a row
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setUploadError(null);
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/student/comment-upload", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? "Не удалось загрузить изображение");
|
||||||
|
// Square brackets in the filename would break the Markdown link syntax.
|
||||||
|
const caption = file.name.replace(/[[\]]/g, "");
|
||||||
|
insertAtCursor(`\n\n`);
|
||||||
|
} catch (err) {
|
||||||
|
setUploadError(err instanceof Error ? err.message : "Не удалось загрузить изображение");
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap the current selection with before/after markers.
|
||||||
|
function wrap(before: string, after: string, placeholderText: string) {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
const selected = value.slice(start, end) || placeholderText;
|
||||||
|
const next = value.slice(0, start) + before + selected + after + value.slice(end);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(start + before.length, start + before.length + selected.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix each selected line (for lists and quotes).
|
||||||
|
function prefixLines(prefix: string) {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart;
|
||||||
|
const end = el.selectionEnd;
|
||||||
|
const lineStart = value.lastIndexOf("\n", start - 1) + 1;
|
||||||
|
const block = value.slice(lineStart, end);
|
||||||
|
const prefixed = block
|
||||||
|
.split("\n")
|
||||||
|
.map((l) => prefix + l)
|
||||||
|
.join("\n");
|
||||||
|
const next = value.slice(0, lineStart) + prefixed + value.slice(end);
|
||||||
|
onChange(next);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(lineStart, lineStart + prefixed.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-1 mb-2">
|
||||||
|
<ToolbarButton title="Жирный" disabled={disabled} onClick={() => wrap("**", "**", "текст")}><b>Ж</b></ToolbarButton>
|
||||||
|
<ToolbarButton title="Курсив" disabled={disabled} onClick={() => wrap("*", "*", "текст")}><i>К</i></ToolbarButton>
|
||||||
|
<ToolbarButton title="Список" disabled={disabled} onClick={() => prefixLines("- ")}>•</ToolbarButton>
|
||||||
|
<ToolbarButton title="Цитата" disabled={disabled} onClick={() => prefixLines("> ")}>»</ToolbarButton>
|
||||||
|
<ToolbarButton title="Код" disabled={disabled} onClick={() => wrap("`", "`", "код")}>{"</>"}</ToolbarButton>
|
||||||
|
<ToolbarButton title="Ссылка" disabled={disabled} onClick={() => wrap("[", "](https://)", "текст")}>🔗</ToolbarButton>
|
||||||
|
<ToolbarButton
|
||||||
|
title="Изображение (jpg, png, gif, webp — до 5 МБ)"
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
>
|
||||||
|
{uploading ? "…" : "🖼"}
|
||||||
|
</ToolbarButton>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFile}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
rows={rows}
|
||||||
|
maxLength={MAX_LEN}
|
||||||
|
disabled={disabled}
|
||||||
|
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)")}
|
||||||
|
/>
|
||||||
|
{uploadError && (
|
||||||
|
<div className="text-xs mt-1" style={{ color: "var(--destructive, #b00)" }}>
|
||||||
|
{uploadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Поддерживается форматирование Markdown{value.length > 0 ? ` · ${value.length}/${MAX_LEN}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LessonComments({ lessonId, slug, comments, currentUserId, currentUserRole, imageHost }: Props) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [replyToId, setReplyToId] = useState<string | null>(null);
|
const [replyToId, setReplyToId] = useState<string | null>(null);
|
||||||
const [replyText, setReplyText] = useState("");
|
const [replyText, setReplyText] = useState("");
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editText, setEditText] = useState("");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -74,6 +307,19 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEdit(commentId: string) {
|
||||||
|
if (!editText.trim()) return;
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await editComment(commentId, lessonId, slug, editText.trim());
|
||||||
|
setEditingId(null);
|
||||||
|
setEditText("");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleDelete(commentId: string) {
|
function handleDelete(commentId: string) {
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -84,6 +330,89 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderNode(node: Comment | Reply, isReply: boolean) {
|
||||||
|
const isOwn = node.user.id === currentUserId;
|
||||||
|
const editing = editingId === node.id;
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
|
<span className="text-sm font-bold">{node.user.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{formatDate(node.createdAt)}
|
||||||
|
{node.editedAt && !node.deleted ? " · изменено" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{node.deleted ? (
|
||||||
|
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
[Комментарий удалён]
|
||||||
|
</p>
|
||||||
|
) : editing ? (
|
||||||
|
<div>
|
||||||
|
<CommentEditor value={editText} onChange={setEditText} placeholder="Измените комментарий..." disabled={isPending} rows={3} />
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(node.id)}
|
||||||
|
disabled={isPending || !editText.trim()}
|
||||||
|
className="btn-aubade btn-aubade-accent text-sm"
|
||||||
|
>
|
||||||
|
{isPending ? "Сохранение..." : "Сохранить"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(null); setEditText(""); }}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CommentBody text={node.text} imageHost={imageHost} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!node.deleted && !editing && (
|
||||||
|
<div className="flex gap-3 mt-1">
|
||||||
|
{isOwn && (
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingId(node.id); setEditText(node.text); setReplyToId(null); }}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Изменить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(isOwn || canModerate) && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(node.id)}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isReply && canModerate && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setReplyToId(replyToId === node.id ? null : node.id);
|
||||||
|
setReplyText("");
|
||||||
|
}}
|
||||||
|
disabled={isPending}
|
||||||
|
className="text-xs underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{replyToId === node.id ? "Отмена" : "Ответить"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="space-y-5 mb-6">
|
<div className="space-y-5 mb-6">
|
||||||
@@ -107,86 +436,25 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
>
|
>
|
||||||
{comment.user.name[0]?.toUpperCase() ?? "?"}
|
{comment.user.name[0]?.toUpperCase() ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
|
{renderNode(comment, false)}
|
||||||
<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)" }}>
|
|
||||||
{formatDate(comment.createdAt)}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-3 mt-1">
|
|
||||||
{!comment.deleted && (comment.user.id === currentUserId || canModerate) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(comment.id)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="text-xs underline"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!comment.deleted && canModerate && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setReplyToId(replyToId === comment.id ? null : comment.id);
|
|
||||||
setReplyText("");
|
|
||||||
}}
|
|
||||||
disabled={isPending}
|
|
||||||
className="text-xs underline"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
{replyToId === comment.id ? "Отмена" : "Ответить"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Inline reply form */}
|
|
||||||
{replyToId === comment.id && (
|
|
||||||
<div className="mt-3">
|
|
||||||
<textarea
|
|
||||||
value={replyText}
|
|
||||||
onChange={(e) => setReplyText(e.target.value)}
|
|
||||||
placeholder="Напишите ответ..."
|
|
||||||
rows={2}
|
|
||||||
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)")}
|
|
||||||
/>
|
|
||||||
<div className="flex justify-end mt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handleReply(comment.id)}
|
|
||||||
disabled={isPending || !replyText.trim()}
|
|
||||||
className="btn-aubade btn-aubade-accent text-sm"
|
|
||||||
>
|
|
||||||
{isPending ? "Отправка..." : "Отправить ответ"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Inline reply form (under root) */}
|
||||||
|
{replyToId === comment.id && (
|
||||||
|
<div className="mt-3 ml-11">
|
||||||
|
<CommentEditor value={replyText} onChange={setReplyText} placeholder="Напишите ответ..." disabled={isPending} rows={2} />
|
||||||
|
<div className="flex justify-end mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleReply(comment.id)}
|
||||||
|
disabled={isPending || !replyText.trim()}
|
||||||
|
className="btn-aubade btn-aubade-accent text-sm"
|
||||||
|
>
|
||||||
|
{isPending ? "Отправка..." : "Отправить ответ"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Replies */}
|
{/* Replies */}
|
||||||
{comment.replies.length > 0 && (
|
{comment.replies.length > 0 && (
|
||||||
<div
|
<div
|
||||||
@@ -205,33 +473,7 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
>
|
>
|
||||||
{reply.user.name[0]?.toUpperCase() ?? "?"}
|
{reply.user.name[0]?.toUpperCase() ?? "?"}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
{renderNode(reply, true)}
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
|
||||||
<span className="text-sm font-bold">{reply.user.name}</span>
|
|
||||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
{formatDate(reply.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{reply.deleted ? (
|
|
||||||
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
[Комментарий удалён]
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">
|
|
||||||
{reply.text}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!reply.deleted && (reply.user.id === currentUserId || canModerate) && (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(reply.id)}
|
|
||||||
disabled={isPending}
|
|
||||||
className="text-xs mt-1 underline"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -242,33 +484,13 @@ export function LessonComments({ lessonId, slug, comments, currentUserId, curren
|
|||||||
|
|
||||||
{/* New comment form */}
|
{/* New comment form */}
|
||||||
<form onSubmit={handleAdd}>
|
<form onSubmit={handleAdd}>
|
||||||
<textarea
|
<CommentEditor value={text} onChange={setText} placeholder="Напишите комментарий..." disabled={isPending} rows={3} />
|
||||||
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 && (
|
{error && (
|
||||||
<p className="text-xs mt-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
<p className="text-xs mt-1" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center justify-between mt-2">
|
<div className="flex items-center justify-end mt-2">
|
||||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
||||||
{text.length > 0 ? `${text.length}/2000` : ""}
|
|
||||||
</span>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPending || !text.trim()}
|
disabled={isPending || !text.trim()}
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useRef, useState, type CSSProperties } from "react";
|
||||||
|
import {
|
||||||
|
PAY_METHODS_URL,
|
||||||
|
PAY_ORDER_URL,
|
||||||
|
type StoreCatalogEntry,
|
||||||
|
} from "@/lib/store-catalog";
|
||||||
|
|
||||||
|
export interface LockedCourse {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
coverImage: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PayMethod {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider: string;
|
||||||
|
kind: "online" | "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
course: LockedCourse;
|
||||||
|
entry: StoreCatalogEntry;
|
||||||
|
buyer: { name: string; email: string };
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRub(v: number) {
|
||||||
|
return `${v.toLocaleString("ru-RU")} ₽`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle: CSSProperties = {
|
||||||
|
width: "100%",
|
||||||
|
border: "2px solid var(--border)",
|
||||||
|
background: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
padding: "0.5rem 0.6rem",
|
||||||
|
fontSize: "14px",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
outline: "none",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StoreCheckoutModal({ course, entry, buyer, onClose }: Props) {
|
||||||
|
const [selectedSku, setSelectedSku] = useState(entry.tariffs[0].sku);
|
||||||
|
const [methods, setMethods] = useState<PayMethod[] | null>(null);
|
||||||
|
const [method, setMethod] = useState("robokassa");
|
||||||
|
const [otherOpen, setOtherOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
const multiTariff = entry.tariffs.length > 1;
|
||||||
|
const currentMethod = methods?.find((m) => m.id === method);
|
||||||
|
|
||||||
|
// Payment methods dropdown, same source as the landings. On any failure the
|
||||||
|
// hidden method=robokassa keeps the checkout fully functional.
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(PAY_METHODS_URL, { signal: AbortSignal.timeout(5000) })
|
||||||
|
.then((r) => (r.ok ? r.json() : Promise.reject(new Error("bad status"))))
|
||||||
|
.then((data: { methods?: PayMethod[] }) => {
|
||||||
|
const ms = data?.methods ?? [];
|
||||||
|
if (ms.length >= 2) {
|
||||||
|
setMethods(ms);
|
||||||
|
setMethod((cur) => (cur === "robokassa" ? ms[0].id : cur));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Lock body scroll while the modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = prev;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus the dialog on open, restore focus on close
|
||||||
|
useEffect(() => {
|
||||||
|
const prev = document.activeElement as HTMLElement | null;
|
||||||
|
dialogRef.current?.focus();
|
||||||
|
return () => prev?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Coming back from the payment page via bfcache must not leave the
|
||||||
|
// submit button stuck in the "submitting" state.
|
||||||
|
useEffect(() => {
|
||||||
|
const onPageShow = (e: PageTransitionEvent) => {
|
||||||
|
if (e.persisted) setSubmitting(false);
|
||||||
|
};
|
||||||
|
window.addEventListener("pageshow", onPageShow);
|
||||||
|
return () => window.removeEventListener("pageshow", onPageShow);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Minimal focus trap: keep Tab cycling inside the dialog
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key !== "Tab" || !dialogRef.current) return;
|
||||||
|
const focusables = dialogRef.current.querySelectorAll<HTMLElement>(
|
||||||
|
'button, [href], input, select, [tabindex]:not([tabindex="-1"])'
|
||||||
|
);
|
||||||
|
if (focusables.length === 0) return;
|
||||||
|
const first = focusables[0];
|
||||||
|
const last = focusables[focusables.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labelClass = "block text-xs font-bold uppercase tracking-widest mb-1.5";
|
||||||
|
const primary = methods?.[0];
|
||||||
|
const rest = methods?.slice(1) ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center px-4"
|
||||||
|
style={{ background: "rgba(50,50,50,0.45)" }}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={dialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
tabIndex={-1}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full max-w-md max-h-[90dvh] overflow-y-auto p-6 space-y-4"
|
||||||
|
style={{
|
||||||
|
background: "var(--background)",
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
boxShadow: "6px 6px 0 0 var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Покупка курса
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
className="-mt-3 -mr-3 flex h-11 w-11 items-center justify-center"
|
||||||
|
style={{ color: "var(--muted-foreground)", fontSize: "22px", lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{course.coverImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={course.coverImage}
|
||||||
|
alt=""
|
||||||
|
className="w-24 aspect-video object-cover flex-shrink-0"
|
||||||
|
style={{ border: "2px solid var(--border)" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-24 aspect-video flex items-center justify-center text-xl flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--color-surface)", border: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
📚
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h2 id={titleId} className="font-bold text-lg leading-tight">
|
||||||
|
{course.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{course.description && (
|
||||||
|
<p className="text-sm line-clamp-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form method="POST" action={PAY_ORDER_URL} onSubmit={() => setSubmitting(true)}>
|
||||||
|
<input type="hidden" name="name" value={buyer.name} />
|
||||||
|
<input type="hidden" name="email" value={buyer.email} />
|
||||||
|
<input type="hidden" name="method" value={method} />
|
||||||
|
{!multiTariff && <input type="hidden" name="sku" value={entry.tariffs[0].sku} />}
|
||||||
|
|
||||||
|
{/* Tariff */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className={labelClass} style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Тариф
|
||||||
|
</span>
|
||||||
|
{multiTariff ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entry.tariffs.map((t) => (
|
||||||
|
<label
|
||||||
|
key={t.sku}
|
||||||
|
className="flex items-start gap-3 p-3 cursor-pointer min-h-[44px]"
|
||||||
|
style={{
|
||||||
|
border:
|
||||||
|
"2px solid " +
|
||||||
|
(selectedSku === t.sku ? "var(--foreground)" : "var(--border)"),
|
||||||
|
background: selectedSku === t.sku ? "var(--accent)" : "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="sku"
|
||||||
|
value={t.sku}
|
||||||
|
checked={selectedSku === t.sku}
|
||||||
|
onChange={() => setSelectedSku(t.sku)}
|
||||||
|
className="mt-1"
|
||||||
|
style={{ accentColor: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
<span className="flex-1">
|
||||||
|
<span className="flex items-baseline justify-between gap-2">
|
||||||
|
<span className="text-sm font-bold">{t.label}</span>
|
||||||
|
<span className="text-sm font-bold whitespace-nowrap">
|
||||||
|
{formatRub(t.priceRub)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{t.includes && (
|
||||||
|
<span
|
||||||
|
className="block text-xs mt-0.5"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{t.includes}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="font-bold text-lg">{formatRub(entry.tariffs[0].priceRub)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment method — rendered only when the list actually loaded */}
|
||||||
|
{methods && primary && rest.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<span className={labelClass} style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Способ оплаты
|
||||||
|
</span>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center gap-3 p-2 cursor-pointer min-h-[44px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="method_ui"
|
||||||
|
checked={!otherOpen}
|
||||||
|
onChange={() => {
|
||||||
|
setOtherOpen(false);
|
||||||
|
setMethod(primary.id);
|
||||||
|
}}
|
||||||
|
style={{ accentColor: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">
|
||||||
|
<span className="font-bold">{primary.label}</span>{" "}
|
||||||
|
<span style={{ color: "var(--muted-foreground)" }}>{primary.provider}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-2 cursor-pointer min-h-[44px]">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="method_ui"
|
||||||
|
checked={otherOpen}
|
||||||
|
onChange={() => {
|
||||||
|
setOtherOpen(true);
|
||||||
|
setMethod(rest[0].id);
|
||||||
|
}}
|
||||||
|
style={{ accentColor: "var(--foreground)" }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Другой способ</span>
|
||||||
|
</label>
|
||||||
|
{otherOpen && (
|
||||||
|
<select
|
||||||
|
value={method}
|
||||||
|
onChange={(e) => setMethod(e.target.value)}
|
||||||
|
aria-label="Другой способ оплаты"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
|
>
|
||||||
|
{rest.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.label} — {m.provider}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{currentMethod?.kind === "manual" && (
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Счёт выставим вручную — доступ откроется после подтверждения оплаты.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Promo code */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label
|
||||||
|
className={labelClass}
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
htmlFor={`${titleId}-promo`}
|
||||||
|
>
|
||||||
|
Промокод
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={`${titleId}-promo`}
|
||||||
|
type="text"
|
||||||
|
name="promo_code"
|
||||||
|
maxLength={40}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Если есть"
|
||||||
|
style={inputStyle}
|
||||||
|
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||||
|
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Доступ придёт на <span className="font-bold">{buyer.email}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="btn-aubade btn-aubade-accent w-full min-h-[44px] text-sm"
|
||||||
|
>
|
||||||
|
{submitting ? "Переходим к оплате…" : "Перейти к оплате →"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="space-y-2 pt-1">
|
||||||
|
<a
|
||||||
|
href={entry.landingUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm underline inline-block"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
Подробнее о курсе на сайте →
|
||||||
|
</a>
|
||||||
|
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Сумма на странице оплаты отобразится в тенге (₸). Доступ откроется автоматически
|
||||||
|
после оплаты.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { STORE_CATALOG } from "@/lib/store-catalog";
|
||||||
|
import { StoreCheckoutModal, type LockedCourse } from "./store-checkout-modal";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
courses: LockedCourse[];
|
||||||
|
buyer: { name: string; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blurred card body shared by both the modal-trigger button and the
|
||||||
|
// fallback landing link.
|
||||||
|
function CardBody({ course }: { course: LockedCourse }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Overlay: замок + призыв, поверх размытого контента */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 transition-opacity"
|
||||||
|
style={{ backgroundColor: "color-mix(in srgb, var(--background) 55%, transparent)" }}
|
||||||
|
>
|
||||||
|
<span className="text-2xl">🔒</span>
|
||||||
|
<span
|
||||||
|
className="text-sm font-bold px-4 py-2"
|
||||||
|
style={{
|
||||||
|
border: "2px solid var(--foreground)",
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Подробнее →
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Размытый контент карточки */}
|
||||||
|
<div
|
||||||
|
className="flex flex-col flex-1"
|
||||||
|
style={{ filter: "blur(1.8px)", opacity: 0.82, pointerEvents: "none" }}
|
||||||
|
>
|
||||||
|
{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-lg leading-tight">{course.title}</h2>
|
||||||
|
{course.description && (
|
||||||
|
<p className="text-sm line-clamp-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoreShowcase({ courses, buyer }: Props) {
|
||||||
|
const [selectedSlug, setSelectedSlug] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const selected = courses.find((c) => c.slug === selectedSlug);
|
||||||
|
const selectedEntry = selected ? STORE_CATALOG[selected.slug] : undefined;
|
||||||
|
|
||||||
|
const cardClass = "card-aubade p-0 overflow-hidden flex flex-col relative group";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-10">
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-3"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Другие курсы Second Brain
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{courses.map((course) => {
|
||||||
|
const entry = STORE_CATALOG[course.slug];
|
||||||
|
if (!entry || entry.tariffs.length === 0) {
|
||||||
|
// Fallback: no catalog entry → keep the old landing link
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={course.id}
|
||||||
|
href={entry?.landingUrl ?? "https://second-brain.ru"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={cardClass}
|
||||||
|
>
|
||||||
|
<CardBody course={course} />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={course.id}
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={() => setSelectedSlug(course.slug)}
|
||||||
|
className={`${cardClass} text-left w-full cursor-pointer`}
|
||||||
|
>
|
||||||
|
<CardBody course={course} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected && selectedEntry && (
|
||||||
|
<StoreCheckoutModal
|
||||||
|
course={selected}
|
||||||
|
entry={selectedEntry}
|
||||||
|
buyer={buyer}
|
||||||
|
onClose={() => setSelectedSlug(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// Gate-лестница после wow-урока (ежедневные заметки). Двойной оффер:
|
||||||
|
// основной — трипвайр «Второй мозг для предпринимателя», вторичный — полный курс.
|
||||||
|
// Server component, ДС-2 (Aubade).
|
||||||
|
|
||||||
|
import { GateViewTracker } from "./gate-view-tracker";
|
||||||
|
|
||||||
|
const TRIPWIRE_URL = "https://second-brain.ru/entrepreneur";
|
||||||
|
const FULL_COURSE_URL = "https://obsidian.second-brain.ru/";
|
||||||
|
|
||||||
|
export function TripwireGateBanner() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="card-aubade p-6 mt-10"
|
||||||
|
style={{ background: "var(--accent)", boxShadow: "4px 4px 0 0 var(--foreground)" }}
|
||||||
|
>
|
||||||
|
<GateViewTracker />
|
||||||
|
<p
|
||||||
|
className="text-xs font-bold uppercase tracking-widest mb-2"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Ты освоил главную привычку
|
||||||
|
</p>
|
||||||
|
<h2 className="font-bold text-xl mb-2">🎯 Что дальше?</h2>
|
||||||
|
<p className="text-sm mb-4" style={{ color: "var(--foreground)" }}>
|
||||||
|
Ежедневные заметки — это фундамент второго мозга. Дальше система начинает
|
||||||
|
работать на тебя. Выбери свой следующий шаг:
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<a
|
||||||
|
href={TRIPWIRE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-aubade btn-aubade-accent text-sm flex-1 text-center font-bold"
|
||||||
|
>
|
||||||
|
⚡ Второй мозг для предпринимателя →
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={FULL_COURSE_URL}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn-aubade text-sm flex-1 text-center"
|
||||||
|
>
|
||||||
|
🏛 Полный курс «Obsidian» →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { CopyButton } from "./CopyButton";
|
||||||
|
import type { ToolId } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
|
export function CodeOutput({ code, tool, label }: { code: string; tool: ToolId; label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{label && <div className="text-xs font-bold tracking-wide" style={{ color: "var(--muted-foreground)" }}>{label}</div>}
|
||||||
|
<pre
|
||||||
|
className="overflow-auto p-3 text-sm"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono, 'Fira Mono', monospace)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: "2px",
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
}}
|
||||||
|
>{code}</pre>
|
||||||
|
<div><CopyButton text={code} tool={tool} /></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Copy, Check } from "lucide-react";
|
||||||
|
import { logToolUsage } from "@/lib/actions/tool-usage";
|
||||||
|
import type { ToolId } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
|
export function CopyButton({ text, tool }: { text: string; tool: ToolId }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
async function onCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
return; // буфер недоступен (нет разрешения / insecure context) — молча
|
||||||
|
}
|
||||||
|
setCopied(true);
|
||||||
|
logToolUsage({ tool }).catch(() => {}); // аналитика — best-effort, не роняем UI
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onCopy} className="btn-aubade btn-aubade-accent inline-flex items-center gap-2 text-sm">
|
||||||
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
{copied ? "Скопировано" : "Скопировать"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { MessageSquareQuote, FileCode2, Palette, SlidersHorizontal, Table2, Database, FileText, Wrench, type LucideIcon } from "lucide-react";
|
||||||
|
import type { ToolMeta } from "@/lib/tools/_shared/types";
|
||||||
|
|
||||||
|
// Явная карта (не `import * as Icons`) — иначе весь набор lucide попадает в бандл.
|
||||||
|
const ICONS: Record<string, LucideIcon> = {
|
||||||
|
MessageSquareQuote,
|
||||||
|
FileCode2,
|
||||||
|
Palette,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Table2,
|
||||||
|
Database,
|
||||||
|
FileText,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ToolCard({ tool }: { tool: ToolMeta }) {
|
||||||
|
const Icon = ICONS[tool.icon] ?? Wrench;
|
||||||
|
return (
|
||||||
|
<Link href={`/tools/${tool.id}`} className="card-aubade p-4 flex flex-col gap-2 no-underline">
|
||||||
|
<div className="flex items-center gap-2" style={{ color: "var(--foreground)" }}>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="font-bold">{tool.title}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>{tool.description}</p>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Wrench } from "lucide-react";
|
||||||
|
|
||||||
|
// Промо-карточка Toolbox для раздела «Мои курсы» — точка входа в /tools.
|
||||||
|
export function ToolboxPromoCard() {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href="/tools"
|
||||||
|
className="card-aubade p-5 flex items-center gap-4 no-underline"
|
||||||
|
style={{ background: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
<Wrench size={28} style={{ color: "var(--foreground)", flexShrink: 0 }} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="font-bold text-lg leading-tight" style={{ color: "var(--foreground)" }}>
|
||||||
|
Инструменты Obsidian
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm" style={{ color: "var(--foreground)" }}>
|
||||||
|
Генераторы синтаксиса: callout, frontmatter, темы, Style Settings, Dataview, Bases.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-bold whitespace-nowrap" style={{ color: "var(--foreground)" }}>
|
||||||
|
Открыть →
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
// Draws 2px rays from the edge of every [data-scheme-card] to the central
|
||||||
|
// [data-scheme-logo] circle. Redraws on container resize and after fonts load.
|
||||||
|
export function SchemeConnectors() {
|
||||||
|
const ref = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const svg = ref.current;
|
||||||
|
const scheme = svg?.parentElement;
|
||||||
|
if (!svg || !scheme) return;
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
if (!svg || !scheme) return;
|
||||||
|
const logo = scheme.querySelector<HTMLElement>("[data-scheme-logo]");
|
||||||
|
if (!logo) return;
|
||||||
|
const s = scheme.getBoundingClientRect();
|
||||||
|
const l = logo.getBoundingClientRect();
|
||||||
|
const cx = l.left + l.width / 2 - s.left;
|
||||||
|
const cy = l.top + l.height / 2 - s.top;
|
||||||
|
const r = l.width / 2 + 4;
|
||||||
|
svg.setAttribute("viewBox", `0 0 ${s.width} ${s.height}`);
|
||||||
|
let lines = "";
|
||||||
|
scheme.querySelectorAll<HTMLElement>("[data-scheme-card]").forEach((card) => {
|
||||||
|
const c = card.getBoundingClientRect();
|
||||||
|
const isLeft = card.dataset.schemeCard === "left";
|
||||||
|
const x1 = (isLeft ? c.right : c.left) - s.left;
|
||||||
|
const y1 = c.top + c.height / 2 - s.top;
|
||||||
|
const dx = cx - x1;
|
||||||
|
const dy = cy - y1;
|
||||||
|
const d = Math.hypot(dx, dy) || 1;
|
||||||
|
const x2 = cx - (dx / d) * r;
|
||||||
|
const y2 = cy - (dy / d) * r;
|
||||||
|
lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" />`;
|
||||||
|
});
|
||||||
|
svg.innerHTML = lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
draw();
|
||||||
|
const ro = new ResizeObserver(draw);
|
||||||
|
ro.observe(scheme);
|
||||||
|
document.fonts?.ready.then(draw).catch(() => {});
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
ref={ref}
|
||||||
|
aria-hidden="true"
|
||||||
|
className="hidden md:block absolute inset-0 w-full h-full pointer-events-none"
|
||||||
|
style={{ stroke: "var(--border)", strokeWidth: 2 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Public landing shown on "/" for unauthenticated visitors.
|
||||||
|
// DS-2 rework of the old platform's "second brain" infographic.
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
StickyNote,
|
||||||
|
BookOpen,
|
||||||
|
Lightbulb,
|
||||||
|
Database,
|
||||||
|
GraduationCap,
|
||||||
|
Wrench,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { SchemeConnectors } from "./scheme-connectors";
|
||||||
|
|
||||||
|
interface SchemeBlock {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
items: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const BLOCKS_LEFT: SchemeBlock[] = [
|
||||||
|
{
|
||||||
|
icon: StickyNote,
|
||||||
|
title: "Заметки",
|
||||||
|
items: [
|
||||||
|
"разбираемся, как создавать качественные заметки, а не просто писать всё подряд",
|
||||||
|
"изучаем самые эффективные подходы в заметкоделии и заметковедении",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: BookOpen,
|
||||||
|
title: "Знания",
|
||||||
|
items: [
|
||||||
|
"превращаем сырые данные и разрозненную информацию в устойчивые знания, готовые к повторному применению",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Lightbulb,
|
||||||
|
title: "Идеи",
|
||||||
|
items: [
|
||||||
|
"фиксируем, развиваем и воплощаем гениальные идеи в жизнь",
|
||||||
|
"и не даём им сгинуть в процессе",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BLOCKS_RIGHT: SchemeBlock[] = [
|
||||||
|
{
|
||||||
|
icon: Database,
|
||||||
|
title: "Базы знаний",
|
||||||
|
items: [
|
||||||
|
"создаём картотеку, строим структуру, наполняем её качественными заметками",
|
||||||
|
"связываем отдельные элементы в единую сеть идей и размышлений",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: GraduationCap,
|
||||||
|
title: "Обучение",
|
||||||
|
items: [
|
||||||
|
"учимся правильно учиться, осваиваем эффективные стратегии",
|
||||||
|
"выстраиваем свой индивидуальный трек развития",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Wrench,
|
||||||
|
title: "Инструменты",
|
||||||
|
items: [
|
||||||
|
"разбираемся с особенностями инструментов и выбираем самые подходящие",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROW_START = ["md:row-start-1", "md:row-start-2", "md:row-start-3"];
|
||||||
|
|
||||||
|
function SchemeCard({ block, side, row }: { block: SchemeBlock; side: "left" | "right"; row: number }) {
|
||||||
|
const Icon = block.icon;
|
||||||
|
const colClass = side === "left" ? "md:col-start-1" : "md:col-start-3";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-scheme-card={side}
|
||||||
|
className={`card-aubade relative z-[1] p-5 ${colClass} ${ROW_START[row]}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center justify-center w-10 h-10 flex-shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--accent)", border: "2px solid var(--foreground)" }}
|
||||||
|
>
|
||||||
|
<Icon size={22} style={{ color: "var(--foreground)" }} />
|
||||||
|
</span>
|
||||||
|
<span className="font-bold uppercase" style={{ fontSize: "16px", letterSpacing: "0.08em" }}>
|
||||||
|
{block.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{block.items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item}
|
||||||
|
className="relative pl-4 leading-normal"
|
||||||
|
style={{ fontSize: "14.5px" }}
|
||||||
|
>
|
||||||
|
<span className="absolute left-0" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomePage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 px-4 sm:px-8 py-3.5"
|
||||||
|
style={{ borderBottom: "2px solid var(--border)" }}
|
||||||
|
>
|
||||||
|
<span className="font-bold text-lg whitespace-nowrap">Second Brain</span>
|
||||||
|
<nav className="flex gap-3">
|
||||||
|
<Link href="/login" className="btn-aubade text-sm whitespace-nowrap">
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
<Link href="/register" className="btn-aubade btn-aubade-accent text-sm whitespace-nowrap">
|
||||||
|
Зарегистрироваться
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 w-full max-w-[1240px] mx-auto px-4 sm:px-8 pt-10 md:pt-14 pb-16">
|
||||||
|
<p
|
||||||
|
className="text-center text-xs font-bold uppercase mb-3.5"
|
||||||
|
style={{ color: "var(--muted-foreground)", letterSpacing: "0.18em" }}
|
||||||
|
>
|
||||||
|
Образовательная платформа Second Brain
|
||||||
|
</p>
|
||||||
|
<h1 className="text-center font-bold leading-tight text-[26px] md:text-[40px] mb-2.5">
|
||||||
|
Создаём и развиваем
|
||||||
|
<br />
|
||||||
|
второй мозг
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-center mb-8 md:mb-14"
|
||||||
|
style={{ color: "var(--muted-foreground)", fontSize: "17px" }}
|
||||||
|
>
|
||||||
|
Заметки, знания и идеи — в одной системе, которая работает на вас
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="relative grid grid-cols-1 md:grid-cols-[1fr_320px_1fr] gap-y-5 md:gap-y-7 gap-x-11 items-center">
|
||||||
|
<SchemeConnectors />
|
||||||
|
<div className="md:col-start-2 md:row-start-1 md:row-span-3 flex flex-col items-center gap-4 mb-4 md:mb-0">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="/logo.svg"
|
||||||
|
alt="Second Brain"
|
||||||
|
data-scheme-logo
|
||||||
|
className="w-[150px] h-[150px] md:w-[280px] md:h-[280px]"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="text-xs font-bold uppercase"
|
||||||
|
style={{ color: "var(--muted-foreground)", letterSpacing: "0.18em" }}
|
||||||
|
>
|
||||||
|
Second Brain
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{BLOCKS_LEFT.map((block, i) => (
|
||||||
|
<SchemeCard key={block.title} block={block} side="left" row={i} />
|
||||||
|
))}
|
||||||
|
{BLOCKS_RIGHT.map((block, i) => (
|
||||||
|
<SchemeCard key={block.title} block={block} side="right" row={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center gap-4 mt-10 md:mt-14">
|
||||||
|
<Link href="/register" className="btn-aubade btn-aubade-accent px-6 py-2.5">
|
||||||
|
Начать учиться →
|
||||||
|
</Link>
|
||||||
|
<Link href="/login" className="btn-aubade px-6 py-2.5">
|
||||||
|
У меня уже есть доступ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer
|
||||||
|
className="flex flex-col sm:flex-row justify-between gap-1.5 px-4 sm:px-8 py-4 text-sm"
|
||||||
|
style={{ borderTop: "2px solid var(--border)", color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<span>ИП Second Brain Production</span>
|
||||||
|
<a href="https://second-brain.ru" className="underline" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
second-brain.ru — все курсы и материалы
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { commentReplyRecipient } from "../comment-notify";
|
||||||
|
|
||||||
|
const parent = {
|
||||||
|
userId: "student-1",
|
||||||
|
deleted: false,
|
||||||
|
user: { email: "student@example.com", name: "Владимир" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("commentReplyRecipient", () => {
|
||||||
|
it("отдаёт автора родительского комментария, когда админ ответил ученику", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toEqual({ email: "student@example.com", name: "Владимир" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("работает и для куратора", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "curator-1",
|
||||||
|
replierRole: "curator",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toEqual({ email: "student@example.com", name: "Владимир" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит при выключенной настройке", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: false,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит, когда отвечает не staff", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "student-2",
|
||||||
|
replierRole: "student",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит без родительского комментария — это корневая запись, а не ответ", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: null,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит, если родительский комментарий удалён", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: { ...parent, deleted: true },
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("не пишет письмо самому себе", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "student-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent,
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("молчит, когда у автора нет почты", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: { ...parent, user: { email: " ", name: "Без почты" } },
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("отдаёт пустое имя, если в карточке его нет", () => {
|
||||||
|
expect(
|
||||||
|
commentReplyRecipient({
|
||||||
|
notifyEnabled: true,
|
||||||
|
replierId: "admin-1",
|
||||||
|
replierRole: "admin",
|
||||||
|
parent: { ...parent, user: { email: "student@example.com", name: "" } },
|
||||||
|
})
|
||||||
|
).toEqual({ email: "student@example.com", name: "" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const sent: Array<Record<string, string>> = [];
|
||||||
|
|
||||||
|
vi.mock("resend", () => ({
|
||||||
|
Resend: class {
|
||||||
|
emails = {
|
||||||
|
send: async (args: Record<string, string>) => {
|
||||||
|
sent.push(args);
|
||||||
|
return { data: { id: "test" }, error: null };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../settings", () => ({
|
||||||
|
getSetting: async () => "Second Brain",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { sendCommentReplyEmail } = await import("../email");
|
||||||
|
|
||||||
|
const LESSON_URL = "https://school.second-brain.ru/courses/obsidian-full/lessons/lesson-1";
|
||||||
|
|
||||||
|
describe("sendCommentReplyEmail", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
sent.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("кладёт текст ответа в письмо и ведёт кнопкой на урок", async () => {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
"student@example.com",
|
||||||
|
"Владимир",
|
||||||
|
"6.3. Холст",
|
||||||
|
"Прямого импорта нет, но есть два пути.",
|
||||||
|
LESSON_URL,
|
||||||
|
"Дмитрий"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(sent).toHaveLength(1);
|
||||||
|
expect(sent[0].to).toBe("student@example.com");
|
||||||
|
expect(sent[0].subject).toBe("Ответ на ваш комментарий к уроку «6.3. Холст»");
|
||||||
|
expect(sent[0].html).toContain("Прямого импорта нет, но есть два пути.");
|
||||||
|
expect(sent[0].html).toContain("Привет, Владимир!");
|
||||||
|
expect(sent[0].html).toContain("Отвечает <strong>Дмитрий</strong>:");
|
||||||
|
expect(sent[0].html).toContain(`href="${LESSON_URL}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("экранирует разметку в тексте ответа и в названии урока", async () => {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
"student@example.com",
|
||||||
|
"Владимир",
|
||||||
|
"Урок <b>про холст</b>",
|
||||||
|
'Смотрите тег <script>alert("x")</script>',
|
||||||
|
LESSON_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = sent[0].html;
|
||||||
|
expect(html).not.toContain("<script>");
|
||||||
|
expect(html).toContain("<script>");
|
||||||
|
expect(html).toContain("Урок <b>про холст</b>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("обрезает длинный ответ и говорит, что целиком он под уроком", async () => {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
"student@example.com",
|
||||||
|
"Владимир",
|
||||||
|
"6.3. Холст",
|
||||||
|
"я".repeat(4500),
|
||||||
|
LESSON_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = sent[0].html;
|
||||||
|
expect(html).toContain("Ответ длинный — целиком он под уроком.");
|
||||||
|
expect(html).toContain("…");
|
||||||
|
expect(html).not.toContain("я".repeat(4100));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("без имени отвечающего пишет просто «Школа ответила»", async () => {
|
||||||
|
await sendCommentReplyEmail("student@example.com", "", "6.3. Холст", "Коротко", LESSON_URL);
|
||||||
|
|
||||||
|
const html = sent[0].html;
|
||||||
|
expect(html).toContain("Школа ответила на ваш комментарий");
|
||||||
|
expect(html).not.toContain("Отвечает <strong>");
|
||||||
|
expect(html).toContain("Привет!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"use server";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { hasPaidAccess } from "@/lib/clean-pdf/access";
|
||||||
|
import { regenerateKey } from "@/lib/clean-pdf/keys";
|
||||||
|
|
||||||
|
export async function regeneratePdfApiKey(): Promise<{ ok: boolean; key?: string }> {
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return { ok: false };
|
||||||
|
if (!(await hasPaidAccess(session.user.id))) return { ok: false };
|
||||||
|
const key = await regenerateKey(session.user.id);
|
||||||
|
return { ok: true, key };
|
||||||
|
}
|
||||||
@@ -4,7 +4,12 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { sendHomeworkSubmittedEmail } from "@/lib/email";
|
import {
|
||||||
|
sendHomeworkSubmittedEmail,
|
||||||
|
sendCommentNotificationEmail,
|
||||||
|
sendCommentReplyEmail,
|
||||||
|
} from "@/lib/email";
|
||||||
|
import { commentReplyRecipient } from "@/lib/comment-notify";
|
||||||
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
import { getSettings, parseNotificationEmails, asBool } from "@/lib/settings";
|
||||||
|
|
||||||
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
// ── Lesson Progress ───────────────────────────────────────────────────────────
|
||||||
@@ -56,7 +61,7 @@ export async function submitHomework(
|
|||||||
include: { feedbacks: true },
|
include: { feedbacks: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing?.feedbacks && existing.feedbacks.length > 0) {
|
if ((existing?.feedbacks && existing.feedbacks.length > 0) || existing?.status === "APPROVED") {
|
||||||
throw new Error("Работа уже проверена");
|
throw new Error("Работа уже проверена");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,11 +157,11 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
if (!session) throw new Error("Unauthorized");
|
if (!session) throw new Error("Unauthorized");
|
||||||
|
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
if (!trimmed || trimmed.length > 2000) throw new Error("Invalid text");
|
if (!trimmed || trimmed.length > 10000) throw new Error("Invalid text");
|
||||||
|
|
||||||
const lesson = await prisma.lesson.findUnique({
|
const lesson = await prisma.lesson.findUnique({
|
||||||
where: { id: lessonId },
|
where: { id: lessonId },
|
||||||
select: { module: { select: { course: { select: { id: true } } } } },
|
select: { title: true, module: { select: { course: { select: { id: true, slug: true } } } } },
|
||||||
});
|
});
|
||||||
if (!lesson) throw new Error("Lesson not found");
|
if (!lesson) throw new Error("Lesson not found");
|
||||||
|
|
||||||
@@ -175,9 +180,20 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Родителя читаем один раз: он нужен и для проверки, и для письма автору ветки.
|
||||||
|
const parent = parentId
|
||||||
|
? await prisma.lessonComment.findUnique({
|
||||||
|
where: { id: parentId },
|
||||||
|
select: {
|
||||||
|
lessonId: true,
|
||||||
|
userId: true,
|
||||||
|
deleted: true,
|
||||||
|
user: { select: { email: true, name: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
if (parentId) {
|
if (parentId) {
|
||||||
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
if (!isAdmin && !isCurator) throw new Error("Forbidden");
|
||||||
const parent = await prisma.lessonComment.findUnique({ where: { id: parentId } });
|
|
||||||
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
if (!parent || parent.lessonId !== lessonId) throw new Error("Invalid parent");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +201,49 @@ export async function addComment(lessonId: string, slug: string, text: string, p
|
|||||||
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${slug}/lessons/${lessonId}`;
|
||||||
|
|
||||||
|
// Уведомление админам/кураторам о новом комментарии студента (не о своих ответах)
|
||||||
|
if (!isAdmin && !isCurator) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (asBool(settings.notifyOnComment)) {
|
||||||
|
const configured = parseNotificationEmails(settings.notificationEmails);
|
||||||
|
const recipients = configured.length > 0
|
||||||
|
? configured.map((email) => ({ email, name: "" }))
|
||||||
|
: await prisma.user.findMany({
|
||||||
|
where: { role: { in: ["admin", "curator"] } },
|
||||||
|
select: { email: true, name: true },
|
||||||
|
});
|
||||||
|
await Promise.all(
|
||||||
|
recipients.map((r) =>
|
||||||
|
sendCommentNotificationEmail(r.email, r.name, session.user.name, lesson.title, trimmed, lessonUrl)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обратное направление: автор ветки узнаёт письмом, что школа ответила. Без него
|
||||||
|
// ответ виден только тому, кто сам вернётся в урок.
|
||||||
|
if (parentId) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
const recipient = commentReplyRecipient({
|
||||||
|
notifyEnabled: asBool(settings.notifyStudentOnCommentReply),
|
||||||
|
replierId: session.user.id,
|
||||||
|
replierRole: session.user.role,
|
||||||
|
parent,
|
||||||
|
});
|
||||||
|
if (recipient) {
|
||||||
|
await sendCommentReplyEmail(
|
||||||
|
recipient.email,
|
||||||
|
recipient.name,
|
||||||
|
lesson.title,
|
||||||
|
trimmed,
|
||||||
|
lessonUrl,
|
||||||
|
session.user.name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,3 +267,24 @@ export async function deleteComment(commentId: string, lessonId: string, slug: s
|
|||||||
|
|
||||||
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function editComment(commentId: string, 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 > 10000) throw new Error("Invalid text");
|
||||||
|
|
||||||
|
const comment = await prisma.lessonComment.findUnique({ where: { id: commentId } });
|
||||||
|
if (!comment) throw new Error("Not found");
|
||||||
|
if (comment.deleted) throw new Error("Comment deleted");
|
||||||
|
// Только автор может редактировать текст своего комментария (модераторы — только удаление).
|
||||||
|
if (comment.userId !== session.user.id) throw new Error("Forbidden");
|
||||||
|
|
||||||
|
await prisma.lessonComment.update({
|
||||||
|
where: { id: commentId },
|
||||||
|
data: { text: trimmed, editedAt: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user