Compare commits

..

71 Commits

Author SHA1 Message Date
admins e5ba94cb33 Fix security, transaction, and badge issues from final review
- Validate file URLs against S3 prefix in messages route (Fix 1)
- Guard attachment hrefs with https:// check in QuestionThread and QuestionSplitView (Fix 2)
- Wrap message create + updatedAt bump in prisma.$transaction (Fix 3)
- Add questionsBadge count query to curator layout for admin branch (Fix 4)
- Fire-and-forget email sends with void Promise.all (Fix 5)
- Wrap req.json() calls in try/catch returning 400 on parse failure (Fix 6)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:56:31 +05:00
admins 12e1785ff2 Send homework-updated email to staff on submission edit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:52:04 +05:00
admins bd1e77c2a3 Add questions nav links and admin unread badge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:49:18 +05:00
admins d2362a3f1e Fix QuestionSplitView error handling, loading state, file key
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:47:35 +05:00
admins 3a2f64d47d Fix QuestionSplitView panel widths and message bubble styling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:45:57 +05:00
admins d32186c101 Add admin/curator split-view questions page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:42:39 +05:00
admins f7d428180b Fix student questions pages: CSS tokens, scroll, upload guard, S3 path
- Replace non-existent --surface/--surface-muted/--border-strong with actual
  design-system tokens (--color-surface, --background, --foreground, --muted)
- Remove tmp/ segment from S3 upload key in question-upload route
- Add auto-scroll to bottom on new message in QuestionThread
- Block Send while file upload is in progress (uploading guard)
- Replace <a> with Next.js <Link> in new question page back-link
- Replace hardcoded #c00 error color with var(--destructive) in both files
- Replace hardcoded #E8E8E0/#F5F5F0 hex backgrounds with CSS tokens
2026-05-19 13:40:05 +05:00
admins c5d2caa345 Fix message alignment in QuestionThread 2026-05-19 13:32:52 +05:00
admins 89d614fa00 Add student questions list, new question form, and thread pages
Tasks 8–10: student-facing questions UI — list page with unread badges,
new question form with client-side submission, and thread page with
QuestionThread component for real-time reply + file attachment flow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:31:31 +05:00
admins a9e6272d2d Fix API routes: closed-question guard, file validation, files sanitization, follow-up email
- Add CLOSED status guard in messages POST (returns 409)
- Add extension allowlist check in upload route + text/x-markdown MIME type
- Sanitize files JSON array before DB write
- Add sendQuestionFollowUpEmail helper and use it for student follow-up replies
- Scope email field to staff only in questions list query

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:28:08 +05:00
admins f2946db57a Add student questions API routes
Implements GET/POST /api/questions, GET /api/questions/[id] with read tracking, POST /api/questions/[id]/messages with email notifications, PATCH /api/questions/[id]/close for staff, and POST /api/student/question-upload for file attachments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:23:26 +05:00
admins 9cb56b9b04 Add question and homework-update email helpers 2026-05-19 13:20:06 +05:00
admins 6fa49d4113 Add indexes to StudentQuestion and StudentQuestionMessage 2026-05-19 13:18:54 +05:00
admins 90f155d334 Add StudentQuestion and StudentQuestionMessage models
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 13:11:51 +05:00
admins d47f79be1a Add student questions implementation plan 2026-05-19 12:59:25 +05:00
admins ec128f670a Add student questions feature design spec 2026-05-19 12:52:57 +05:00
admins a27089bc0c docs(lms): заменить устаревший DESIGN.md указателем на ДС-2
Прежний DESIGN.md ссылался на легаси-дизайн-систему v1 (кремовая
палитра + лаванда) и расходился с реальным globals.css. Новый —
указатель на канон ДС-2 «Second Brain LMS & Press» (терминальный
Aubade) и описание того, где токены живут в этом репозитории.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:14:16 +05:00
admins c94a8dafa9 style(lms): синхронизировать типографику со шкалой ДС-2 (+2px)
Переопределены токены шкалы Tailwind (--text-xs…--text-5xl) на +2px,
базовый размер body 18px, размеры компонентных классов (.btn-aubade,
.tag-aubade, .admin-sidebar-nav-link) и инлайновые fontSize приведены
к канону дизайн-системы ДС-2. Rem-база (html 16px) не тронута —
спейсинг и сетка не затронуты, растёт только текст.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 17:14:16 +05:00
admins 47840901c5 feat: collapsible mobile sidebar for student course view
Hamburger button (top-left, lg:hidden), dark overlay, slide-in animation.
Sidebar closes on lesson link click. Spacer added to prevent content overlap.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 05:07:29 +00:00
admins e3e6c713d2 Add reset password button to admin user page 2026-05-11 19:34:33 +05:00
admins 77016a03c7 Add active users last 24h card to admin dashboard 2026-05-11 18:52:40 +05:00
admins c1ae048c14 Rewrite password change form to use Server Action
Replaces client-side fetch with a proper Server Action + useActionState.
Uncontrolled inputs fix agent-browser testing and improve reliability.
Server action verifies bcrypt hash directly via Prisma.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:41:42 +05:00
admins 799117d287 Fix change-password form to use direct fetch instead of authClient
authClient.changePassword does not exist in better-auth v1.6 client bundle.
Use direct POST to /api/auth/change-password endpoint instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:10:18 +05:00
admins c445bfacad Add student profile page with password change
- New page /profile: shows name/email and password change form
- Uses authClient.changePassword (current + new + confirm)
- Student name in header is now a link to /profile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 17:03:33 +05:00
admins 41871a1e8e Fix build: remove deleted package imports from globals.css
tw-animate-css, shadcn, @tailwindcss/typography were removed in F008
but their @import/@plugin lines remained in globals.css, breaking the build.
CSS variables are defined inline so none of these imports are needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 16:56:06 +05:00
admins 444b9c0faf Apply tech debt fixes: middleware rename, React.cache, file size limits, remove dead deps
- Rename proxy.ts → middleware.ts, export proxy() → middleware() so Next.js edge
  protection actually activates (F001)
- Add PUBLIC_ROUTES entries for /forgot-password and /reset-password
- Wrap getSettings() in React.cache() to eliminate duplicate DB call in root layout (F003)
- Remove 4 console.log calls from saveLesson Server Action, keep console.error (F005)
- Add 50 MB file size guard to all 6 upload routes before arrayBuffer() read (F004)
- Remove unused deps: @tailwindcss/typography, shadcn, tw-animate-css (F008)
- Update CLAUDE.md: Prisma version 6.x → 7.x
- Add TECH_DEBT_AUDIT.md with 14 findings across 9 dimensions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 19:35:05 +05:00
admins 5547b427bb Add nonzero balance filter to users page, link from dashboard card 2026-05-08 14:24:31 +05:00
admins 2dfc42821c Move balance card below Activity block in admin dashboard 2026-05-08 14:20:44 +05:00
admins 33dcf9bb4a Add total user balances stat card to admin dashboard 2026-05-08 14:09:59 +05:00
admins a5e7b20699 Add forgot-password and reset-password flow
Users can now request a password reset link via email. Better Auth
sendResetPassword callback sends a branded email via Resend. Login
page shows success notice after password is set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 11:04:06 +05:00
admins 93e74951a7 Add balance transactions to user admin panel
Introduces BalanceTransaction model to track per-user balance history
(prepayments, refunds, partner credits). Admin can add/delete transactions;
current balance is computed as the running sum.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 09:24:25 +05:00
admins 48721759d3 Add comment field to user profile in admin panel
- Prisma: User.comment String? column + migration
- UserContactEditor: comment shown in view mode, textarea in edit mode
- updateUserContact action: saves comment to DB
2026-05-06 14:06:53 +00:00
admins 4f3b389f05 Update load test to 100 VUs with login jitter
Stages: 10 → 50 → 100 VUs, hold 3 min, ramp down.
Added random 0-10s sleep before first login to spread auth requests
and reduce Better Auth rate-limit retries.
Results: p(95)=244ms, pages 100% OK, auth retries 6.28% (rate-limit artifact).
2026-05-06 12:33:49 +00:00
admins 628226151b Add k6 load test script for 50 concurrent users
Tests login → dashboard → course page → 3 random lessons flow.
VU-level session: each VU logs in once and reuses the cookie jar.
Thresholds: p(95) < 3s, error rate < 5%.
Results on 50 VU / 5 min: p(95)=329ms, errors=4.94% (login rate-limit retries).
2026-05-06 11:51:39 +00:00
admins 9a21c705b7 Fix KinescopePlayer SSR crash on direct page load
The @kinescope/react-kinescope-player library accesses browser APIs
(window/document) during server-side rendering. In Next.js App Router,
client components are SSR-rendered on full page loads (direct URL,
refresh) but not on RSC navigations. This caused a 500 error for all
lessons with a kinescopeId when accessed directly.

Fix: defer rendering KinescopeReactPlayer until after mount with
useEffect + useState(false), so it only runs in the browser.
2026-05-06 11:42:01 +00:00
admins 7888a7598b Add coverImage poster to player, fix TipTap v3 editor reset, quiz admin preview
- Add coverImage field to Lesson model (prisma)
- Pass coverImage as poster prop to KinescopePlayer
- Show quiz in read-only preview mode for admin on lesson page
- Fix TipTap v3 editor reset on save: pass [lesson.id] as deps to useEditor
  to prevent setOptions() from reinitializing content on every re-render
- Replace saveLesson Server Action call with fetch PATCH /api/admin/lessons/[id]
  to avoid Next.js 16 automatic RSC refresh after Server Actions
- Simplify revalidatePath: only revalidate module page, not lesson editor page
2026-05-01 13:26:30 +00:00
admins c25369b766 Add threaded comment replies for admin and curator
- Add parentId field to LessonComment (self-referential FK, SetNull on delete)
- Show replies indented under parent comment with left border visual
- Add reply button (visible to admin/curator only) with inline textarea
- Only root comments shown in main list; replies nested below their parent
- Update comment count to include replies
- Server-side validation: only admin/curator can reply, parent must belong to same lesson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 13:17:30 +05:00
admins 6b5bfc853e Add name/email editing and days-based course access in admin user card
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 12:01:01 +05:00
admins e691124058 Fix LessonFile duplication: upsert on upload, delete S3 on remove
POST /api/admin/lesson-files now checks for an existing record with the
same (lessonId, name) before uploading — replaces it (old S3 object
deleted) instead of always creating a new one. Previously every save
cycle accumulated an extra copy; 1183 duplicates occupying 6.5 GiB were
found and cleaned up.

DELETE now receives the file URL and extracts the S3 key from it, so
manual deletion actually removes the object from storage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 10:59:37 +05:00
admins fdb9f96382 Add phone and birthday fields to User model with admin editor
- Add phone/birthday columns via Prisma migration
- Admin user page shows phone and birthday with inline edit UI
- UserContactEditor client component for editing contact info
- updateUserContact server action with admin-only guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 16:43:35 +05:00
admins c64f393a7b Implement platform settings (Stage 9)
- Wire settings to actual platform behavior: maintenance mode, registration toggle,
  notification emails, curator feedback emails, email verification flag
- Add logo (logoUrl, showLogo) and social network links (YouTube, VK, Telegram) settings
- Show logo + school name dynamically in student layout header
- Add footer to student layout with org requisites and social links
- Register page: read settings server-side, validate terms checkbox with legal links
- Login page: show notice when redirected from closed registration
- Settings form: add Logo and Social Networks sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 15:31:10 +05:00
admins ba0a630fd9 Fix quiz attempts page: fetch users separately (no User relation on QuizAttempt)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:07:28 +05:00
admins 2468671d82 Fix QuizAttempt field name: createdAt -> completedAt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:06:13 +05:00
admins 7242a989ba Add admin quiz attempts viewer
- /admin/quizzes: list all quizzes with question and attempt counts
- /admin/quizzes/[quizId]: view all student attempts with answers per question
- Add "Тесты" link to admin sidebar navigation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 12:05:01 +05:00
admins d2150153df Add quiz feature: student UI, admin editor, lesson page integration
- QuizSection component: shows questions as text inputs, read-only after submission
- QuizEditor component: admin CRUD for quiz questions with type selector
- saveQuiz/deleteQuiz server actions for admin
- submitQuizAttempt server action: idempotent, auto-marks lesson complete
- Student lesson page: renders QuizSection, updates complete button logic
- Admin lesson page: renders QuizEditor below homework section

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-27 11:43:16 +05:00
admins 3ed7bc147b Add lesson complete button with homework-aware logic
- Show "Отметить как пройденный" button only on lessons without homework
- Show static "Пройдено" badge on homework lessons completed via approval
- Auto-create LessonProgress when curator/admin approves homework submission
- Revalidate student lesson, course, and dashboard pages on approval

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 14:00:47 +05:00
admins 39d84a3db2 Add labeled file materials with format badge
- Store human-readable label in LessonFile.name via optional label field on upload
- Add PATCH endpoint to rename existing files inline
- Admin: label input before upload, click-to-edit inline rename
- Student: colored format badge (PDF/DOCX/XLSX/ZIP/etc) replaces paperclip emoji

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-26 11:55:07 +05:00
admins 15df731e37 Make lesson editor header and toolbar sticky on scroll
Both the controls bar (Save button, publish toggle) and the formatting
toolbar now stick to the top of the viewport while editing long lessons.
Header sticks at top: 0, toolbar sticks just below it at top: 62px.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:10:07 +05:00
admins bfa037885f Fix saveLesson: sanitize content JSON to prevent RSC proxy error
React treats TipTap's editor.getJSON() output as a non-plain object when
passed through Server Action serialization, causing isDecimal() inside
Prisma's query builder to receive a temporary client reference proxy and
throw ERROR 1213974697. JSON.parse(JSON.stringify()) strips the prototype
chain and any non-enumerable properties, ensuring Prisma receives a
clean plain object.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:58:13 +05:00
admins 8757537344 Debug: add try/catch and JSON sanitize in saveLesson
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:54:10 +05:00
admins 65aa669522 Fix prisma generator provider to prisma-client
The generated TypeScript client in src/generated/prisma was created with
prisma-client provider (new TypeScript-first generator), but schema.prisma
had prisma-client-js. This caused Docker builds to generate files in
node_modules/@prisma/client instead of src/generated/prisma, breaking the
@/generated/prisma/client import.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:47:26 +05:00
admins f4e74b38d4 Add explicit prisma output path to fix Docker build
Without output directive, Prisma 7 generates to node_modules/@prisma/client
in the Docker build context, causing the @/generated/prisma/client import
to fail with "Module not found". Explicit output ensures generated TypeScript
files are always placed at src/generated/prisma/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:38:19 +05:00
admins c050c005e4 Include src/generated in Docker build context for Prisma 7 TS client
Remove src/generated from .dockerignore so Turbopack can resolve
@/generated/prisma/client during build. The files are regenerated
by prisma generate inside the builder anyway.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 17:29:47 +05:00
admins af1fb6f61e Fix RSC toStringTag error: import PrismaClient from generated TS client
Use @/generated/prisma/client instead of @prisma/client to avoid
Turbopack creating a broken external proxy for the missing
.prisma/client/default module at runtime. Add @prisma/adapter-pg
to serverExternalPackages, remove unused resolveAlias.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:08:09 +05:00
admins 09e5653191 Add Turbopack resolveAlias for Prisma client to fix RSC crash
Without resolveAlias, Turbopack fails to resolve .prisma/client/default
and creates a temporary client reference, causing the toStringTag error.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:35:26 +05:00
admins 29f6533e63 Switch to prisma-client-js generator to fix Turbopack RSC crash
The new prisma-client generator outputs TypeScript files to src/generated/prisma/
which include import.meta.url at module level. Turbopack sees this and marks the
entire module as a client reference, causing 'Cannot access toStringTag on the
server' on every page that uses Prisma.

Switching to prisma-client-js puts the generated client in node_modules/@prisma/client
where serverExternalPackages can properly exclude it from the server bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:32:31 +05:00
admins 4821764a4f Fix Prisma 7 + Turbopack RSC compatibility by adding serverExternalPackages
Next.js 16 with Turbopack bundles @prisma/client into the RSC bundle,
causing it to be treated as a client module and creating 'temporary client
references'. This triggers the 'Cannot access toStringTag on the server' error
whenever Prisma result objects are used in Server Components.

Adding serverExternalPackages tells Turbopack to treat these as native
Node.js packages and keep them out of the RSC bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:23:02 +05:00
admins 5dfa79d357 Fix all Server Actions imported from dynamic route paths
All admin and student Client Components were importing Server Actions
from paths with dynamic segments ([courseId], [moduleId], [lessonId], [slug]).
This caused "Cannot access toStringTag on the server" RSC crash.

Consolidated all Server Actions into static files under src/lib/actions/:
- course-actions.ts  (modules + enrollment)
- module-actions.ts  (lessons + reorder + move)
- user-actions.ts    (bulk grant / revoke)
- student-actions.ts (progress + homework + comments)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:05:26 +05:00
admins 9eb21e3ab4 Move Server Actions to static paths to fix RSC temporary client reference error
Server Actions imported from dynamic route paths ([courseId]/[moduleId]/[lessonId])
caused "Cannot access toStringTag on the server" crash after saving a lesson.
Moved saveLesson, saveHomework, deleteHomework to src/lib/actions/*.ts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:58:27 +05:00
admins af8644ebce Serialize all Prisma proxy data in admin lesson and module pages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:48:43 +05:00
admins 0bde11b86e Serialize all Prisma data before passing to Client Components
Prisma 7 proxy objects (DateTime, _count, relations) cannot be
serialized by React RSC. Convert all course page data to plain
JSON objects with JSON.parse/stringify before passing as props.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:42:05 +05:00
admins d8be6d6d95 Fix Prisma 7 JSON proxy serialization in RSC props
Prisma 7 wraps Json fields in proxy objects that RSC cannot serialize.
Fix: select specific columns (exclude content) in module page,
and JSON.parse/stringify lesson content before passing to client.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:36:04 +05:00
admins 9731fcab48 Add impersonatedBy field to Session model for admin plugin
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:25:39 +05:00
admins 0e4f6c4b01 Fix impersonation: use direct fetch to /api/auth/admin/impersonate-user
authClient.admin.impersonateUser is not registered in pathMethods
in better-auth v1.6 client plugin — call the endpoint directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:18:49 +05:00
admins dd198349fb Fix impersonation: hard navigation + stop impersonating banner
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:12:47 +05:00
admins 808bcadfca Fix nested list spacing in TipTap lesson content
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 13:05:33 +05:00
admins ab37af59f2 Fix server component passing event handlers to client components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:59:00 +05:00
admins ce305eab58 Add admin impersonation button to users table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 12:52:13 +05:00
admins e590f541b3 Add automated backup scripts for PostgreSQL and S3 files to Backblaze B2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:38:48 +05:00
admins 48a9398905 Add optional audio response for students in homework submissions
- Course: add allowAudio toggle (per-course setting, off by default)
- HomeworkSubmission: add audioUrl field
- Student: AudioRecorder in homework form when allowAudio is enabled
- Student: show audio player in submission view and curator feedback view
- Curator: show student audio on submission detail page
- AudioRecorder: accept uploadUrl prop (reused for student/curator)
- API: /api/student/audio-upload route for S3 upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:17:46 +05:00
admins 3855bbd4be Add homework review workflow: statuses, audio, file attachments, tabs
- HomeworkSubmission: add status (PENDING/REVIEWING/APPROVED/REJECTED) + statusAt
- HomeworkFeedback: add files (Json) + audioUrl fields
- Curator detail page: meta table, content tabs, feedback history with audio/files
- FeedbackForm: file upload, audio recorder (Web Audio API + S3), action buttons
- AudioRecorder component: record → preview → upload to S3
- ContentTabs: toggle between homework description and lesson content (TipTap read-only)
- Homework list: 4-color status badges with proper filtering
- API routes: /api/curator/upload and /api/curator/audio-upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:01:55 +05:00
122 changed files with 8752 additions and 3410 deletions
-1
View File
@@ -5,7 +5,6 @@
.env.production .env.production
node_modules node_modules
.next .next
src/generated
*.md *.md
docker-compose.yml docker-compose.yml
docker-compose.prod.yml docker-compose.prod.yml
+3
View File
@@ -45,3 +45,6 @@ yarn-error.log*
next-env.d.ts next-env.d.ts
/src/generated/prisma /src/generated/prisma
# Claude Code local plugins (external git repos, не коммитим)
.claude/plugins/
+307 -4
View File
@@ -1,5 +1,308 @@
<!-- BEGIN:nextjs-agent-rules --> # AGENTS.md — LMS Second Brain
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. Собственная LMS-платформа для образовательных курсов по PKM и Obsidian.
<!-- END:nextjs-agent-rules --> Заменяет emdesell.ru. Масштаб: ~1000 аккаунтов, ~200 активных, до 10 курсов.
Production: **https://school.second-brain.ru**
> Подробная техническая документация — в `TECHNICAL.md`.
> Роадмап и текущий статус — в `ROADMAP.md`.
> Полные правила для Claude Code — в `CLAUDE.md`.
---
## Стек
| Слой | Технология | Версия |
|------|-----------|--------|
| Фреймворк | Next.js (App Router) | **16.2.2** |
| Язык | TypeScript (strict) | 5.x |
| UI | React | 19 |
| Стили | Tailwind CSS (CSS-based, **без** tailwind.config.ts) | 4.x |
| Компоненты | shadcn/ui (Base UI, **не Radix**) | v4 |
| ORM | Prisma | 7.x |
| Auth | Better Auth (**не NextAuth**) | 1.6.0 |
| Редактор | TipTap WYSIWYG | 2.x |
| Drag-and-drop | @dnd-kit | latest |
| БД | PostgreSQL | 16 |
| Email | Resend | latest |
| Хранилище | Hetzner Object Storage (S3-совместимый) | — |
| Видео | Kinescope (iframe embed) | — |
| Валидация | Zod | 3.x |
---
## Критические отличия от стандартных версий
Эти технологии отличаются от того, что содержится в обучающих данных большинства моделей. **Читай документацию перед написанием кода.**
### Next.js 16.2.2
- Используется `proxy.ts` вместо `middleware.ts`
- Экспортируемая функция называется `proxy`, не `middleware`
- Перед написанием кода смотри `node_modules/next/dist/docs/`
### Tailwind CSS v4
- **Нет файла `tailwind.config.ts`** — вся кастомизация через CSS
- Конфиг: `@import "tailwindcss"` и `@theme` в `globals.css`
### shadcn/ui v4
- Базируется на `@base-ui/react`, **не Radix**
- Нет пропа `asChild` — триггеры обычные элементы
- Установка: `npx shadcn@latest add <component>`
### Prisma 7.x
- Импорт: `from "@/generated/prisma/client"` (не `from "@/generated/prisma"`)
- Требует адаптер: `new PrismaPg({ connectionString })`
- Не генерирует `index.ts`
### Better Auth 1.6.0
- **Не путать с NextAuth** — другая библиотека, другое API
- В этом проекте используется **bcrypt** (не scrypt по умолчанию)
- Настройки `password.hash` / `password.verify` в `src/lib/auth.ts`
- `auth-client.ts` не использует `baseURL` — берёт `window.location.origin`
- Seed-пользователи вставлены через SQL с `emailVerified = true`
---
## Команды
```bash
# Разработка
npm run dev # localhost:3000
docker compose up -d # Поднять PostgreSQL локально
# Проверка качества
npm run lint # ESLint
npm run type-check # tsc --noEmit
# Сборка
npm run build
npm run start
# База данных
npx prisma migrate dev --name <snake_case_name> # Новая миграция
npx prisma migrate deploy # Применить в production
npx prisma generate # Пересоздать клиент
npx prisma db seed # Заполнить тестовыми данными
npx prisma studio # GUI для БД
# Production-деплой (на сервере в /root/digital-household/lms-sb/)
git pull
docker compose -f docker-compose.prod.yml up -d --build
```
При старте production-контейнер автоматически запускает `prisma migrate deploy`, затем `node server.js`.
---
## Структура проекта
```
lms-system/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # login, register, verify-email
│ │ ├── (student)/ # dashboard, courses/[slug], lessons/[lessonId]
│ │ ├── curator/ # homework review, dashboard
│ │ ├── admin/ # courses, users, settings, categories
│ │ └── api/ # REST endpoints + Better Auth handler
│ ├── components/
│ │ ├── ui/ # shadcn/ui (автогенерация, не трогать)
│ │ ├── editor/ # TipTap WYSIWYG
│ │ ├── player/ # Kinescope Player wrapper
│ │ ├── course/ # Компоненты курса
│ │ └── layout/ # Header, Sidebar, Footer
│ ├── lib/
│ │ ├── auth.ts # Better Auth config (сервер)
│ │ ├── auth-client.ts # Better Auth client (браузер)
│ │ ├── prisma.ts # Prisma singleton
│ │ ├── s3.ts # Hetzner S3 клиент
│ │ ├── email.ts # Resend email helpers
│ │ └── utils.ts # cn() и утилиты
│ ├── types/ # TypeScript-типы
│ ├── proxy.ts # Auth middleware (защита маршрутов)
│ └── middleware.ts # Обёртка над proxy
├── prisma/
│ ├── schema.prisma # Схема БД (~314 строк)
│ ├── seed.ts # Тестовые данные
│ └── migrations/ # НЕ РЕДАКТИРОВАТЬ ВРУЧНУЮ
├── docker-compose.yml # Локальная разработка
├── docker-compose.prod.yml # Production
├── Dockerfile # Multi-stage build
├── .env.example # Шаблон переменных (без секретов)
└── .env.local # Локальные секреты (в .gitignore)
```
---
## Роли и маршруты
| Роль | Маршруты | Описание |
|------|---------|----------|
| `admin` | `/admin/*`, `/curator/*`, всё | Полный доступ |
| `curator` | `/curator/*`, `/dashboard` | Проверка ДЗ, комментарии |
| `student` | `/dashboard`, `/courses/*` | Просмотр курсов, прогресс |
Защита маршрутов — в `src/proxy.ts` + проверка сессии в layout/page.
---
## Модель данных (ключевые сущности)
```
User → Session, Account, Verification # Better Auth
Category → Course → Module → Lesson # Структура контента
Lesson → LessonFile # Файлы к уроку
CourseEnrollment (userId + courseId) # Доступ с expiresAt
AccessLog # Аудит доступов
LessonProgress (userId + lessonId) # Прогресс ученика
Lesson → Homework → HomeworkSubmission → HomeworkFeedback # ДЗ
Lesson → LessonComment # Обсуждения (soft-delete)
Lesson → Quiz → QuizQuestion → QuizOption # Тесты
Quiz → QuizAttempt # Результаты тестов
Settings (key-value) # Настройки платформы
```
---
## Дизайн-система «Second Brain Aubade»
Типографский, монохромный, газетный стиль.
| Токен | Значение |
|-------|---------|
| Шрифт | Fira Mono (400/500/700, Latin + Cyrillic) |
| Фон | `#F5F5F0` (тёплый off-white) |
| Текст | `#323232` |
| Поверхность | `#E8E8E0` |
| Акцент | `#E8F0D8` (зелёный) |
| Border | `#AAAAAA` |
| Сайдбар | `#2A2A28` (тёмный) |
**Aubade-эффект** (карточки и кнопки):
- Border: `2px solid #AAAAAA`
- Shadow: `4px 4px 0 0 #AAAAAA`
- Hover: `translate(-2px, -2px)` + shadow `6px 6px`
- Active: `translate(2px, 2px)`, shadow убирается
- CSS-классы: `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`
---
## Инфраструктура
| Компонент | Значение |
|-----------|---------|
| Сервер | Hetzner VPS — 8 vCPU / 16 GB RAM / 320 GB NVMe |
| Reverse proxy | Caddy (auto HTTPS, Let's Encrypt) |
| Порт | 3010 (внутри контейнера 3000) |
| БД | PostgreSQL 16 (контейнер `lms-sb-db-1`) |
| Object Storage | Hetzner S3, endpoint `nbg1.your-objectstorage.com`, бакет `second-brain-lms` |
| Git | Gitea — `https://git.second-brain.ru/admins/lms-sb` |
| Email | Resend, домен `mailsend.second-brain.ru` |
| Бэкапы | PostgreSQL → Backblaze B2 (ежедневно, 03:00, ротация 7 дней) |
---
## Переменные окружения
```env
DATABASE_URL="postgresql://lms_user:password@localhost:5432/lms_db"
BETTER_AUTH_SECRET="generate-with-openssl-rand-base64-32"
BETTER_AUTH_URL="http://localhost:3000"
RESEND_API_KEY=""
EMAIL_FROM="noreply@mailsend.second-brain.ru"
S3_ENDPOINT="https://nbg1.your-objectstorage.com"
S3_BUCKET="second-brain-lms"
S3_ACCESS_KEY=""
S3_SECRET_KEY=""
S3_REGION="eu-central"
```
Секреты — **только в `.env.local`**. При добавлении новых переменных обновлять `.env.example`.
---
## Правила написания кода
### Языки
- **UI-строки** (заголовки, кнопки, сообщения): русский
- **Переменные, функции, файлы, комментарии**: английский
- **Коммиты**: английский, imperative mood (`Add lesson progress`, `Fix auth redirect`)
### Стиль
- Server Actions для форм и мутаций
- Не добавлять абстракции «на будущее» — только текущий этап
- Нет `console.log` в production (только `console.error` для реальных ошибок)
- Нет захардкоженных секретов, URL, ID
### Миграции БД
- **Никогда** не редактировать `prisma/migrations/` вручную
- **Всегда** спрашивать перед миграцией, которая меняет или удаляет существующие поля
- Имена миграций: английский, snake_case (`add_lesson_progress`)
- Перед `prisma migrate deploy` на production — бэкап БД
### Файлы и загрузки
- Все файлы (ДЗ, PDF, изображения) — через Hetzner Object Storage, **не на диск VPS**
- Обложки курсов: 16:9, max 5 MB, JPG/PNG/WebP
- Изображения в уроках: max 10 MB
- Файлы к уроку: max 100 MB, PDF/ZIP/DOCX
### Коммиты
- Один коммит = одна логически завершённая единица
- Перед коммитом: `npm run lint && npm run type-check`
- После завершения каждого этапа ROADMAP — `git push` в Gitea
---
## Чек-лист перед коммитом
- [ ] `npm run lint` — без ошибок
- [ ] `npm run type-check` — без ошибок
- [ ] Новые `.env` переменные добавлены в `.env.example`
- [ ] Миграция БД согласована (если есть)
- [ ] Нет `console.log`, нет секретов в коде
---
## Тестовые аккаунты
| Email | Пароль | Роль |
|-------|--------|------|
| admin@second-brain.ru | Password123! | admin |
| curator@second-brain.ru | Password123! | curator |
| student@second-brain.ru | Password123! | student |
---
## Текущий статус проекта
**Завершено (9 из 13 этапов):**
- Этап 0: Каркас, auth, роли, деплой
- Этап 1: Курсы → Модули → Уроки (CRUD, drag-and-drop, TipTap, S3)
- Этап 1.5: Расширенный доступ (сроки, категории, AccessLog)
- Этап 2: Kinescope-интеграция, рендер уроков для ученика
- Этап 3: Прогресс (кнопка завершения, прогресс-бар)
- Этап 5: Домашние задания + обратная связь куратора
- Этап 6: Обсуждения под уроками
- Этап 7: Email-уведомления (Resend)
- Этап 8: Импорт уроков из Markdown (Obsidian)
**В работе:**
- Этап 9: Настройки платформы (Admin Settings)
**Впереди:**
- Этап 11: Импорт/экспорт учеников (CSV, миграция с emdesell)
- Этап 12: Telegram-бот + аналитика (Yandex.Metrika)
- Этап 13: Тесты и квизы с автопроверкой
**Бэклог:** сертификаты, геймификация, платежи, медиатека, цифровой сад, CI/CD
Полный роадмап с деталями и критериями готовности — в `ROADMAP.md`.
---
## Известные ограничения
- Seed-пользователи вставлены через SQL с `emailVerified = true` (обход Better Auth)
- Загрузка файлов: нет лимита на уровне Next.js (только S3)
- Drag-and-drop: возможны race conditions при быстрых перетаскиваниях (некритично)
- `expiresAt` проверяется в UI, но не блокирует доступ на уровне middleware
+1 -1
View File
@@ -11,7 +11,7 @@
| TypeScript | 5.x | Язык | | TypeScript | 5.x | Язык |
| React | 19 | UI | | React | 19 | UI |
| PostgreSQL | 16 | База данных | | PostgreSQL | 16 | База данных |
| Prisma | 6.x | ORM + миграции | | Prisma | 7.x | ORM + миграции |
| Better Auth | latest | Аутентификация и сессии | | Better Auth | latest | Аутентификация и сессии |
| Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) | | Tailwind CSS | 4.x (CSS-based config) | Стили (нет tailwind.config.ts) |
| shadcn/ui | latest | UI-компоненты | | shadcn/ui | latest | UI-компоненты |
+34
View File
@@ -0,0 +1,34 @@
# Дизайн-система — LMS
Этот проект использует дизайн-систему **ДС-2 «Second Brain LMS & Press»** — терминальный язык «Aubade».
## Канон
Полная спецификация (9 секций, формат `DESIGN.md`):
- **Source of truth:** `SecondBrainTech/02-Стандарты/Дизайн-LMS/DESIGN.md`
- Копия для Open Design: `~/Documents/Claude/open-design/design-systems/second-brain-lms/`
- Превью со всеми примерами: `preview.html` в тех же каталогах
> Канон правится только там. Этот файл — практический указатель для разработки внутри репозитория.
## Язык в двух словах
Терминальный, моноширинный, «реестровый». Серо-зелёная палитра, острые углы 2px, выраженные рамки 2px, жёсткие тени-подложки с физикой hover/active. Тёмный админ-сайдбар. Без кремовых тонов и серифа — это язык ДС-1 (сайт и PDF), отдельной парной системы.
## Где токены в этом репозитории
Реализация — `src/app/globals.css`:
- **Палитра** — CSS-переменные в `:root`: `--background #F5F5F0`, `--foreground #323232`, `--accent #E8F0D8`, `--border #AAAAAA`, тёмный сайдбар `--sidebar-*`.
- **Типографическая шкала** — переопределённые токены Tailwind `--text-*` в блоке `@theme` (канон ДС-2, +2px к дефолту Tailwind).
- **Шрифт** — Fira Mono, подключение в `src/app/layout.tsx` через `next/font/google`.
- **Компонентные классы** — `.card-aubade`, `.btn-aubade`, `.btn-aubade-accent`, `.tag-aubade`, `.admin-sidebar*`.
## Письма Press
Рассылка Second Brain Press — поверхность Email той же ДС-2. Шаблон — Listmonk template id=1 (табличная вёрстка, Arial, карта 620px с рамкой 2px `#AAAAAA`). Подробности — секция 5 канонического `DESIGN.md`.
## История
Предыдущая версия этого файла ссылалась на легаси-дизайн-систему v1 (кремовая палитра + лаванда, `~/Documents/Claude/design-system/`). Она заменена: v1 — легаси, актуальна ДС-2.
+24
View File
@@ -275,6 +275,30 @@
## Бэклог (после MVP) ## Бэклог (после MVP)
- **Миграция email-шаблонов на React Email 6 + Resend CLI 2.0** (Resend Launch Week 6, 24.04.2026):
- React Email 6: новые шаблоны для auth и ecommerce flows (welcome, password reset, purchase confirmation, course progress) — можно взять за основу вместо своих
- Resend CLI 2.0: локальный preview и тестирование шаблонов (`resend send --local ...`), 50+ команд
- Embeddable open-source editor (в одну строку) — отложить, пока не требуется
- Сейчас Этап 7 (Email-уведомления) завершён на базовой связке, задача — рефакторинг на React Email
- **Самостоятельная регистрация + автоматический онбординг** — два сценария входа и воронка после регистрации:
**Сценарии регистрации:**
- С лендинга через покупку — пользователь оплачивает курс, аккаунт создаётся автоматически, письмо с доступом приходит сразу
- Прямой вход на платформу — пользователь приходит по реферальной ссылке, из соцсетей, от партнёров — регистрируется сам без покупки
**Автоматический онбординг после регистрации:**
- Автоназначение вводных / вотер-модулей курсов (бесплатные превью, чтобы зацепить)
- Доступ к базовой библиотеке материалов по умолчанию (статьи, шаблоны, гайды — определяется в настройках)
- Приветственная воронка: серия писем / уведомлений, которая ведёт к первой покупке
- Уведомление администратора о новой регистрации (email + Telegram)
**Что нужно проработать:**
- Публичная страница регистрации (+ CAPTCHA, опционально)
- Настройка в Этапе 9: «Регистрация открыта: да/нет» + выбор вводных курсов/модулей, которые назначаются автоматически
- Интеграция с платёжной системой: оплата на лендинге → автосоздание аккаунта → автовыдача доступа к купленному курсу
- Разграничение: что видит гость / зарегистрированный без покупки / купивший курс
- Резервное копирование PostgreSQL (cron → Object Storage) - Резервное копирование PostgreSQL (cron → Object Storage)
- GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy) - GitHub Actions: CI/CD pipeline (lint → build → push Docker image → deploy)
- Сертификаты по окончании курса - Сертификаты по окончании курса
+153
View File
@@ -0,0 +1,153 @@
# Tech Debt Audit — lms-sb
Generated: 2026-05-09
---
## Executive Summary
- **1 Critical**: middleware не работает — файл называется `proxy.ts` вместо `middleware.ts`, защита маршрутов на уровне Next.js отсутствует
- **3 High**: двойная загрузка полной структуры курса на каждый урок; `getSettings()` вызывается дважды в root layout; нет ограничения размера загружаемых файлов
- **0 тестов** — ни одного test-файла во всём проекте
- **4 отладочных `console.log`** в production-коде Server Action
- Zod установлен как зависимость, но нигде не используется — Server Actions принимают `FormData` без валидации схемы
- 3 уязвимости npm **high** severity (next, fast-uri, fast-xml-builder)
- `settings-form.tsx` — 506 строк, единственный god-файл, но внутренняя структура оправданна
- Самые горячие файлы совпадают с самыми крупными: student lesson page (12 правок, 270 строк) и lesson-editor (8 правок, 408 строк) — концентрация долга
---
## Architectural Mental Model
LMS построена на Next.js 16 App Router с тремя зонами доступа: `(auth)`, `(student)`, `admin`/`curator`. Мутации идут через Server Actions, данные читаются в RSC. Better Auth отвечает за сессии и роли. Prisma 7 с PostgreSQL через собственный PrismaPg адаптер (обходит ограничения Turbopack).
Главная аномалия: middleware объявлен в `src/proxy.ts` с функцией `proxy()`, а не в `src/middleware.ts` с функцией `middleware()`. Next.js его не подхватывает. Защита работает только за счёт явных проверок сессии в каждой странице и action — что само по себе достаточно, но заявленный в CLAUDE.md "Auth middleware (защита маршрутов)" фактически не существует.
Второй структурный факт: при открытии страницы урока студентом происходит двойная загрузка полной структуры курса — один раз в `layout.tsx` (для sidebar), второй в `page.tsx` (для prev/next навигации). Это N+1 на уровне layout/page.
---
## Findings
| ID | Category | File:Line | Severity | Effort | Description | Recommendation |
|----|----------|-----------|----------|--------|-------------|----------------|
| F001 | Security | src/proxy.ts:1 | Critical | S | Файл `proxy.ts` не является Next.js middleware. Next.js ищет `src/middleware.ts` с экспортом `middleware`. Маршруты не защищены на уровне edge. | Переименовать файл в `src/middleware.ts`, переименовать экспорт `proxy``middleware` |
| F002 | Performance | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:37 | High | M | `page.tsx` загружает `lesson.module.course.modules` с вложенными уроками для prev/next nav — та же структура уже загружена в `layout.tsx:20`. Двойной DB-запрос на каждый pageview урока. | Вынести prev/next навигацию в layout или передавать через `searchParams`/context вместо повторной загрузки |
| F003 | Performance | src/app/layout.tsx:14,27 | High | S | `getSettings()` вызывается дважды в одном компоненте — в `generateMetadata()` и в `RootLayout()`. Два одинаковых DB-запроса на каждый запрос. | Объединить в один вызов или обернуть `getSettings` в `React.cache()` |
| F004 | Security | src/app/api/admin/upload/route.ts:1, src/app/api/student/homework-upload/route.ts:1, src/app/api/curator/audio-upload/route.ts:1 | High | S | Ни один upload endpoint не проверяет размер файла перед `file.arrayBuffer()`. Загрузка 1 ГБ файла ляжет в память Node.js. | Добавить проверку `file.size` до 50 МБ (или другого лимита) сразу после `form.get("file")` |
| F005 | Observability | src/lib/actions/lesson-actions.ts:24,27,42,48 | Medium | S | 4 `console.log` в production Server Action. Логируют `lessonId` и статус каждого сохранения в prod-консоль. | Убрать все 4. Оставить только `console.error` в catch-блоках. |
| F006 | Type & Contract | src/app/admin/courses/actions.ts:28-47 | Medium | M | Server Actions принимают `FormData` и читают поля через `as string` без валидации. Zod установлен, но не используется нигде в проекте. | Добавить Zod-схему на входе `createCourse` и `updateCourse`; повторить паттерн в остальных actions |
| F007 | Dependencies | package.json | Medium | S | `npm audit` показывает 3 high severity уязвимости: `next` (сам фреймворк), `fast-uri`, `fast-xml-builder`. | `npm update next` до последнего патча; проверить влияние на остальные зависимости |
| F008 | Dependencies | package.json | Low | S | `depcheck` находит 4 неиспользуемые зависимости: `@tailwindcss/typography`, `shadcn`, `tw-animate-css`, `zod`. Если Zod будет использован (F006), убрать оставшиеся три. | `npm remove @tailwindcss/typography shadcn tw-animate-css` |
| F009 | Architecture | src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:102-106 | Low | S | Функция `formatSize` определена внутри Server Component — при каждом рендере пересоздаётся. Не ошибка, но засоряет файл. | Вынести в `src/lib/utils.ts` |
| F010 | Consistency | src/app/admin/courses/[courseId]/actions.ts:12, src/app/admin/categories/actions.ts:11, src/app/admin/settings/actions.ts:11 | Low | S | Разные строки ошибки авторизации: `"Forbidden"` (EN), `"Нет доступа"` (RU), `"Unauthorized"` (EN). Нет единого паттерна. | Выбрать один формат и применить везде; ошибки авторизации не должны уходить клиенту как читаемый текст |
| F011 | Security | src/app/layout.tsx:32,37 | Low | — | `dangerouslySetInnerHTML` с `headCode`/`bodyCode` из БД — admin может вставить произвольный JS. | Намеренная функция (code injection для аналитики). Задокументировать явно, что это admin-only привилегия. Добавить проверку роли на странице настроек — уже есть. |
| F012 | Testing | — | High | L | Ни одного теста во всём проекте. Нет `*.test.*`, `*.spec.*`, нет `__tests__/`. Горячие файлы (lesson page, lesson-editor) не прикрыты ничем. | Начать с unit-тестов `src/lib/md-to-tiptap.ts` (чистая функция, высокий риск регрессии) и `src/lib/settings.ts`. Для UI — Playwright E2E на login + lesson complete flow. |
| F013 | Consistency | src/app/(student)/courses/[slug]/layout.tsx:54, src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx:53 | Low | S | Оба файла самостоятельно проверяют `isAdmin` для conditional DB queries с одинаковой логикой. Паттерн не вынесен. | Не критично при текущем размере, но при росте числа маршрутов станет проблемой |
| F014 | Documentation | src/proxy.ts:1, CLAUDE.md | Medium | S | CLAUDE.md: `src/middleware.ts — Auth middleware (защита маршрутов)`. Файла не существует, существует `src/proxy.ts`. Документация врёт. | После исправления F001 — обновить CLAUDE.md |
---
## Top 5 "fix these first"
### 1. F001 — Переименовать proxy.ts в middleware.ts
```bash
git mv src/proxy.ts src/middleware.ts
```
В `src/middleware.ts`:
```ts
// было:
export function proxy(request: NextRequest) { ... }
// стало:
export function middleware(request: NextRequest) { ... }
```
`config` экспорт уже правильный — оставить как есть. Это однострочный фикс с нулевым риском регрессии.
---
### 2. F003 — Двойной `getSettings()` в root layout
```ts
// src/app/layout.tsx — было: два вызова
const settings = await getSettings(); // в generateMetadata
const settings = await getSettings(); // в RootLayout
// стало: обернуть в React.cache
// src/lib/settings.ts
import { cache } from "react";
export const getSettings = cache(async (): Promise<Settings> => { ... });
```
Один `cache()` снимает оба дублированных запроса в рамках одного render pass.
---
### 3. F004 — Лимит размера файлов в upload endpoints
В каждом из 5 upload routes добавить сразу после получения файла:
```ts
const MAX_BYTES = 50 * 1024 * 1024; // 50 МБ
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
}
```
---
### 4. F005 — Убрать console.log из lesson-actions.ts
```ts
// src/lib/actions/lesson-actions.ts — удалить строки 24, 27, 42, 48
console.log("[saveLesson] start", lessonId); // удалить
console.log("[saveLesson] auth ok"); // удалить
console.log("[saveLesson] db update ok"); // удалить
console.log("[saveLesson] done"); // удалить
```
---
### 5. F002 — Устранить двойную загрузку структуры курса
`layout.tsx` уже загружает все модули/уроки курса для sidebar. `page.tsx` загружает ту же структуру ещё раз для prev/next навигации. Самое чистое решение — передавать `allLessons` через `searchParams` или вычислять в layout и передавать через `slot`:
Альтернатива проще: убрать из `page.tsx` `include: { modules: { include: { lessons } } }` и принять `prevLessonId`/`nextLessonId` как query params, которые layout прописывает в ссылки sidebar-а.
---
## Quick Wins
- [ ] **F001**`git mv src/proxy.ts src/middleware.ts` + переименовать экспорт (5 минут)
- [ ] **F003**`import { cache } from "react"` в settings.ts, обернуть `getSettings` (10 минут)
- [ ] **F005** — Удалить 4 строки `console.log` в lesson-actions.ts (2 минуты)
- [ ] **F007**`npm update next` — закрыть CVE в самом фреймворке
- [ ] **F008**`npm remove @tailwindcss/typography shadcn tw-animate-css` — убрать мёртвый вес
- [ ] **F004** — Добавить `file.size` проверку в 5 upload routes (15 минут)
---
## Things that look bad but are actually fine
**`src/generated/prisma/`** — 20+ сгенерированных файлов в `src/`. Выглядит как мусор, но это намеренно: Prisma 7 с Turbopack требует TypeScript-клиента в src для корректной работы RSC. Объяснено в коммите `af8644e` и в memory-файле `project_lms_prisma_config.md`. Не трогать.
**`src/app/(student)/courses/[slug]/layout.tsx:54`** — `prisma.lessonProgress.findMany({ where: { lessonId: { in: allLessonIds } } })` загружает прогресс по всем урокам курса. Выглядит избыточно, но это единственный способ отрисовать sidebar с чекбоксами без N+1 запроса на каждый урок. Правильный паттерн.
**`src/lib/auth.ts:37`** — Хардкод `"https://school.second-brain.ru"` в `trustedOrigins`. Выглядит как нарушение правила "нет захардкоженных URL". На самом деле это security-критичный список и он должен быть явным, не конфигурируемым через переменные (иначе можно было бы переопределить в .env). Оставить.
**`catch (() => {})` в трёх местах** (email в import, S3 delete) — выглядит как проглатывание ошибок. В контексте это правильно: ошибка отправки welcome-email или удаления старого файла из S3 не должна ронять основную операцию (импорт/апгрейд файла).
**506 строк в `settings-form.tsx`** — формально god-файл, но это один большой конфиг-экран с однородной структурой (Section + Field + Toggle). Файл читается линейно, нет запутанной логики. Декомпозиция не добавит ясности.
---
## Open Questions
1. **`forgot-password` и `reset-password` маршруты** не входят в `PUBLIC_ROUTES` в `proxy.ts:4`. Это намеренно — эти страницы требуют cookie для валидации токена? Или просто забыто?
2. **`src/app/api/admin/import-md/route.ts`** — существует, но нет UI для вызова. Мёртвый endpoint или WIP?
3. **QuizOption** — схема Prisma содержит `QuizOption` с `isCorrect`, но в `page.tsx` урока quiz загружается без `options` (`include: { questions: { orderBy: { order: "asc" } } }`). Тест работает только с open-ended вопросами или options загружаются где-то ещё?
4. **`load-test.js`** в корне репо — `k6` отмечен как missing dependency в depcheck. Это намеренно отдельный инструмент или планируется CI-интеграция?
@@ -0,0 +1,159 @@
# Student Questions — Design Spec
_Created: 20260519_
## Overview
A support-chat feature inside the LMS. Students ask questions, school staff (admin/curator) answers. Each question is a threaded conversation with open/closed status. Includes file attachments and email notifications for all parties.
Also in scope: email notifications for homework submissions (new + student updates).
---
## Data Model
### StudentQuestion
```
id String @id @default(cuid())
userId String -- student who created it
courseId String? -- optional course context
title String
status QuestionStatus @default(OPEN) -- OPEN | CLOSED
closedAt DateTime?
closedById String? -- admin/curator who closed
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User
course Course?
messages StudentQuestionMessage[]
```
### StudentQuestionMessage
```
id String @id @default(cuid())
questionId String
authorId String
text String
files String[] -- S3 paths: questions/{questionId}/{messageId}/{filename}
isRead Boolean @default(false)
createdAt DateTime @default(now())
question StudentQuestion
author User
```
### QuestionStatus (enum)
```
OPEN
CLOSED
```
---
## Routes
### Student
| Route | Description |
|---|---|
| `(student)/questions` | List of own questions with unread indicators |
| `(student)/questions/new` | Form to create a new question |
| `(student)/questions/[id]` | Thread view — read messages + reply |
### Admin / Curator
| Route | Description |
|---|---|
| `admin/questions` | Split-view: question list left, thread right |
| `curator/questions` | Same split-view (curators have same access as admins here) |
---
## UI Behaviour
### Student — Questions List (`/questions`)
- Header: "Мои вопросы" + "+ Задать вопрос" button (→ `/questions/new`)
- Each row: title, message count, last activity, status badge (ОТКРЫТ / ЗАКРЫТ)
- Unread indicator: black dot + "Новый ответ от школы" when school replied since last student visit
- Closed questions: dimmed (opacity 0.7), grey badge
- Active (unread) questions: bold border, bold title
### Student — Thread (`/questions/[id]`)
- Header: question title, created date, status, "← Все вопросы" link
- Message bubbles: student messages left (#E8E8E0), school messages right (#F5F5F0 + green border #E8F0D8)
- New school message: bold label "🔵 новое" in timestamp
- File attachments shown inline under message text (📎 filename · size)
- Reply form at bottom: textarea + attach button + send button
- Attachment types: jpg, png, pdf, md · max 10 MB
- Student CAN reply to closed questions (creates new message, does NOT reopen question)
### Admin/Curator — Split View (`/admin/questions`, `/curator/questions`)
- Left panel (45%): tab filter "Открытые / Закрытые", question list sorted by last activity
- Unread: red dot, green-tinted background, bold student name
- Right panel (55%): selected thread with full message history + reply form + "✓ Закрыть вопрос" button
- Only admin/curator can close a question
- Closing a question does NOT prevent further messages
---
## Notifications
### → School (admin + all curators)
| Trigger | Channel |
|---|---|
| New question created | Email + admin sidebar badge (count of unread questions) |
| Student adds message to existing question | Email |
### → Student
| Trigger | Channel |
|---|---|
| Admin/curator replies to question | Email |
### Admin Badge
- Sidebar badge shows count of questions with unread messages (school hasn't seen yet)
- Separate from homework badge
### Email for Homework (added to scope)
| Trigger | Recipient |
|---|---|
| New HomeworkSubmission created | Admin + all curators |
| Student updates existing submission (adds text/file) | Admin + all curators |
---
## File Storage
- Path pattern: `questions/{questionId}/{messageId}/{filename}`
- Reuse existing S3 upload infrastructure (`src/lib/s3.ts`)
- Allowed: jpg, png, pdf, md
- Max size: 10 MB per file
- No limit on number of files per message (reasonable: 5)
---
## Read Tracking
- `isRead` flag per message, set to `true` when the OTHER party opens the thread
- Student opens `/questions/[id]` → all school messages in that thread marked `isRead = true`
- Admin/curator opens a question in split-view → all student messages marked `isRead = true`
- Admin badge recalculates on each page load (count questions where latest student message is unread)
---
## API Routes
```
POST /api/questions -- create question
GET /api/questions -- list own questions (student) or all (admin/curator)
GET /api/questions/[id] -- get question + messages
POST /api/questions/[id]/messages -- add message + files
PATCH /api/questions/[id]/close -- close question (admin/curator only)
POST /api/upload/question-file -- upload attachment, returns S3 path
```
---
## Out of Scope (this iteration)
- Question categories / tags
- Assigning question to a specific curator
- Email threading (reply-to email to answer)
- Push/browser notifications
- Question templates
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
import http from "k6/http";
import { check, sleep } from "k6";
const BASE = "https://school.second-brain.ru";
// Реальные lesson IDs курса obsidian (опубликованные уроки)
const LESSONS = [
"c729fjgtrl0tfowh49jh55uak",
"ctxca16mjamn5bh2exa3dxltg",
"c1f130hwjgks3zm4ohrcneueh",
"cn3bahic20cdxj9ih4cxr8tjl",
"c2usfe6rwoqcombd9veaalvgj",
"clil8czg79uqmqtexw8e5cede",
"c0ej1a3wrueg60d1oew2j8ky6",
"cypv15bq07deuyi2tjb556n52",
"c7v4qdnowy7i6y7pp361dwne3",
"c3l9ox9xvd5qyv5mt2pd7if2x",
];
const TEST_USER = {
email: "loadtest@second-brain.ru",
password: "LoadTest2025!",
};
const BROWSER_HEADERS = {
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "ru-RU,ru;q=0.9",
};
export const options = {
stages: [
{ duration: "30s", target: 10 }, // разгон до 10 пользователей
{ duration: "1m", target: 50 }, // разгон до 50
{ duration: "1m", target: 100 }, // разгон до 100
{ duration: "3m", target: 100 }, // держим 100 три минуты
{ duration: "30s", target: 0 }, // спад
],
thresholds: {
http_req_duration: ["p(95)<3000"], // 95% запросов быстрее 3 секунд
http_req_failed: ["rate<0.05"], // ошибок меньше 5%
},
};
// Переменная уровня VU — логин один раз на весь жизненный цикл VU.
let isLoggedIn = false;
export default function () {
// http.cookieJar() без аргументов — jar уровня VU, сохраняется между итерациями.
const jar = http.cookieJar();
// 1. Логин — только при первой итерации VU
if (!isLoggedIn) {
// Случайная задержка 0-10s: распределяем 100 логинов во времени,
// иначе все VU стартуют одновременно и бьют rate-limit Better Auth.
sleep(Math.random() * 10);
const loginRes = http.post(
`${BASE}/api/auth/sign-in/email`,
JSON.stringify({ email: TEST_USER.email, password: TEST_USER.password }),
{ headers: { "Content-Type": "application/json" }, jar }
);
check(loginRes, { "login 200": (r) => r.status === 200 });
if (loginRes.status !== 200) {
sleep(5); // пауза при неудаче, не штурмуем auth endpoint
return;
}
isLoggedIn = true;
sleep(1);
}
// 2. Дашборд студента
const dashRes = http.get(`${BASE}/dashboard`, { jar, headers: BROWSER_HEADERS });
check(dashRes, { "dashboard 200": (r) => r.status === 200 });
sleep(1);
// 3. Страница курса
const courseRes = http.get(`${BASE}/courses/obsidian`, { jar, headers: BROWSER_HEADERS });
check(courseRes, { "course page 200": (r) => r.status === 200 });
sleep(2);
// 4. Открыть 3 случайных урока (имитация чтения)
for (let i = 0; i < 3; i++) {
const lessonId = LESSONS[Math.floor(Math.random() * LESSONS.length)];
const lessonRes = http.get(
`${BASE}/courses/obsidian/lessons/${lessonId}`,
{ jar, headers: BROWSER_HEADERS }
);
check(lessonRes, { "lesson page 200": (r) => r.status === 200 });
sleep(Math.random() * 3 + 2); // студент "читает" 2-5 секунд
}
}
+1
View File
@@ -3,6 +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"],
}; };
export default nextConfig; export default nextConfig;
+96 -3040
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -22,7 +22,6 @@
"@kinescope/react-kinescope-player": "^0.5.4", "@kinescope/react-kinescope-player": "^0.5.4",
"@prisma/adapter-pg": "^7.6.0", "@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0", "@prisma/client": "^7.6.0",
"@tailwindcss/typography": "^0.5.19",
"@tiptap/extension-image": "^3.22.2", "@tiptap/extension-image": "^3.22.2",
"@tiptap/extension-link": "^3.22.2", "@tiptap/extension-link": "^3.22.2",
"@tiptap/extension-placeholder": "^3.22.2", "@tiptap/extension-placeholder": "^3.22.2",
@@ -44,10 +43,8 @@
"react-dom": "19.2.4", "react-dom": "19.2.4",
"remark-parse": "^11.0.0", "remark-parse": "^11.0.0",
"resend": "^6.10.0", "resend": "^6.10.0",
"shadcn": "^4.1.2",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
@@ -0,0 +1,2 @@
ALTER TABLE "HomeworkSubmission" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING';
ALTER TABLE "HomeworkSubmission" ADD COLUMN "statusAt" TIMESTAMP(3);
@@ -0,0 +1,2 @@
ALTER TABLE "HomeworkFeedback" ADD COLUMN "files" JSONB;
ALTER TABLE "HomeworkFeedback" ADD COLUMN "audioUrl" TEXT;
@@ -0,0 +1,2 @@
ALTER TABLE "Course" ADD COLUMN "allowAudio" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "HomeworkSubmission" ADD COLUMN "audioUrl" TEXT;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "phone" TEXT;
ALTER TABLE "User" ADD COLUMN "birthday" TIMESTAMP(3);
@@ -0,0 +1,4 @@
ALTER TABLE "LessonComment" ADD COLUMN "parentId" TEXT;
ALTER TABLE "LessonComment" ADD CONSTRAINT "LessonComment_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "LessonComment"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "comment" TEXT;
@@ -0,0 +1,11 @@
CREATE TABLE "BalanceTransaction" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"amount" DECIMAL(10,2) NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "BalanceTransaction_pkey" PRIMARY KEY ("id"),
CONSTRAINT "BalanceTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE INDEX "BalanceTransaction_userId_idx" ON "BalanceTransaction"("userId");
@@ -0,0 +1,45 @@
-- CreateEnum
CREATE TYPE "QuestionStatus" AS ENUM ('OPEN', 'CLOSED');
-- CreateTable
CREATE TABLE "StudentQuestion" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"courseId" TEXT,
"title" TEXT NOT NULL,
"status" "QuestionStatus" NOT NULL DEFAULT 'OPEN',
"closedAt" TIMESTAMP(3),
"closedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "StudentQuestion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "StudentQuestionMessage" (
"id" TEXT NOT NULL,
"questionId" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"text" TEXT NOT NULL,
"files" JSONB,
"isRead" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "StudentQuestionMessage_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestion" ADD CONSTRAINT "StudentQuestion_closedById_fkey" FOREIGN KEY ("closedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_questionId_fkey" FOREIGN KEY ("questionId") REFERENCES "StudentQuestion"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "StudentQuestionMessage" ADD CONSTRAINT "StudentQuestionMessage_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,8 @@
-- CreateIndex
CREATE INDEX "StudentQuestion_userId_idx" ON "StudentQuestion"("userId");
-- CreateIndex
CREATE INDEX "StudentQuestion_status_idx" ON "StudentQuestion"("status");
-- CreateIndex
CREATE INDEX "StudentQuestionMessage_questionId_idx" ON "StudentQuestionMessage"("questionId");
+102 -25
View File
@@ -18,32 +18,40 @@ model User {
emailVerified Boolean @default(false) emailVerified Boolean @default(false)
image String? image String?
role String @default("student") // student | curator | admin role String @default("student") // student | curator | admin
phone String?
birthday DateTime?
banned Boolean? @default(false) banned Boolean? @default(false)
banReason String? banReason String?
banExpires DateTime? banExpires DateTime?
comment String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
sessions Session[] sessions Session[]
accounts Account[] accounts Account[]
enrollments CourseEnrollment[] enrollments CourseEnrollment[]
progress LessonProgress[] progress LessonProgress[]
submissions HomeworkSubmission[] submissions HomeworkSubmission[]
comments LessonComment[] comments LessonComment[]
feedbacks HomeworkFeedback[] feedbacks HomeworkFeedback[]
accessLogs AccessLog[] @relation("AccessLogUser") accessLogs AccessLog[] @relation("AccessLogUser")
adminAccessLogs AccessLog[] @relation("AccessLogAdmin") adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
balanceTransactions BalanceTransaction[]
questions StudentQuestion[]
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
questionMessages StudentQuestionMessage[]
} }
model Session { model Session {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
token String @unique token String @unique
expiresAt DateTime expiresAt DateTime
ipAddress String? ipAddress String?
userAgent String? userAgent String?
createdAt DateTime @default(now()) impersonatedBy String?
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
@@ -96,6 +104,7 @@ model Course {
description String? description String?
coverImage String? coverImage String?
published Boolean @default(false) published Boolean @default(false)
allowAudio Boolean @default(false)
order Int @default(0) order Int @default(0)
categoryId String? categoryId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -105,6 +114,7 @@ model Course {
modules Module[] modules Module[]
enrollments CourseEnrollment[] enrollments CourseEnrollment[]
accessLogs AccessLog[] accessLogs AccessLog[]
questions StudentQuestion[]
} }
model Module { model Module {
@@ -126,6 +136,7 @@ model Lesson {
title String title String
content Json? content Json?
kinescopeId String? kinescopeId String?
coverImage String?
order Int @default(0) order Int @default(0)
published Boolean @default(false) published Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@ -258,12 +269,15 @@ model Homework {
} }
model HomeworkSubmission { model HomeworkSubmission {
id String @id @default(cuid()) id String @id @default(cuid())
homeworkId String homeworkId String
userId String userId String
text String? text String?
files Json? files Json?
submittedAt DateTime @default(now()) status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED
statusAt DateTime?
audioUrl String?
submittedAt DateTime @default(now())
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade) homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@ -275,6 +289,8 @@ model HomeworkFeedback {
submissionId String submissionId String
curatorId String curatorId String
text String text String
files Json? // [{name, url, size}]
audioUrl String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade) submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
@@ -292,9 +308,70 @@ model LessonComment {
text String text String
deleted Boolean @default(false) deleted Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
parentId String?
lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade) lesson Lesson @relation(fields: [lessonId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
parent LessonComment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: SetNull)
replies LessonComment[] @relation("CommentReplies")
}
// ─────────────────────────────────────────────
// Student Questions
// ─────────────────────────────────────────────
enum QuestionStatus {
OPEN
CLOSED
}
model StudentQuestion {
id String @id @default(cuid())
userId String
courseId String?
title String
status QuestionStatus @default(OPEN)
closedAt DateTime?
closedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
closedBy User? @relation("QuestionClosedBy", fields: [closedById], references: [id], onDelete: SetNull)
messages StudentQuestionMessage[]
@@index([userId])
@@index([status])
}
model StudentQuestionMessage {
id String @id @default(cuid())
questionId String
authorId String
text String
files Json? // [{name, url, size}]
isRead Boolean @default(false)
createdAt DateTime @default(now())
question StudentQuestion @relation(fields: [questionId], references: [id], onDelete: Cascade)
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
@@index([questionId])
}
// ─────────────────────────────────────────────
// Balance
// ─────────────────────────────────────────────
model BalanceTransaction {
id String @id @default(cuid())
userId String
amount Decimal @db.Decimal(10, 2)
description String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
} }
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
+129
View File
@@ -0,0 +1,129 @@
# Настройка бекапов на сервере
## Что бекапится
- **PostgreSQL** → дамп каждую ночь → Backblaze B2
- **S3-файлы** (Hetzner Object Storage) → синхронизация → Backblaze B2
- Хранение: последние 7 дневных дампов БД + все файлы (sync зеркало)
---
## Шаг 1 — Backblaze B2: создать bucket и ключи
1. Зарегистрироваться на https://www.backblaze.com/b2/
2. **Buckets → Create a Bucket**:
- Name: `lms-backups-second-brain`
- Files in Bucket are: `Private`
3. **App Keys → Add a New Application Key**:
- Name: `lms-server`
- Access: `Read and Write`
- Bucket: `lms-backups-second-brain`
- Сохранить `keyID` и `applicationKey` — показываются один раз
---
## Шаг 2 — Установить rclone на сервере
```bash
curl https://rclone.org/install.sh | sudo bash
```
---
## Шаг 3 — Настроить rclone: Backblaze B2
```bash
rclone config
```
Ответы:
```
n (новый remote)
name: b2lms
type: b2
account: <keyID из шага 1>
key: <applicationKey из шага 1>
<Enter для остальных — defaults>
q (quit)
```
---
## Шаг 4 — Настроить rclone: Hetzner S3
Значения берём из `.env` на сервере.
```bash
rclone config
```
Ответы:
```
n
name: hetzner
type: s3
provider: Other
env_auth: false
access_key_id: <S3_ACCESS_KEY>
secret_access_key: <S3_SECRET_KEY>
region: <пусто — Enter>
endpoint: <S3_ENDPOINT, например: fsn1.your-objectstorage.com>
<Enter для остальных>
q
```
Проверить:
```bash
rclone ls hetzner:lms-uploads
```
---
## Шаг 5 — Установить скрипт
```bash
sudo mkdir -p /opt/lms-backup
sudo cp scripts/backup.sh /opt/lms-backup/backup.sh
sudo chmod +x /opt/lms-backup/backup.sh
```
---
## Шаг 6 — Настроить cron
```bash
sudo crontab -e
```
Добавить строку (запуск каждую ночь в 3:00):
```
0 3 * * * /opt/lms-backup/backup.sh >> /var/log/lms-backup.log 2>&1
```
---
## Шаг 7 — Проверить вручную
```bash
sudo /opt/lms-backup/backup.sh
tail -50 /var/log/lms-backup.log
```
---
## Восстановление из бекапа
### База данных
```bash
# Скачать нужный дамп с B2
rclone copy b2lms:lms-backups-second-brain/db/db_20260408_0300.sql.gz /tmp/
# Восстановить в контейнер
gunzip -c /tmp/db_20260408_0300.sql.gz \
| docker exec -i lms-system-db-1 psql -U lms_user lms_db
```
### Файлы
```bash
# Синхронизировать файлы обратно на Hetzner S3
rclone sync b2lms:lms-backups-second-brain/files hetzner:lms-uploads
```
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# LMS Second Brain — Backup Script
# Backs up PostgreSQL (from Docker) + S3 files to Backblaze B2
# Place at: /opt/lms-backup/backup.sh on the server
# Cron: 0 3 * * * /opt/lms-backup/backup.sh >> /var/log/lms-backup.log 2>&1
set -euo pipefail
# ── Config ───────────────────────────────────────────────────────────────────
DB_CONTAINER="lms-system-db-1"
DB_USER="lms_user"
DB_NAME="lms_db"
BACKUP_DIR="/tmp/lms-backups"
DATE=$(date +%Y%m%d_%H%M)
DUMP_FILE="${BACKUP_DIR}/db_${DATE}.sql.gz"
# B2 rclone remote name (configured via: rclone config)
B2_REMOTE="b2lms"
B2_BUCKET="lms-backups-second-brain"
B2_DB_PATH="${B2_REMOTE}:${B2_BUCKET}/db"
B2_FILES_PATH="${B2_REMOTE}:${B2_BUCKET}/files"
# Hetzner S3 rclone remote name
S3_REMOTE="hetzner"
S3_BUCKET="lms-uploads"
# Retention: keep last N daily backups
KEEP_DAYS=7
# ── Functions ─────────────────────────────────────────────────────────────────
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
# ── Main ──────────────────────────────────────────────────────────────────────
log "=== LMS Backup started ==="
mkdir -p "$BACKUP_DIR"
# 1. PostgreSQL dump
log "Dumping PostgreSQL from container ${DB_CONTAINER}..."
docker exec "$DB_CONTAINER" \
pg_dump -U "$DB_USER" "$DB_NAME" \
| gzip > "$DUMP_FILE"
log "Dump created: ${DUMP_FILE} ($(du -sh "$DUMP_FILE" | cut -f1))"
# 2. Upload DB dump to B2
log "Uploading DB dump to Backblaze B2..."
rclone copy "$DUMP_FILE" "$B2_DB_PATH"
log "DB dump uploaded: ${B2_DB_PATH}/$(basename "$DUMP_FILE")"
# 3. Sync S3 files to B2
log "Syncing S3 files to Backblaze B2..."
rclone sync \
"${S3_REMOTE}:${S3_BUCKET}" \
"$B2_FILES_PATH" \
--progress \
--transfers=8
log "S3 files synced to ${B2_FILES_PATH}"
# 4. Cleanup local temp files
rm -f "$DUMP_FILE"
log "Local temp files cleaned"
# 5. Prune old DB backups on B2 (keep last KEEP_DAYS)
log "Pruning DB backups older than ${KEEP_DAYS} days..."
rclone delete "$B2_DB_PATH" \
--min-age "${KEEP_DAYS}d" \
--include "db_*.sql.gz"
log "Pruning done"
log "=== LMS Backup finished successfully ==="
@@ -0,0 +1,88 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
};
export function ForgotPasswordForm() {
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
await authClient.requestPasswordReset({
email,
redirectTo: "/reset-password",
});
setLoading(false);
setSent(true);
}
if (sent) {
return (
<div className="space-y-4 text-center">
<p className="text-sm" style={{ color: "var(--foreground)" }}>
Письмо со ссылкой для сброса пароля отправлено на{" "}
<strong>{email}</strong>.
</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Проверьте папку «Спам», если письмо не пришло в течение пары минут.
</p>
<Link href="/login" className="block text-xs underline mt-4" style={{ color: "var(--muted-foreground)" }}>
Вернуться к входу
</Link>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-1.5">
<p className="text-sm mb-4" style={{ color: "var(--muted-foreground)" }}>
Введите email мы пришлём ссылку для задания нового пароля.
</p>
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="you@example.com"
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="btn-aubade w-full justify-center"
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{loading ? "Отправка..." : "Сбросить пароль"}
</button>
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}>
<Link href="/login" className="underline" style={{ color: "var(--foreground)" }}>
Вернуться к входу
</Link>
</p>
</form>
);
}
+23
View File
@@ -0,0 +1,23 @@
import { getSetting } from "@/lib/settings";
import { ForgotPasswordForm } from "./forgot-password-form";
export default async function ForgotPasswordPage() {
const schoolName = await getSetting("schoolName");
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName}
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа
</p>
</div>
<div className="card-aubade p-8">
<ForgotPasswordForm />
</div>
</div>
</div>
);
}
+5 -3
View File
@@ -95,12 +95,14 @@ export function LoginForm() {
> >
{loading ? "Вход..." : "Войти"} {loading ? "Вход..." : "Войти"}
</button> </button>
<p className="text-center text-xs" style={{ color: "var(--muted-foreground)" }}> <div className="flex items-center justify-between text-xs" style={{ color: "var(--muted-foreground)" }}>
Нет аккаунта?{" "} <Link href="/forgot-password" className="underline" style={{ color: "var(--muted-foreground)" }}>
Забыли пароль?
</Link>
<Link href="/register" className="underline" style={{ color: "var(--foreground)" }}> <Link href="/register" className="underline" style={{ color: "var(--foreground)" }}>
Зарегистрироваться Зарегистрироваться
</Link> </Link>
</p> </div>
</form> </form>
); );
} }
+29 -3
View File
@@ -1,17 +1,43 @@
import { getSetting } from "@/lib/settings";
import { LoginForm } from "./login-form"; import { LoginForm } from "./login-form";
export default function LoginPage() { export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ notice?: string }>;
}) {
const [schoolName, { notice }] = await Promise.all([
getSetting("schoolName"),
searchParams,
]);
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">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
Second Brain {schoolName}
</h1> </h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}> <p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа Образовательная платформа
</p> </p>
</div> </div>
{notice === "password_reset" && (
<div
className="mb-4 px-4 py-3 text-sm text-center"
style={{ border: "2px solid var(--border)", color: "var(--foreground)" }}
>
Пароль успешно задан. Войдите с новым паролем.
</div>
)}
{notice === "registration_closed" && (
<div
className="mb-4 px-4 py-3 text-sm text-center"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Регистрация временно закрыта. Обратитесь к администратору.
</div>
)}
<div className="card-aubade p-8"> <div className="card-aubade p-8">
<LoginForm /> <LoginForm />
</div> </div>
+17 -4
View File
@@ -1,19 +1,32 @@
import { redirect } from "next/navigation";
import { getSettings } from "@/lib/settings";
import { RegisterForm } from "./register-form"; import { RegisterForm } from "./register-form";
export default function RegisterPage() { export default async function RegisterPage() {
const settings = await getSettings();
if (settings.registrationEnabled !== "true") {
redirect("/login?notice=registration_closed");
}
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">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
Second Brain {settings.schoolName}
</h1> </h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "0.65rem" }}> <p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа Образовательная платформа
</p> </p>
</div> </div>
<div className="card-aubade p-8"> <div className="card-aubade p-8">
<RegisterForm /> <RegisterForm
showTermsCheckbox={settings.showTermsCheckbox === "true"}
privacyPolicyUrl={settings.privacyPolicyUrl}
termsUrl={settings.termsUrl}
offerUrl={settings.offerUrl}
/>
</div> </div>
</div> </div>
</div> </div>
+52 -2
View File
@@ -4,10 +4,18 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { signUp } from "@/lib/auth-client"; import { signUp } from "@/lib/auth-client";
export function RegisterForm() { interface Props {
showTermsCheckbox: boolean;
privacyPolicyUrl: string;
termsUrl: string;
offerUrl: string;
}
export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, offerUrl }: 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("");
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
@@ -18,12 +26,22 @@ export function RegisterForm() {
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
} as React.CSSProperties; } as React.CSSProperties;
const legalLinks = [
{ url: privacyPolicyUrl, label: "Политику конфиденциальности" },
{ url: termsUrl, label: "Согласие на обработку данных" },
{ url: offerUrl, label: "Договор-оферту" },
].filter((l) => l.url);
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
if (showTermsCheckbox && !termsAccepted) {
setError("Необходимо принять условия для продолжения");
return;
}
setError(""); setError("");
setLoading(true); setLoading(true);
@@ -102,6 +120,38 @@ export function RegisterForm() {
placeholder="Минимум 8 символов" placeholder="Минимум 8 символов"
/> />
</div> </div>
{showTermsCheckbox && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-0.5 flex-shrink-0"
style={{ width: "16px", height: "16px", accentColor: "var(--foreground)" }}
/>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Я принимаю{" "}
{legalLinks.length > 0
? legalLinks.map((l, i) => (
<span key={l.url}>
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: "var(--foreground)" }}
>
{l.label}
</a>
{i < legalLinks.length - 1 ? ", " : ""}
</span>
))
: "условия использования платформы"}
</span>
</label>
)}
{error && ( {error && (
<p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}> <p className="text-xs py-2 px-3" style={{ border: "2px solid oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
{error} {error}
+26
View File
@@ -0,0 +1,26 @@
import { Suspense } from "react";
import { getSetting } from "@/lib/settings";
import { ResetPasswordForm } from "./reset-password-form";
export default async function ResetPasswordPage() {
const schoolName = await getSetting("schoolName");
return (
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: "var(--background)" }}>
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName}
</h1>
<p className="mt-1 text-sm uppercase tracking-widest" style={{ color: "var(--muted-foreground)", fontSize: "13px" }}>
Образовательная платформа
</p>
</div>
<div className="card-aubade p-8">
<Suspense fallback={null}>
<ResetPasswordForm />
</Suspense>
</div>
</div>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { authClient } from "@/lib/auth-client";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
};
export function ResetPasswordForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
if (!token) {
return (
<div className="text-center space-y-4">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Ссылка недействительна или устарела.
</p>
<Link href="/forgot-password" className="text-xs underline" style={{ color: "var(--foreground)" }}>
Запросить новую ссылку
</Link>
</div>
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
if (password !== confirm) {
setError("Пароли не совпадают");
return;
}
if (password.length < 8) {
setError("Пароль должен быть не короче 8 символов");
return;
}
setLoading(true);
const result = await authClient.resetPassword({ newPassword: password, token });
setLoading(false);
if (result.error) {
setError("Ссылка устарела или уже использована. Запросите новую.");
return;
}
router.push("/login?notice=password_reset");
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Задайте новый пароль для вашего аккаунта.
</p>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Новый пароль
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="Минимум 8 символов"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Повторите пароль
</label>
<input
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{error}</p>
)}
<button
type="submit"
disabled={loading}
className="btn-aubade w-full justify-center"
style={loading ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{loading ? "Сохранение..." : "Сохранить пароль"}
</button>
</form>
);
}
@@ -62,6 +62,7 @@ export default async function CourseLayout({ children, params }: Props) {
<div className="flex flex-1"> <div className="flex flex-1">
<CourseSidebar course={course} completedLessonIds={completedLessonIds} /> <CourseSidebar course={course} completedLessonIds={completedLessonIds} />
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
<div className="h-12 lg:hidden" />
{children} {children}
</main> </main>
</div> </div>
@@ -5,7 +5,7 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export async function addComment(lessonId: string, slug: string, text: string) { export async function addComment(lessonId: string, slug: string, text: string, parentId?: string) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized"); if (!session) throw new Error("Unauthorized");
@@ -20,7 +20,8 @@ export async function addComment(lessonId: string, slug: string, text: string) {
if (!lesson) throw new Error("Lesson not found"); if (!lesson) throw new Error("Lesson not found");
const isAdmin = session.user.role === "admin"; const isAdmin = session.user.role === "admin";
if (!isAdmin) { const isCurator = session.user.role === "curator";
if (!isAdmin && !isCurator) {
const enrollment = await prisma.courseEnrollment.findUnique({ const enrollment = await prisma.courseEnrollment.findUnique({
where: { where: {
userId_courseId: { userId_courseId: {
@@ -33,8 +34,14 @@ export async function addComment(lessonId: string, slug: string, text: string) {
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) throw new Error("Access expired"); 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({ await prisma.lessonComment.create({
data: { lessonId, userId: session.user.id, text: trimmed }, data: { lessonId, userId: session.user.id, text: trimmed, ...(parentId ? { parentId } : {}) },
}); });
revalidatePath(`/courses/${slug}/lessons/${lessonId}`); revalidatePath(`/courses/${slug}/lessons/${lessonId}`);
@@ -4,7 +4,7 @@ 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, sendHomeworkUpdatedEmail } from "@/lib/email";
interface HomeworkFile { interface HomeworkFile {
name: string; name: string;
@@ -17,7 +17,8 @@ export async function submitHomework(
slug: string, slug: string,
lessonId: string, lessonId: string,
text: string, text: string,
files: HomeworkFile[] files: HomeworkFile[],
audioUrl?: string | null
) { ) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new Error("Unauthorized"); if (!session) throw new Error("Unauthorized");
@@ -36,12 +37,33 @@ export async function submitHomework(
if (existing) { if (existing) {
const updated = await prisma.homeworkSubmission.update({ const updated = await prisma.homeworkSubmission.update({
where: { id: existing.id }, where: { id: existing.id },
data: { text, files: files as object[], submittedAt: new Date() }, data: { text, files: files as object[], audioUrl: audioUrl ?? null, submittedAt: new Date() },
}); });
submissionId = updated.id; submissionId = updated.id;
// Notify admins/curators when student edits an existing submission
const [lessonRecord, staff] = await Promise.all([
prisma.homework.findUnique({
where: { id: homeworkId },
include: { lesson: { select: { title: true } } },
}),
prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
}),
]);
if (lessonRecord) {
await Promise.all(
staff.map((s) =>
sendHomeworkUpdatedEmail(s.email, s.name, session.user.name, lessonRecord.lesson.title, submissionId).catch(
(e) => console.error("[email] homework-updated:", e)
)
)
);
}
} else { } else {
const created = await prisma.homeworkSubmission.create({ const created = await prisma.homeworkSubmission.create({
data: { homeworkId, userId: session.user.id, text, files: files as object[] }, data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
}); });
submissionId = created.id; submissionId = created.id;
@@ -7,7 +7,9 @@ import { KinescopePlayer } from "@/components/player/kinescope-player";
import { LessonContent } from "@/components/student/lesson-content"; import { LessonContent } from "@/components/student/lesson-content";
import { LessonCompleteButton } from "@/components/student/lesson-complete-button"; import { LessonCompleteButton } from "@/components/student/lesson-complete-button";
import { HomeworkSection } from "@/components/student/homework-section"; import { HomeworkSection } from "@/components/student/homework-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";
interface Props { interface Props {
params: Promise<{ slug: string; lessonId: string }>; params: Promise<{ slug: string; lessonId: string }>;
@@ -25,6 +27,9 @@ export default async function LessonPage({ params }: Props) {
include: { include: {
files: { orderBy: { createdAt: "asc" } }, files: { orderBy: { createdAt: "asc" } },
homework: true, homework: true,
quiz: {
include: { questions: { orderBy: { order: "asc" } } },
},
module: { module: {
include: { include: {
course: { course: {
@@ -51,9 +56,15 @@ export default async function LessonPage({ params }: Props) {
}) })
: null, : null,
prisma.lessonComment.findMany({ prisma.lessonComment.findMany({
where: { lessonId }, where: { lessonId, parentId: null },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
include: { user: { select: { id: true, name: true } } }, include: {
user: { select: { id: true, name: true } },
replies: {
orderBy: { createdAt: "asc" },
include: { user: { select: { id: true, name: true } } },
},
},
}), }),
]); ]);
@@ -61,7 +72,18 @@ export default async function LessonPage({ params }: Props) {
const homeworkSubmission = lesson?.homework && session && !isAdmin const homeworkSubmission = lesson?.homework && session && !isAdmin
? await prisma.homeworkSubmission.findFirst({ ? await prisma.homeworkSubmission.findFirst({
where: { homeworkId: lesson.homework.id, userId: session.user.id }, where: { homeworkId: lesson.homework.id, userId: session.user.id },
include: { feedbacks: { include: { curator: { select: { name: true } } }, orderBy: { createdAt: "desc" } } }, include: {
feedbacks: {
include: { curator: { select: { name: true } } },
orderBy: { createdAt: "asc" },
},
},
})
: null;
const quizAttempt = lesson?.quiz && session && !isAdmin
? await prisma.quizAttempt.findFirst({
where: { quizId: lesson.quiz.id, userId: session.user.id },
}) })
: null; : null;
@@ -91,7 +113,7 @@ export default async function LessonPage({ params }: Props) {
{/* Video */} {/* Video */}
{lesson.kinescopeId && ( {lesson.kinescopeId && (
<div className="mb-8"> <div className="mb-8">
<KinescopePlayer videoId={lesson.kinescopeId} /> <KinescopePlayer videoId={lesson.kinescopeId} poster={lesson.coverImage ?? undefined} />
</div> </div>
)} )}
@@ -115,12 +137,10 @@ export default async function LessonPage({ params }: Props) {
href={file.url} href={file.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors" 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)", display: "flex" }} style={{ border: "2px solid var(--border)" }}
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
> >
<span className="text-lg">📎</span> <FileFormatBadge url={file.url} />
<span className="flex-1 font-medium">{file.name}</span> <span className="flex-1 font-medium">{file.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}> <span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(file.size)} {formatSize(file.size)}
@@ -145,13 +165,48 @@ export default async function LessonPage({ params }: Props) {
submission={homeworkSubmission ? { submission={homeworkSubmission ? {
...homeworkSubmission, ...homeworkSubmission,
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [], files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
audioUrl: homeworkSubmission.audioUrl ?? null,
feedbacks: homeworkSubmission.feedbacks.map((fb) => ({
...fb,
files: (fb.files as { name: string; url: string; size: number }[]) ?? [],
audioUrl: fb.audioUrl ?? null,
})),
} : null} } : null}
slug={slug} slug={slug}
lessonId={lessonId} lessonId={lessonId}
allowAudio={lesson.module.course.allowAudio}
/> />
</div> </div>
)} )}
{/* Quiz */}
{lesson.quiz && (
<div className="mb-8">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Тест{isAdmin && <span className="ml-2 opacity-50">(предпросмотр)</span>}
</p>
{isAdmin ? (
<div className="space-y-4 opacity-70">
{lesson.quiz.questions.map((q, idx) => (
<div key={q.id} className="space-y-1">
<p className="text-sm font-medium">{idx + 1}. {q.text}</p>
<div className="px-4 py-3 text-sm" style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}>
Поле для ответа студента
</div>
</div>
))}
</div>
) : (
<QuizSection
quiz={lesson.quiz}
attempt={quizAttempt ? { answers: quizAttempt.answers as Record<string, string> } : null}
slug={slug}
lessonId={lessonId}
/>
)}
</div>
)}
{/* Complete button + Prev/Next navigation */} {/* Complete button + Prev/Next navigation */}
<div <div
className="flex items-center justify-between pt-6 mt-6" className="flex items-center justify-between pt-6 mt-6"
@@ -168,9 +223,12 @@ export default async function LessonPage({ params }: Props) {
<div /> <div />
)} )}
{!isAdmin && ( {!isAdmin && !lesson.homework && !lesson.quiz && (
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} /> <LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} />
)} )}
{!isAdmin && (lesson.homework || lesson.quiz) && isCompleted && (
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={true} readOnly={true} />
)}
{nextLesson ? ( {nextLesson ? (
<Link <Link
@@ -193,7 +251,10 @@ export default async function LessonPage({ params }: Props) {
style={{ borderTop: "2px solid var(--border)" }} style={{ borderTop: "2px solid var(--border)" }}
> >
<p className="text-xs font-bold uppercase tracking-widest mb-6" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs font-bold uppercase tracking-widest mb-6" style={{ color: "var(--muted-foreground)" }}>
Обсуждение ({comments.filter((c) => !c.deleted).length}) Обсуждение ({
comments.filter(c => !c.deleted).length +
comments.flatMap(c => c.replies).filter(r => !r.deleted).length
})
</p> </p>
<LessonComments <LessonComments
lessonId={lessonId} lessonId={lessonId}
+56 -3
View File
@@ -4,6 +4,7 @@ import { redirect } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button"; import { LogoutButton } from "@/components/layout/logout-button";
import { getSetting } from "@/lib/settings"; import { getSetting } from "@/lib/settings";
import { StopImpersonateBanner } from "@/components/admin/stop-impersonate-banner";
export default async function StudentLayout({ children }: { children: React.ReactNode }) { export default async function StudentLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -15,21 +16,73 @@ export default async function StudentLayout({ children }: { children: React.Reac
if (maintenance === "true") redirect("/maintenance"); if (maintenance === "true") redirect("/maintenance");
} }
const [schoolName, logoUrl, showLogo, socialYoutube, socialVk, socialTelegram, orgRequisites] =
await Promise.all([
getSetting("schoolName"),
getSetting("logoUrl"),
getSetting("showLogo"),
getSetting("socialYoutube"),
getSetting("socialVk"),
getSetting("socialTelegram"),
getSetting("orgRequisites"),
]);
const isImpersonating = !!(session.session as { impersonatedBy?: string }).impersonatedBy;
return ( return (
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}> <div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
{isImpersonating && <StopImpersonateBanner userName={session.user.name} />}
<header <header
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3" className="sticky top-0 z-10 flex items-center justify-between px-6 py-3"
style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }} style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }}
> >
<Link href="/dashboard" className="font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <Link href="/dashboard" className="flex items-center gap-2 font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
Second Brain {logoUrl && showLogo === "true" && (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoUrl} alt={schoolName} className="h-6 w-auto object-contain" />
)}
{schoolName}
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span className="text-sm" style={{ color: "var(--muted-foreground)" }}>{session.user.name}</span> <Link href="/questions" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
Вопросы
</Link>
<Link href="/profile" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
{session.user.name}
</Link>
<LogoutButton /> <LogoutButton />
</div> </div>
</header> </header>
<div className="flex-1 flex flex-col">{children}</div> <div className="flex-1 flex flex-col">{children}</div>
{(socialYoutube || socialVk || socialTelegram || orgRequisites) && (
<footer
className="px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs"
style={{ borderTop: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
{orgRequisites && (
<p className="whitespace-pre-line text-center sm:text-left">{orgRequisites}</p>
)}
{(socialYoutube || socialVk || socialTelegram) && (
<div className="flex items-center gap-4 flex-shrink-0">
{socialYoutube && (
<a href={socialYoutube} target="_blank" rel="noopener noreferrer" className="hover:underline">
YouTube
</a>
)}
{socialVk && (
<a href={socialVk} target="_blank" rel="noopener noreferrer" className="hover:underline">
VK
</a>
)}
{socialTelegram && (
<a href={socialTelegram} target="_blank" rel="noopener noreferrer" className="hover:underline">
Telegram
</a>
)}
</div>
)}
</footer>
)}
</div> </div>
); );
} }
+35
View File
@@ -0,0 +1,35 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function changePasswordAction(_prevState: unknown, formData: FormData) {
const current = (formData.get("currentPassword") as string) ?? "";
const next = (formData.get("newPassword") as string) ?? "";
const confirm = (formData.get("confirmPassword") as string) ?? "";
if (!current || !next || !confirm) return { error: "Заполните все поля" };
if (next !== confirm) return { error: "Пароли не совпадают" };
if (next.length < 8) return { error: "Новый пароль должен быть не короче 8 символов" };
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return { error: "Сессия истекла, войдите заново" };
const account = await prisma.account.findFirst({
where: { userId: session.user.id, providerId: "credential" },
});
if (!account?.password) return { error: "Аккаунт не найден" };
const valid = await bcrypt.compare(current, account.password);
if (!valid) return { error: "Неверный текущий пароль" };
const hash = await bcrypt.hash(next, 10);
await prisma.account.update({
where: { id: account.id },
data: { password: hash },
});
return { success: true };
}
@@ -0,0 +1,80 @@
"use client";
import { useActionState } from "react";
import { changePasswordAction } from "./actions";
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
color: "var(--foreground)",
fontFamily: "var(--font-sans)",
outline: "none",
};
export function ChangePasswordForm() {
const [state, formAction, isPending] = useActionState(changePasswordAction, null);
return (
<form action={formAction} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Текущий пароль
</label>
<input
type="password"
name="currentPassword"
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Новый пароль
</label>
<input
type="password"
name="newPassword"
required
minLength={8}
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="Минимум 8 символов"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Повторите новый пароль
</label>
<input
type="password"
name="confirmPassword"
required
className="w-full px-3 py-2 text-sm bg-transparent"
style={inputStyle}
onFocus={(e) => (e.target.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.target.style.borderColor = "var(--border)")}
placeholder="••••••••"
/>
</div>
{state?.error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>{state.error}</p>
)}
{state?.success && (
<p className="text-sm" style={{ color: "var(--foreground)" }}>Пароль успешно изменён.</p>
)}
<button
type="submit"
disabled={isPending}
className="btn-aubade justify-center"
style={isPending ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{isPending ? "Сохранение..." : "Сменить пароль"}
</button>
</form>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { ChangePasswordForm } from "./change-password-form";
export const metadata = { title: "Профиль" };
export default async function ProfilePage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
return (
<main className="max-w-lg mx-auto px-4 py-10 w-full">
<h1 className="text-xl font-bold tracking-wide mb-8" style={{ color: "var(--foreground)" }}>
Профиль
</h1>
<div className="card-aubade p-6 mb-6">
<h2 className="text-xs uppercase tracking-widest font-bold mb-4" style={{ color: "var(--muted-foreground)" }}>
Аккаунт
</h2>
<div className="space-y-3 text-sm" style={{ color: "var(--foreground)" }}>
<div className="flex justify-between">
<span style={{ color: "var(--muted-foreground)" }}>Имя</span>
<span>{session.user.name}</span>
</div>
<div className="flex justify-between">
<span style={{ color: "var(--muted-foreground)" }}>Email</span>
<span>{session.user.email}</span>
</div>
</div>
</div>
<div className="card-aubade p-6">
<h2 className="text-xs uppercase tracking-widest font-bold mb-4" style={{ color: "var(--muted-foreground)" }}>
Смена пароля
</h2>
<ChangePasswordForm />
</div>
</main>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect, notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
import { QuestionThread } from "@/components/questions/QuestionThread";
export default async function QuestionThreadPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const { id } = await params;
const question = await prisma.studentQuestion.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true } },
messages: {
include: { author: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!question || question.userId !== session.user.id) notFound();
// Mark staff messages as read
await prisma.studentQuestionMessage.updateMany({
where: {
questionId: id,
isRead: false,
NOT: { authorId: session.user.id },
},
data: { isRead: true },
});
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-start justify-between mb-6">
<div>
<h1 className="text-lg font-bold mb-1" style={{ color: "var(--foreground)" }}>
{question.title}
</h1>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Создан{" "}
{new Date(question.createdAt).toLocaleDateString("ru", {
day: "numeric",
month: "long",
})}{" "}
·{" "}
<span
style={{
color: question.status === "OPEN" ? "var(--foreground)" : "var(--muted-foreground)",
fontWeight: 700,
}}
>
{question.status === "OPEN" ? "● Открыт" : "✓ Закрыт"}
</span>
</p>
</div>
<Link
href="/questions"
className="text-xs"
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
>
Все вопросы
</Link>
</div>
<QuestionThread
questionId={id}
questionStatus={question.status as "OPEN" | "CLOSED"}
currentUserId={session.user.id}
initialMessages={question.messages.map((m) => ({
...m,
files: m.files as Array<{ name: string; url: string; size: number }> | null,
createdAt: m.createdAt.toISOString(),
}))}
/>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
export default function NewQuestionPage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !text.trim()) return;
setLoading(true);
setError("");
try {
const res = await fetch("/api/questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, text }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error ?? "Ошибка при создании вопроса");
}
const q = await res.json();
router.push(`/questions/${q.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setLoading(false);
}
}
return (
<div className="max-w-xl mx-auto px-4 py-8">
<div className="flex items-center gap-3 mb-6">
<Link
href="/questions"
className="text-sm"
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
>
Все вопросы
</Link>
</div>
<h1 className="text-xl font-bold mb-6" style={{ color: "var(--foreground)" }}>
Новый вопрос
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label
className="block text-sm font-bold mb-1"
style={{ color: "var(--foreground)" }}
>
Тема вопроса
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Кратко опишите суть вопроса"
required
className="w-full text-sm px-3 py-2 outline-none"
style={{
border: "2px solid var(--border)",
background: "var(--color-surface)",
color: "var(--foreground)",
}}
/>
</div>
<div>
<label
className="block text-sm font-bold mb-1"
style={{ color: "var(--foreground)" }}
>
Описание
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Подробно опишите вопрос или проблему"
required
rows={6}
className="w-full text-sm px-3 py-2 outline-none resize-none"
style={{
border: "2px solid var(--border)",
background: "var(--color-surface)",
color: "var(--foreground)",
}}
/>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>
{error}
</p>
)}
<button
type="submit"
disabled={loading || !title.trim() || !text.trim()}
className="self-end text-sm font-bold px-6 py-2"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: loading ? 0.6 : 1,
}}
>
{loading ? "Отправка..." : "Отправить →"}
</button>
</form>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export default async function QuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const questions = await prisma.studentQuestion.findMany({
where: { userId: session.user.id },
include: {
_count: {
select: {
messages: {
where: { isRead: false, NOT: { authorId: session.user.id } },
},
},
},
messages: {
orderBy: { createdAt: "desc" },
take: 1,
include: { author: { select: { name: true } } },
},
},
orderBy: { updatedAt: "desc" },
});
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold" style={{ color: "var(--foreground)" }}>
Мои вопросы
</h1>
<Link
href="/questions/new"
className="text-sm font-bold px-4 py-2"
style={{
background: "var(--color-surface)",
border: "2px solid var(--foreground)",
color: "var(--foreground)",
}}
>
+ Задать вопрос
</Link>
</div>
{questions.length === 0 && (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
У вас ещё нет вопросов.
</p>
)}
<div className="flex flex-col gap-2">
{questions.map((q) => {
const unread = q._count.messages > 0;
const lastMsg = q.messages[0];
return (
<Link
key={q.id}
href={`/questions/${q.id}`}
className="block p-3 rounded-sm transition-colors"
style={{
border: unread ? "2px solid var(--foreground)" : "1px solid var(--border)",
background: q.status === "CLOSED" ? "var(--background)" : "var(--color-surface)",
opacity: q.status === "CLOSED" ? 0.7 : 1,
}}
>
<div className="flex items-start justify-between gap-2 mb-1">
<span
className="text-sm"
style={{
fontWeight: unread ? 700 : 400,
color: q.status === "CLOSED" ? "var(--muted-foreground)" : "var(--foreground)",
}}
>
{q.title}
</span>
<span
className="text-xs shrink-0 px-1.5 py-0.5 rounded-sm"
style={
q.status === "OPEN"
? { background: "#E8F0D8", border: "1px solid var(--border)", color: "var(--foreground)" }
: { background: "var(--background)", color: "var(--muted-foreground)" }
}
>
{q.status === "OPEN" ? "● ОТКРЫТ" : "✓ ЗАКРЫТ"}
</span>
</div>
{unread && (
<div className="flex items-center gap-1.5 mb-1">
<span
className="inline-block w-2 h-2 rounded-full"
style={{ background: "var(--foreground)" }}
/>
<span className="text-xs font-bold" style={{ color: "var(--foreground)" }}>
Новый ответ от школы
</span>
</div>
)}
{lastMsg && !unread && (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
последнее от {lastMsg.author.name}
</p>
)}
</Link>
);
})}
</div>
</div>
);
}
@@ -4,6 +4,7 @@ import Link from "next/link";
import { LessonEditor } from "@/components/admin/lesson-editor"; import { LessonEditor } from "@/components/admin/lesson-editor";
import { LessonFilesManager } from "@/components/admin/lesson-files-manager"; import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
import { HomeworkEditor } from "@/components/admin/homework-editor"; import { HomeworkEditor } from "@/components/admin/homework-editor";
import { QuizEditor } from "@/components/admin/quiz-editor";
interface Props { interface Props {
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>; params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
@@ -18,6 +19,9 @@ export default async function LessonEditorPage({ params }: Props) {
include: { include: {
files: { orderBy: { createdAt: "asc" } }, files: { orderBy: { createdAt: "asc" } },
homework: true, homework: true,
quiz: {
include: { questions: { orderBy: { order: "asc" } } },
},
module: { module: {
include: { course: { select: { title: true, slug: true } } }, include: { course: { select: { title: true, slug: true } } },
}, },
@@ -36,6 +40,19 @@ export default async function LessonEditorPage({ params }: Props) {
const prevLesson = idx > 0 ? siblings[idx - 1] : null; const prevLesson = idx > 0 ? siblings[idx - 1] : null;
const nextLesson = idx < siblings.length - 1 ? siblings[idx + 1] : null; const nextLesson = idx < siblings.length - 1 ? siblings[idx + 1] : null;
// Serialize all Prisma proxy objects (DateTime, relations) before passing to Client Components
const plain = JSON.parse(JSON.stringify({
files: lesson.files,
homework: lesson.homework,
quiz: lesson.quiz,
siblings,
})) as {
files: typeof lesson.files;
homework: typeof lesson.homework;
quiz: typeof lesson.quiz;
siblings: typeof siblings;
};
return ( return (
<div className="p-8 max-w-4xl"> <div className="p-8 max-w-4xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> <nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
@@ -55,14 +72,14 @@ export default async function LessonEditorPage({ params }: Props) {
id: lesson.id, id: lesson.id,
title: lesson.title, title: lesson.title,
kinescopeId: lesson.kinescopeId ?? "", kinescopeId: lesson.kinescopeId ?? "",
content: (lesson.content as object) ?? {}, content: JSON.parse(JSON.stringify(lesson.content ?? {})),
published: lesson.published, published: lesson.published,
}} }}
courseId={courseId} courseId={courseId}
moduleId={moduleId} moduleId={moduleId}
courseSlug={lesson.module.course.slug} courseSlug={lesson.module.course.slug}
prevLesson={prevLesson} prevLesson={plain.siblings[idx - 1] ?? null}
nextLesson={nextLesson} nextLesson={plain.siblings[idx + 1] ?? null}
/> />
</div> </div>
@@ -71,15 +88,23 @@ export default async function LessonEditorPage({ params }: Props) {
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Файлы и материалы Файлы и материалы
</p> </p>
<LessonFilesManager lessonId={lessonId} initialFiles={lesson.files} /> <LessonFilesManager lessonId={lessonId} initialFiles={plain.files} />
</div> </div>
{/* Homework section */} {/* Homework section */}
<div className="card-aubade p-6"> <div className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Домашнее задание Домашнее задание
</p> </p>
<HomeworkEditor lessonId={lessonId} initial={lesson.homework} /> <HomeworkEditor lessonId={lessonId} initial={plain.homework} />
</div>
{/* Quiz section */}
<div className="card-aubade p-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Тест
</p>
<QuizEditor lessonId={lessonId} initial={plain.quiz} />
</div> </div>
</div> </div>
); );
@@ -15,7 +15,10 @@ export default async function ModulePage({ params }: Props) {
where: { id: moduleId }, where: { id: moduleId },
include: { include: {
course: { select: { title: true } }, course: { select: { title: true } },
lessons: { orderBy: { order: "asc" } }, lessons: {
orderBy: { order: "asc" },
select: { id: true, title: true, order: true, published: true, kinescopeId: true },
},
}, },
}), }),
prisma.module.findMany({ prisma.module.findMany({
@@ -27,6 +30,11 @@ export default async function ModulePage({ params }: Props) {
if (!module || module.courseId !== courseId) notFound(); if (!module || module.courseId !== courseId) notFound();
const plain = JSON.parse(JSON.stringify({ lessons: module.lessons, allModules })) as {
lessons: typeof module.lessons;
allModules: typeof allModules;
};
return ( return (
<div className="p-8 max-w-3xl"> <div className="p-8 max-w-3xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> <nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
@@ -51,8 +59,8 @@ export default async function ModulePage({ params }: Props) {
<SortableLessons <SortableLessons
courseId={courseId} courseId={courseId}
moduleId={moduleId} moduleId={moduleId}
lessons={module.lessons} lessons={plain.lessons}
otherModules={allModules} otherModules={plain.allModules}
/> />
</section> </section>
</div> </div>
+17 -9
View File
@@ -50,13 +50,21 @@ export default async function CourseDetailPage({ params }: Props) {
if (!course) notFound(); if (!course) notFound();
// Prisma 7 returns proxy objects for relations/aggregates that RSC cannot serialize.
// Convert to plain JS before passing to Client Components.
const plain = JSON.parse(JSON.stringify({ course, allStudents, categories })) as {
course: typeof course;
allStudents: typeof allStudents;
categories: typeof categories;
};
return ( return (
<div className="p-8 max-w-4xl"> <div className="p-8 max-w-4xl">
{/* Breadcrumb */} {/* Breadcrumb */}
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> <nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/admin/courses" className="hover:underline">Курсы</Link> <Link href="/admin/courses" className="hover:underline">Курсы</Link>
<span className="mx-2">/</span> <span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{course.title}</span> <span style={{ color: "var(--foreground)" }}>{plain.course.title}</span>
</nav> </nav>
{/* Course metadata */} {/* Course metadata */}
@@ -64,7 +72,7 @@ export default async function CourseDetailPage({ params }: Props) {
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Основная информация Основная информация
</p> </p>
<CourseEditForm course={course} categories={categories} /> <CourseEditForm course={plain.course} categories={plain.categories} />
</section> </section>
{/* Modules */} {/* Modules */}
@@ -74,19 +82,19 @@ export default async function CourseDetailPage({ params }: Props) {
Модули Модули
</p> </p>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}> <span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{course.modules.length} модулей {plain.course.modules.length} модулей
</span> </span>
</div> </div>
<SortableModules courseId={courseId} modules={course.modules} /> <SortableModules courseId={courseId} modules={plain.course.modules} />
</section> </section>
{/* Course tree overview */} {/* Course tree overview */}
{course.modules.length > 0 && ( {plain.course.modules.length > 0 && (
<section className="card-aubade p-6 mb-6"> <section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Структура курса Структура курса
</p> </p>
<CourseTree courseId={courseId} modules={course.modules} /> <CourseTree courseId={courseId} modules={plain.course.modules} />
</section> </section>
)} )}
@@ -97,9 +105,9 @@ export default async function CourseDetailPage({ params }: Props) {
</p> </p>
<EnrollmentManager <EnrollmentManager
courseId={courseId} courseId={courseId}
allStudents={allStudents} allStudents={plain.allStudents}
enrollments={course.enrollments} enrollments={plain.course.enrollments}
accessLogs={course.accessLogs} accessLogs={plain.course.accessLogs}
/> />
</section> </section>
</div> </div>
+2 -1
View File
@@ -43,12 +43,13 @@ export async function updateCourse(courseId: string, formData: FormData) {
const slug = formData.get("slug") as string; const slug = formData.get("slug") as string;
const description = (formData.get("description") as string) || null; const description = (formData.get("description") as string) || null;
const published = formData.get("published") === "true"; const published = formData.get("published") === "true";
const allowAudio = formData.get("allowAudio") === "true";
const coverImage = (formData.get("coverImage") as string) || null; const coverImage = (formData.get("coverImage") as string) || null;
const categoryId = (formData.get("categoryId") as string) || null; const categoryId = (formData.get("categoryId") as string) || null;
await prisma.course.update({ await prisma.course.update({
where: { id: courseId }, where: { id: courseId },
data: { title, slug, description, published, coverImage, categoryId }, data: { title, slug, description, published, allowAudio, coverImage, categoryId },
}); });
revalidatePath("/admin/courses"); revalidatePath("/admin/courses");
+30 -1
View File
@@ -3,6 +3,7 @@ import Link from "next/link";
export default async function AdminDashboard() { export default async function AdminDashboard() {
const now = new Date(); const now = new Date();
const dayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
@@ -16,6 +17,8 @@ export default async function AdminDashboard() {
homeworkPending, homeworkPending,
homeworkTotal, homeworkTotal,
progressTotal, progressTotal,
balanceAggregate,
activeLast24h,
] = await Promise.all([ ] = await Promise.all([
prisma.user.count({ where: { role: "student" } }), prisma.user.count({ where: { role: "student" } }),
prisma.user.count({ where: { role: "student", createdAt: { gte: monthAgo } } }), prisma.user.count({ where: { role: "student", createdAt: { gte: monthAgo } } }),
@@ -30,8 +33,16 @@ export default async function AdminDashboard() {
prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }), prisma.homeworkSubmission.count({ where: { feedbacks: { none: {} } } }),
prisma.homeworkSubmission.count(), prisma.homeworkSubmission.count(),
prisma.lessonProgress.count(), prisma.lessonProgress.count(),
prisma.balanceTransaction.aggregate({ _sum: { amount: true } }),
prisma.session.findMany({
where: { createdAt: { gte: dayAgo } },
select: { userId: true },
distinct: ["userId"],
}).then((rows) => rows.length),
]); ]);
const totalBalance = Number(balanceAggregate._sum.amount ?? 0);
// Recent enrollments // Recent enrollments
const recentEnrollments = await prisma.courseEnrollment.findMany({ const recentEnrollments = await prisma.courseEnrollment.findMany({
orderBy: { enrolledAt: "desc" }, orderBy: { enrolledAt: "desc" },
@@ -165,6 +176,24 @@ export default async function AdminDashboard() {
</div> </div>
</div> </div>
</div> </div>
<Link href="/admin/users?balance=nonzero" className="card-aubade p-5 block">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
На балансах
</p>
<p className="text-3xl font-bold">
{totalBalance.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>сумма по всем пользователям</p>
</Link>
<div className="card-aubade p-5">
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
Авторизации за 24 часа
</p>
<p className="text-3xl font-bold">{activeLast24h}</p>
<p className="text-xs mt-0.5" style={{ color: "var(--muted-foreground)" }}>уникальных пользователей</p>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -179,7 +208,7 @@ function StatCard({
href, href,
}: { }: {
label: string; label: string;
value: number; value: number | string;
sub?: string; sub?: string;
subAccent?: boolean; subAccent?: boolean;
href?: string; href?: string;
+13 -1
View File
@@ -2,11 +2,23 @@ import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { AdminShell } from "@/components/admin/admin-shell"; import { AdminShell } from "@/components/admin/admin-shell";
import { prisma } from "@/lib/prisma";
export default async function AdminLayout({ children }: { children: React.ReactNode }) { export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login"); if (!session) redirect("/login");
if (session.user.role !== "admin") redirect("/dashboard"); if (session.user.role !== "admin") redirect("/dashboard");
return <AdminShell userName={session.user.name}>{children}</AdminShell>; const questionsBadge = await prisma.studentQuestion.count({
where: {
messages: {
some: {
isRead: false,
author: { role: "student" },
},
},
},
});
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
} }
+11
View File
@@ -0,0 +1,11 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
export default async function AdminQuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") redirect("/login");
return <QuestionSplitView currentUserId={session.user.id} />;
}
+116
View File
@@ -0,0 +1,116 @@
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
interface Props {
params: Promise<{ quizId: string }>;
}
export default async function AdminQuizAttemptsPage({ params }: Props) {
const { quizId } = await params;
const quiz = await prisma.quiz.findUnique({
where: { id: quizId },
include: {
questions: { orderBy: { order: "asc" } },
attempts: { orderBy: { completedAt: "desc" } },
lesson: {
select: {
title: true,
module: {
select: {
course: { select: { title: true } },
},
},
},
},
},
});
if (!quiz) notFound();
const userIds = [...new Set(quiz.attempts.map((a) => a.userId))];
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
});
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
return (
<div className="p-8 max-w-4xl">
<nav
className="text-xs mb-6 uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
<Link href="/admin/quizzes" className="hover:underline">
Тесты
</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{quiz.lesson.title}</span>
</nav>
<div className="mb-6">
<h1 className="text-lg font-bold">{quiz.lesson.title}</h1>
<p className="text-xs mt-1 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
{quiz.lesson.module.course.title} · {quiz.questions.length} вопросов · {quiz.attempts.length} ответов
</p>
</div>
{quiz.attempts.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Ответов пока нет
</p>
) : (
<div className="space-y-4">
{quiz.attempts.map((attempt) => {
const answers = attempt.answers as Record<string, string>;
const user = userMap[attempt.userId];
const date = new Date(attempt.completedAt).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<div
key={attempt.id}
className="px-4 py-4 space-y-3"
style={{ border: "2px solid var(--border)" }}
>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-medium">{user?.name ?? "—"}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{user?.email ?? attempt.userId}
</p>
</div>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{date}
</span>
</div>
<div className="space-y-2 pt-2" style={{ borderTop: "1px solid var(--border)" }}>
{quiz.questions.map((q, idx) => (
<div key={q.id}>
<p
className="text-xs font-medium mb-0.5"
style={{ color: "var(--muted-foreground)" }}
>
{idx + 1}. {q.text}
</p>
<p className="text-sm whitespace-pre-wrap">
{answers[q.id]?.trim() || <span style={{ color: "var(--muted-foreground)" }}></span>}
</p>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
+78
View File
@@ -0,0 +1,78 @@
import { prisma } from "@/lib/prisma";
import Link from "next/link";
export const metadata = { title: "Тесты" };
export default async function AdminQuizzesPage() {
const quizzes = await prisma.quiz.findMany({
orderBy: { createdAt: "desc" },
include: {
_count: { select: { questions: true, attempts: true } },
lesson: {
select: {
title: true,
module: {
select: {
course: { select: { title: true, slug: true } },
},
},
},
},
},
});
return (
<div className="p-8">
<div className="mb-6">
<h1
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Тесты
</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{quizzes.length} тестов
</p>
</div>
{quizzes.length === 0 ? (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Тестов нет
</p>
) : (
<div className="space-y-0" style={{ border: "2px solid var(--border)" }}>
{quizzes.map((quiz) => (
<Link
key={quiz.id}
href={`/admin/quizzes/${quiz.id}`}
className="flex items-center gap-4 px-4 py-3 hover:[background:var(--muted)] transition-colors"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{quiz.lesson.title}
</p>
<p
className="text-xs mt-0.5 truncate"
style={{ color: "var(--muted-foreground)" }}
>
{quiz.lesson.module.course.title}
</p>
</div>
<div className="flex items-center gap-4 shrink-0 text-xs" style={{ color: "var(--muted-foreground)" }}>
<span>{quiz._count.questions} вопр.</span>
<span
className="font-bold"
style={{ color: quiz._count.attempts > 0 ? "var(--foreground)" : undefined }}
>
{quiz._count.attempts} ответов
</span>
<span style={{ color: "var(--muted-foreground)" }}></span>
</div>
</Link>
))}
</div>
)}
</div>
);
}
+58
View File
@@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import bcrypt from "bcryptjs";
async function requireAdmin() { async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -41,6 +42,63 @@ export async function bulkGrantAccess(
revalidatePath(`/admin/users/${userId}`); revalidatePath(`/admin/users/${userId}`);
} }
export async function updateUserContact(
userId: string,
data: { name: string; email: string; phone: string; birthday: string; comment: string }
) {
await requireAdmin();
await prisma.user.update({
where: { id: userId },
data: {
name: data.name.trim() || undefined,
email: data.email.trim() || undefined,
phone: data.phone.trim() || null,
birthday: data.birthday ? new Date(data.birthday) : null,
comment: data.comment.trim() || null,
},
});
revalidatePath(`/admin/users/${userId}`);
}
export async function addBalanceTransaction(
userId: string,
data: { amount: string; description: string }
) {
await requireAdmin();
const amount = parseFloat(data.amount.replace(",", "."));
if (isNaN(amount) || amount === 0) throw new Error("Некорректная сумма");
await prisma.balanceTransaction.create({
data: { userId, amount, description: data.description.trim() },
});
revalidatePath(`/admin/users/${userId}`);
}
export async function deleteBalanceTransaction(userId: string, txId: string) {
await requireAdmin();
await prisma.balanceTransaction.delete({ where: { id: txId } });
revalidatePath(`/admin/users/${userId}`);
}
export async function resetUserPassword(userId: string): Promise<{ tempPassword: string }> {
await requireAdmin();
const account = await prisma.account.findFirst({
where: { userId, providerId: "credential" },
});
if (!account) throw new Error("Аккаунт с паролем не найден");
const chars = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
const tempPassword = Array.from({ length: 10 }, () => chars[Math.floor(Math.random() * chars.length)]).join("");
const hash = await bcrypt.hash(tempPassword, 10);
await prisma.account.update({
where: { id: account.id },
data: { password: hash },
});
return { tempPassword };
}
export async function revokeUserAccess(userId: string, courseId: string) { export async function revokeUserAccess(userId: string, courseId: string) {
const session = await requireAdmin(); const session = await requireAdmin();
await prisma.courseEnrollment.delete({ await prisma.courseEnrollment.delete({
+41 -1
View File
@@ -2,6 +2,9 @@ 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 { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager"; import { UserEnrollmentManager } from "@/components/admin/user-enrollment-manager";
import { UserContactEditor } from "@/components/admin/user-contact-editor";
import { UserBalanceBlock } from "@/components/admin/user-balance-block";
import { ResetPasswordButton } from "@/components/admin/reset-password-button";
interface Props { interface Props {
params: Promise<{ userId: string }>; params: Promise<{ userId: string }>;
@@ -26,6 +29,9 @@ export default async function UserPage({ params }: Props) {
grantedBy: { select: { name: true } }, grantedBy: { select: { name: true } },
}, },
}, },
balanceTransactions: {
orderBy: { createdAt: "desc" },
},
}, },
}), }),
prisma.course.findMany({ prisma.course.findMany({
@@ -47,7 +53,7 @@ export default async function UserPage({ params }: Props) {
</nav> </nav>
{/* User info */} {/* User info */}
<section className="card-aubade p-6 mb-6"> <section className="card-aubade p-6 mb-6 space-y-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-xl font-bold">{user.name}</h1> <h1 className="text-xl font-bold">{user.name}</h1>
@@ -60,6 +66,40 @@ export default async function UserPage({ params }: Props) {
</span> </span>
</div> </div>
</div> </div>
<div style={{ borderTop: "2px solid var(--border)", paddingTop: "1rem" }}>
<UserContactEditor
userId={userId}
name={user.name ?? ""}
email={user.email}
phone={user.phone ?? null}
birthday={user.birthday ?? null}
comment={user.comment ?? null}
/>
</div>
</section>
{/* Reset password */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
Пароль
</p>
<ResetPasswordButton userId={userId} />
</section>
{/* Balance */}
<section className="card-aubade p-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
Баланс
</p>
<UserBalanceBlock
userId={userId}
transactions={user.balanceTransactions.map((tx) => ({
id: tx.id,
amount: Number(tx.amount),
description: tx.description,
createdAt: tx.createdAt,
}))}
/>
</section> </section>
{/* Enrollments + bulk grant */} {/* Enrollments + bulk grant */}
+16 -3
View File
@@ -8,14 +8,25 @@ import { UsersSearch } from "@/components/admin/users-search";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
interface Props { interface Props {
searchParams: Promise<{ search?: string; role?: string; page?: string }>; searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string }>;
} }
export default async function UsersPage({ searchParams }: Props) { export default async function UsersPage({ searchParams }: Props) {
const { search = "", role = "", page = "1" } = await searchParams; const { search = "", role = "", page = "1", balance = "" } = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1); const currentPage = Math.max(1, parseInt(page) || 1);
const skip = (currentPage - 1) * PAGE_SIZE; const skip = (currentPage - 1) * PAGE_SIZE;
// Collect userIds with non-zero balance if filter is active
let balanceUserIds: string[] | null = null;
if (balance === "nonzero") {
const groups = await prisma.balanceTransaction.groupBy({
by: ["userId"],
_sum: { amount: true },
having: { amount: { _sum: { not: { equals: 0 } } } },
});
balanceUserIds = groups.map((g) => g.userId);
}
const where = { const where = {
...(search ...(search
? { ? {
@@ -26,6 +37,7 @@ export default async function UsersPage({ searchParams }: Props) {
} }
: {}), : {}),
...(role ? { role } : {}), ...(role ? { role } : {}),
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
}; };
const [users, total] = await Promise.all([ const [users, total] = await Promise.all([
@@ -66,6 +78,7 @@ export default async function UsersPage({ searchParams }: Props) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (search) params.set("search", search);
if (role) params.set("role", role); if (role) params.set("role", role);
if (balance) params.set("balance", balance);
params.set("page", String(p)); params.set("page", String(p));
return `/admin/users?${params.toString()}`; return `/admin/users?${params.toString()}`;
} }
@@ -88,7 +101,7 @@ export default async function UsersPage({ searchParams }: Props) {
{/* Filters */} {/* Filters */}
<Suspense> <Suspense>
<UsersSearch initialSearch={search} initialRole={role} /> <UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} />
</Suspense> </Suspense>
<UsersTable users={tableUsers} /> <UsersTable users={tableUsers} />
+49 -10
View File
@@ -5,37 +5,76 @@ import { prisma } from "@/lib/prisma";
import { uploadFile, deleteFile } from "@/lib/s3"; import { uploadFile, deleteFile } from "@/lib/s3";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
export async function POST(req: NextRequest) { async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") { if (!session || session.user.role !== "admin") return null;
return session;
}
export async function POST(req: NextRequest) {
if (!await requireAdmin()) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const form = await req.formData(); const form = await req.formData();
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
const lessonId = form.get("lessonId") as string | null; const lessonId = form.get("lessonId") as string | null;
const label = (form.get("label") as string | null)?.trim() || null;
if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 }); if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const name = label ?? file.name;
const existing = await prisma.lessonFile.findFirst({ where: { lessonId, name } });
const ext = file.name.split(".").pop() ?? "bin"; const ext = file.name.split(".").pop() ?? "bin";
const key = `lessons/${lessonId}/${randomUUID()}.${ext}`; const key = `lessons/${lessonId}/${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, file.type);
const lessonFile = await prisma.lessonFile.create({ if (existing) {
data: { lessonId, name: file.name, url, size: file.size }, const oldKey = existing.url.split(`/${process.env.S3_BUCKET}/`)[1];
}); if (oldKey) await deleteFile(oldKey).catch(() => {});
const lessonFile = await prisma.lessonFile.update({
where: { id: existing.id },
data: { url, size: file.size },
});
return NextResponse.json(lessonFile);
}
const lessonFile = await prisma.lessonFile.create({
data: { lessonId, name, url, size: file.size },
});
return NextResponse.json(lessonFile); return NextResponse.json(lessonFile);
} }
export async function DELETE(req: NextRequest) { export async function PATCH(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() }); if (!await requireAdmin()) {
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 }); return NextResponse.json({ error: "Forbidden" }, { status: 403 });
} }
const { fileId, key } = await req.json(); const { fileId, label } = await req.json();
if (key) await deleteFile(key).catch(() => {}); if (!fileId || typeof label !== "string") {
return NextResponse.json({ error: "Missing fields" }, { status: 400 });
}
const updated = await prisma.lessonFile.update({
where: { id: fileId },
data: { name: label.trim() || undefined },
});
return NextResponse.json(updated);
}
export async function DELETE(req: NextRequest) {
if (!await requireAdmin()) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { fileId, url } = await req.json();
if (url) {
const key = (url as string).split(`/${process.env.S3_BUCKET}/`)[1];
if (key) await deleteFile(key).catch(() => {});
}
await prisma.lessonFile.delete({ where: { id: fileId } }); await prisma.lessonFile.delete({ where: { id: fileId } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ lessonId: string }> }
) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { lessonId } = await params;
const body = await req.json() as {
title: string;
kinescopeId: string;
content: object;
published: boolean;
};
await prisma.lesson.update({
where: { id: lessonId },
data: {
title: body.title,
kinescopeId: body.kinescopeId || null,
content: body.content,
published: body.published,
},
});
return NextResponse.json({ ok: true });
}
+3
View File
@@ -14,6 +14,9 @@ export async function POST(req: NextRequest) {
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "No file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "No file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin"; const ext = file.name.split(".").pop() ?? "bin";
const key = `uploads/${randomUUID()}.${ext}`; const key = `uploads/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
const key = `feedback-audio/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type || "audio/webm");
return NextResponse.json({ url });
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `feedback/${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 });
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
export async function PATCH(
_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 });
if (session.user.role !== "admin" && session.user.role !== "curator") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const { id } = await params;
const question = await prisma.studentQuestion.findUnique({ where: { id } });
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (question.status === "CLOSED") {
return NextResponse.json({ error: "Already closed" }, { status: 400 });
}
const updated = await prisma.studentQuestion.update({
where: { id },
data: { status: "CLOSED", closedAt: new Date(), closedById: session.user.id },
});
return NextResponse.json(updated);
}
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendQuestionFollowUpEmail, sendQuestionReplyEmail } from "@/lib/email";
interface FileAttachment {
name: string;
url: string;
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(
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 isStaff = session.user.role === "admin" || session.user.role === "curator";
const question = await prisma.studentQuestion.findUnique({
where: { id },
include: { user: { select: { id: true, name: true, email: true } } },
});
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!isStaff && question.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
if (question.status === "CLOSED") {
return NextResponse.json({ error: "Question is closed" }, { status: 409 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { text, files } = body as { text: string; files?: FileAttachment[] };
if (!text?.trim()) {
return NextResponse.json({ error: "text is required" }, { status: 400 });
}
const s3Prefix = buildS3Prefix();
const safeFiles = files
?.filter(
(f) =>
typeof f.name === "string" &&
typeof f.url === "string" &&
f.url.startsWith("https://") &&
f.url.startsWith(s3Prefix) &&
typeof f.size === "number"
)
.map((f) => ({ name: f.name.slice(0, 255), url: f.url, size: Math.max(0, f.size) }));
const [msg] = await prisma.$transaction([
prisma.studentQuestionMessage.create({
data: {
questionId: id,
authorId: session.user.id,
text: text.trim(),
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
},
include: { author: { select: { id: true, name: true, role: true } } },
}),
prisma.studentQuestion.update({
where: { id },
data: { updatedAt: new Date() },
}),
]);
// Send notifications (fire-and-forget, outside transaction)
if (isStaff) {
void sendQuestionReplyEmail(
question.user.email,
question.user.name,
question.title,
id,
);
} else {
const staff = await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
void Promise.all(
staff.map((s) =>
sendQuestionFollowUpEmail(s.email, s.name, session.user.name, question.title)
)
);
}
return NextResponse.json(msg, { status: 201 });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
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 isStaff = session.user.role === "admin" || session.user.role === "curator";
const question = await prisma.studentQuestion.findUnique({
where: { id },
include: {
user: { select: { id: true, name: true } },
course: { select: { id: true, title: true } },
messages: {
include: { author: { select: { id: true, name: true, role: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!question) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!isStaff && question.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Mark unread messages as read
const unreadWhere = isStaff
? { questionId: id, isRead: false, author: { role: "student" } }
: { questionId: id, isRead: false, NOT: { authorId: session.user.id } };
await prisma.studentQuestionMessage.updateMany({
where: unreadWhere,
data: { isRead: true },
});
return NextResponse.json(question);
}
+97
View File
@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { sendQuestionCreatedEmail } from "@/lib/email";
export async function GET() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const isStaff = session.user.role === "admin" || session.user.role === "curator";
const userSelect = isStaff
? { id: true as const, name: true as const, email: true as const }
: { id: true as const, name: true as const };
const questions = await prisma.studentQuestion.findMany({
where: isStaff ? undefined : { userId: session.user.id },
include: {
user: { select: userSelect },
course: { select: { id: true, title: true } },
_count: {
select: {
messages: {
where: isStaff
? { isRead: false, author: { role: "student" } }
: { isRead: false, NOT: { authorId: session.user.id } },
},
},
},
},
orderBy: { updatedAt: "desc" },
});
return NextResponse.json(
questions.map((q) => ({
id: q.id,
title: q.title,
status: q.status,
createdAt: q.createdAt,
updatedAt: q.updatedAt,
user: q.user,
course: q.course,
unreadCount: q._count.messages,
}))
);
}
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
if (session.user.role !== "student") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { title, text, courseId } = body as {
title: string;
text: string;
courseId?: string;
};
if (!title?.trim() || !text?.trim()) {
return NextResponse.json({ error: "title and text are required" }, { status: 400 });
}
const question = await prisma.studentQuestion.create({
data: {
userId: session.user.id,
courseId: courseId ?? null,
title: title.trim(),
messages: {
create: {
authorId: session.user.id,
text: text.trim(),
},
},
},
});
const staff = await prisma.user.findMany({
where: { role: { in: ["admin", "curator"] } },
select: { email: true, name: true },
});
void Promise.all(
staff.map((s) =>
sendQuestionCreatedEmail(s.email, s.name, session.user.name, title.trim())
)
);
return NextResponse.json(question, { status: 201 });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
const key = `homework-audio/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type || "audio/webm");
return NextResponse.json({ url });
}
@@ -12,6 +12,9 @@ export async function POST(req: NextRequest) {
const file = form.get("file") as File | null; const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 }); if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const MAX_BYTES = 50 * 1024 * 1024;
if (file.size > MAX_BYTES) return NextResponse.json({ error: "Файл слишком большой" }, { status: 413 });
const ext = file.name.split(".").pop() ?? "bin"; const ext = file.name.split(".").pop() ?? "bin";
const key = `homework/${session.user.id}/${randomUUID()}.${ext}`; const key = `homework/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
@@ -0,0 +1,43 @@
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";
const ALLOWED_TYPES = new Set([
"image/jpeg", "image/png", "image/gif", "image/webp",
"application/pdf", "text/markdown", "text/x-markdown", "text/plain",
]);
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB
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: "Файл слишком большой (макс. 10 МБ)" }, { status: 413 });
}
if (!ALLOWED_TYPES.has(file.type)) {
return NextResponse.json(
{ error: "Разрешены только jpg, png, pdf, md" },
{ status: 415 }
);
}
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const ALLOWED_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "pdf", "md", "txt"]);
if (!ALLOWED_EXTS.has(ext)) {
return NextResponse.json({ error: "Недопустимое расширение файла" }, { status: 415 });
}
const key = `questions/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ name: file.name, url, size: file.size });
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default async function CuratorDashboard() {
prisma.homeworkSubmission.count(), prisma.homeworkSubmission.count(),
prisma.homeworkFeedback.count({ prisma.homeworkFeedback.count({
where: { where: {
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }, createdAt: { gte: new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) },
curatorId: session.user.id, curatorId: session.user.id,
}, },
}), }),
@@ -5,27 +5,38 @@ 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 { sendFeedbackReceivedEmail } from "@/lib/email"; import { sendFeedbackReceivedEmail } from "@/lib/email";
import { getSetting, asBool } from "@/lib/settings";
export async function submitFeedback(submissionId: string, text: string) { async function requireCurator() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) { if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
throw new Error("Forbidden"); throw new Error("Forbidden");
} }
return session;
}
await prisma.homeworkFeedback.create({ export async function submitFeedback(
data: { submissionId, curatorId: session.user.id, text }, submissionId: string,
}); data: {
text: string;
files?: { name: string; url: string; size: number }[];
audioUrl?: string | null;
action: "approve" | "reject";
}
) {
const session = await requireCurator();
const status = data.action === "approve" ? "APPROVED" : "REJECTED";
// Send email to student
const submission = await prisma.homeworkSubmission.findUnique({ const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId }, where: { id: submissionId },
include: { include: {
user: { select: { email: true, name: true } }, user: { select: { id: true, email: true, name: true } },
homework: { homework: {
include: { include: {
lesson: { lesson: {
select: { select: {
title: true, title: true,
id: true,
module: { select: { course: { select: { slug: true } } } }, module: { select: { course: { select: { slug: true } } } },
}, },
}, },
@@ -34,18 +45,71 @@ export async function submitFeedback(submissionId: string, text: string) {
}, },
}); });
if (submission) { if (!submission) throw new Error("Submission not found");
const { lesson } = submission.homework;
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${submission.homework.lessonId}`; await prisma.$transaction(async (tx) => {
await tx.homeworkFeedback.create({
data: {
submissionId,
curatorId: session.user.id,
text: data.text,
files: data.files ?? [],
audioUrl: data.audioUrl ?? null,
},
});
await tx.homeworkSubmission.update({
where: { id: submissionId },
data: { status, statusAt: new Date() },
});
if (status === "APPROVED") {
await tx.lessonProgress.upsert({
where: {
userId_lessonId: {
userId: submission.user.id,
lessonId: submission.homework.lesson.id,
},
},
create: {
userId: submission.user.id,
lessonId: submission.homework.lesson.id,
},
update: {},
});
}
});
const { lesson } = submission.homework;
const notifySetting = await getSetting("notifyStudentOnFeedback");
if (asBool(notifySetting)) {
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`;
await sendFeedbackReceivedEmail( await sendFeedbackReceivedEmail(
submission.user.email, submission.user.email,
submission.user.name, submission.user.name,
lesson.title, lesson.title,
text, data.text,
lessonUrl lessonUrl
); );
} }
revalidatePath("/curator/homework"); revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`); revalidatePath(`/curator/homework/${submissionId}`);
revalidatePath(`/courses/${lesson.module.course.slug}/lessons/${lesson.id}`);
revalidatePath(`/courses/${lesson.module.course.slug}`);
revalidatePath("/dashboard");
}
export async function setReviewing(submissionId: string) {
await requireCurator();
await prisma.homeworkSubmission.update({
where: { id: submissionId },
data: { status: "REVIEWING", statusAt: new Date() },
});
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
}
export async function deleteSubmission(submissionId: string) {
await requireCurator();
await prisma.homeworkSubmission.delete({ where: { id: submissionId } });
revalidatePath("/curator/homework");
} }
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { ContentViewer } from "@/components/curator/content-viewer";
interface Props {
homeworkDescription: string;
lessonContent: unknown;
}
export function ContentTabs({ homeworkDescription, lessonContent }: Props) {
const [tab, setTab] = useState<"homework" | "lesson">("homework");
return (
<div>
{/* Tab bar */}
<div className="flex items-center gap-0" style={{ borderBottom: "2px solid var(--border)" }}>
{(["homework", "lesson"] as const).map((t) => {
const label = t === "homework" ? "Содержимое ДЗ" : "Содержимое урока";
const active = tab === t;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className="px-4 py-2 text-xs font-medium"
style={{
borderBottom: active ? "2px solid var(--foreground)" : "2px solid transparent",
marginBottom: -2,
color: active ? "var(--foreground)" : "var(--muted-foreground)",
background: "transparent",
}}
>
{label}
</button>
);
})}
</div>
{/* Content */}
<div
className="px-4 py-4 text-sm"
style={{ border: "2px solid var(--border)", borderTop: "none" }}
>
{tab === "homework" ? (
<div className="whitespace-pre-wrap">{homeworkDescription}</div>
) : (
<ContentViewer content={lessonContent} />
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteSubmission } from "./actions";
export function DeleteSubmissionButton({
submissionId,
userName,
}: {
submissionId: string;
userName: string;
}) {
const [pending, startTransition] = useTransition();
const router = useRouter();
function handleDelete() {
if (!confirm(`Удалить работу студента ${userName}? Это действие нельзя отменить.`)) return;
startTransition(async () => {
await deleteSubmission(submissionId);
router.push("/curator/homework");
});
}
return (
<button
type="button"
onClick={handleDelete}
disabled={pending}
className="text-xs px-3 py-1.5"
style={{
border: "1px solid var(--border)",
color: "oklch(0.577 0.245 27.325)",
opacity: pending ? 0.5 : 1,
}}
>
🗑 Удалить работу
</button>
);
}
@@ -1,40 +1,98 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { submitFeedback } from "./actions"; import Link from "next/link";
import { submitFeedback, setReviewing } from "./actions";
import { AudioRecorder } from "@/components/curator/audio-recorder";
export function FeedbackForm({ submissionId }: { submissionId: string }) { interface FileItem {
name: string;
url: string;
size: number;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export function FeedbackForm({
submissionId,
currentStatus,
}: {
submissionId: string;
currentStatus: string;
}) {
const [text, setText] = useState(""); const [text, setText] = useState("");
const [files, setFiles] = useState<FileItem[]>([]);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter(); const router = useRouter();
function handleSubmit(e: React.FormEvent) { async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
e.preventDefault(); const picked = Array.from(e.target.files ?? []);
if (!picked.length) return;
setUploading(true);
const uploaded: FileItem[] = [];
for (const f of picked) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/curator/upload", { method: "POST", body: form });
const data = await res.json();
if (data.url) uploaded.push({ name: data.name, url: data.url, size: data.size });
}
setFiles((prev) => [...prev, ...uploaded]);
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
function handleAction(action: "approve" | "reject") {
if (!text.trim()) return; if (!text.trim()) return;
startTransition(async () => { startTransition(async () => {
await submitFeedback(submissionId, text.trim()); await submitFeedback(submissionId, {
text: text.trim(),
files,
audioUrl,
action,
});
router.push("/curator/homework"); router.push("/curator/homework");
}); });
} }
function handleReviewing() {
startTransition(async () => {
await setReviewing(submissionId);
});
}
const isWorking = pending || uploading;
return ( return (
<form onSubmit={handleSubmit} className="space-y-3"> <div className="space-y-4">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> <p
Написать фидбек className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Ваш ответ
</p> </p>
{/* Text */}
<textarea <textarea
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
required
placeholder="Напишите обратную связь студенту..." placeholder="Напишите обратную связь студенту..."
disabled={isWorking}
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
background: "var(--background)", background: "var(--background)",
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
resize: "vertical", resize: "vertical",
minHeight: "120px", minHeight: "120px",
@@ -42,14 +100,110 @@ export function FeedbackForm({ submissionId }: { submissionId: string }) {
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/> />
<button
type="submit" {/* File upload */}
disabled={pending || !text.trim()} <div className="space-y-2">
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm" <div className="flex items-center gap-2">
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }} <label
> className="btn-aubade text-xs px-3 py-1.5 cursor-pointer"
{pending ? "Отправка..." : "Отправить фидбек"} style={{ opacity: isWorking ? 0.5 : 1 }}
</button> >
</form> 📎 Прикрепить файл
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
disabled={isWorking}
/>
</label>
{uploading && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка...
</span>
)}
</div>
{files.length > 0 && (
<div className="space-y-1">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-2 px-3 py-1.5 text-xs"
style={{ border: "1px solid var(--border)" }}
>
<span>📎</span>
<span className="flex-1 truncate">{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
×
</button>
</div>
))}
</div>
)}
</div>
{/* Audio recorder */}
<AudioRecorder value={audioUrl} onChange={setAudioUrl} />
{/* Action buttons */}
<div className="flex items-center gap-2 flex-wrap pt-1">
<button
type="button"
onClick={() => handleAction("approve")}
disabled={isWorking || !text.trim()}
className="btn-aubade-accent px-4 py-2 text-sm"
style={{ opacity: isWorking || !text.trim() ? 0.5 : 1 }}
>
{pending ? "Отправка..." : "Отправить ответ"}
</button>
{currentStatus !== "REVIEWING" && (
<button
type="button"
onClick={handleReviewing}
disabled={isWorking}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.08 80)",
color: "oklch(0.4 0.1 80)",
opacity: isWorking ? 0.5 : 1,
}}
>
На рассмотрение
</button>
)}
<button
type="button"
onClick={() => handleAction("reject")}
disabled={isWorking || !text.trim()}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.06 27)",
color: "oklch(0.45 0.2 27)",
opacity: isWorking || !text.trim() ? 0.5 : 1,
}}
>
Отклонить и отправить
</button>
<Link
href="/curator/homework"
className="px-4 py-2 text-sm"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
К списку ДЗ
</Link>
</div>
</div>
); );
} }
+194 -47
View File
@@ -2,6 +2,8 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { FeedbackForm } from "./feedback-form"; import { FeedbackForm } from "./feedback-form";
import { ContentTabs } from "./content-tabs";
import { DeleteSubmissionButton } from "./delete-button";
interface Props { interface Props {
params: Promise<{ submissionId: string }>; params: Promise<{ submissionId: string }>;
@@ -13,6 +15,24 @@ function formatSize(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`; return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
} }
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; bg: string; color: string }> = {
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)" },
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)" },
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" },
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)" },
};
const s = map[status] ?? map.PENDING;
return (
<span
className="text-xs px-2 py-0.5 font-medium"
style={{ background: s.bg, color: s.color }}
>
{s.label}
</span>
);
}
export default async function SubmissionPage({ params }: Props) { export default async function SubmissionPage({ params }: Props) {
const { submissionId } = await params; const { submissionId } = await params;
@@ -22,14 +42,21 @@ export default async function SubmissionPage({ params }: Props) {
user: { select: { name: true, email: true } }, user: { select: { name: true, email: true } },
feedbacks: { feedbacks: {
include: { curator: { select: { name: true } } }, include: { curator: { select: { name: true } } },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "asc" },
}, },
homework: { homework: {
include: { include: {
lesson: { lesson: {
select: { select: {
id: true,
title: true, title: true,
module: { select: { title: true, course: { select: { title: true } } } }, content: true,
module: {
select: {
title: true,
course: { select: { title: true, slug: true } },
},
},
}, },
}, },
}, },
@@ -40,59 +67,121 @@ export default async function SubmissionPage({ params }: Props) {
if (!submission) notFound(); if (!submission) notFound();
const files = (submission.files as { name: string; url: string; size: number }[]) ?? []; const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
const isReviewed = submission.feedbacks.length > 0; const lesson = submission.homework.lesson;
const course = lesson.module.course;
return ( return (
<div className="p-8 max-w-2xl"> <div className="p-8 max-w-2xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> {/* Breadcrumb */}
<Link href="/curator/homework" className="hover:underline">ДЗ на проверку</Link> <nav
className="text-xs mb-6 uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
<Link href="/curator/homework" className="hover:underline">
ДЗ на проверку
</Link>
<span className="mx-2">/</span> <span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{submission.user.name}</span> <span style={{ color: "var(--foreground)" }}>{submission.user.name}</span>
</nav> </nav>
{/* Meta */} {/* Meta table */}
<div
className="mb-6"
style={{ border: "2px solid var(--border)" }}
>
{[
{ label: "Автор", value: submission.user.name },
{ label: "Логин", value: submission.user.email },
{
label: "Урок",
value: (
<Link
href={`/courses/${course.slug}/lessons/${lesson.id}`}
target="_blank"
className="hover:underline"
style={{ color: "var(--foreground)" }}
>
{lesson.title}
</Link>
),
},
{ label: "Курс", value: course.title },
{ label: "Статус", value: <StatusBadge status={submission.status} /> },
{
label: "Время последнего изменения статуса",
value: submission.statusAt
? new Date(submission.statusAt).toLocaleString("ru-RU")
: new Date(submission.submittedAt).toLocaleString("ru-RU"),
},
].map(({ label, value }) => (
<div
key={label}
className="flex items-start gap-4 px-4 py-2.5 text-sm"
style={{ borderBottom: "1px solid var(--border)" }}
>
<span
className="w-52 shrink-0 font-medium"
style={{ color: "var(--muted-foreground)" }}
>
{label}
</span>
<span>{value}</span>
</div>
))}
</div>
{/* Content tabs */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-xl font-bold">{submission.homework.lesson.title}</h1> <ContentTabs
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}> homeworkDescription={submission.homework.description}
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.module.title} lessonContent={lesson.content}
</p> />
</div>
{/* Student info */}
<div className="flex items-center justify-between px-4 py-3 mb-6" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<div>
<p className="font-medium text-sm">{submission.user.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{submission.user.email}</p>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
</p>
</div>
{/* Homework description */}
<div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Задание</p>
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{submission.homework.description}
</div>
</div> </div>
{/* Student answer */} {/* Student answer */}
<div className="mb-4"> <div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Ответ студента</p> <p
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: "var(--muted-foreground)" }}
>
Ответ студента
</p>
{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} {submission.text}
</div> </div>
) : ( ) : (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>Текст не добавлен</p> <p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
Текст не добавлен
</p>
)} )}
</div> </div>
{/* Files */} {/* Student audio */}
{submission.audioUrl && (
<div className="mb-4">
<p
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: "var(--muted-foreground)" }}
>
Аудио студента
</p>
<audio controls src={submission.audioUrl} style={{ width: "100%", height: 40 }} />
</div>
)}
{/* Student files */}
{files.length > 0 && ( {files.length > 0 && (
<div className="mb-6"> <div className="mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Прикреплённые файлы</p> <p
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: "var(--muted-foreground)" }}
>
Файлы студента
</p>
<div className="space-y-1"> <div className="space-y-1">
{files.map((f) => ( {files.map((f) => (
<a <a
@@ -105,28 +194,86 @@ export default async function SubmissionPage({ params }: Props) {
> >
<span>📎</span> <span>📎</span>
<span className="flex-1 underline">{f.name}</span> <span className="flex-1 underline">{f.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span> <span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
</a> </a>
))} ))}
</div> </div>
</div> </div>
)} )}
{/* Existing feedback */} {/* Existing feedbacks */}
{submission.feedbacks.map((fb) => ( {submission.feedbacks.length > 0 && (
<div key={fb.id} className="mb-4 px-4 py-3" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}> <div className="mb-6 space-y-3">
<div className="flex items-center justify-between mb-1"> <p
<p className="text-xs font-bold uppercase tracking-widest">Фидбек</p> className="text-xs font-bold uppercase tracking-widest"
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}> style={{ color: "var(--muted-foreground)" }}
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")} >
</span> История фидбека
</div> </p>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p> {submission.feedbacks.map((fb) => {
const fbFiles = (fb.files as { name: string; url: string; size: number }[]) ?? [];
return (
<div
key={fb.id}
className="px-4 py-3"
style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold uppercase tracking-widest">
{fb.curator.name}
</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{new Date(fb.createdAt).toLocaleString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
{fbFiles.length > 0 && (
<div className="space-y-1 mt-2">
{fbFiles.map((f) => (
<a
key={f.url}
href={f.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs underline"
>
<span>📎</span>
<span>{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
</a>
))}
</div>
)}
{fb.audioUrl && (
<div className="mt-2">
<audio controls src={fb.audioUrl} style={{ height: 32 }} />
</div>
)}
</div>
);
})}
</div> </div>
))} )}
{/* Feedback form */} {/* Feedback form */}
{!isReviewed && <FeedbackForm submissionId={submissionId} />} <div
className="p-5 mb-4"
style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}
>
<FeedbackForm
submissionId={submissionId}
currentStatus={submission.status}
/>
</div>
{/* Delete */}
<div className="flex justify-end">
<DeleteSubmissionButton submissionId={submissionId} userName={submission.user.name} />
</div>
</div> </div>
); );
} }
+17 -16
View File
@@ -38,8 +38,10 @@ export default async function HomeworkListPage({ searchParams }: Props) {
}, },
} }
: {}), : {}),
...(status === "pending" ? { feedbacks: { none: {} } } : {}), ...(status === "pending" ? { status: "PENDING" } : {}),
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}), ...(status === "reviewing" ? { status: "REVIEWING" } : {}),
...(status === "approved" ? { status: "APPROVED" } : {}),
...(status === "rejected" ? { status: "REJECTED" } : {}),
}; };
const [submissions, total, courses] = await Promise.all([ const [submissions, total, courses] = await Promise.all([
@@ -50,7 +52,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
take: PAGE_SIZE, take: PAGE_SIZE,
include: { include: {
user: { select: { name: true, email: true } }, user: { select: { name: true, email: true } },
feedbacks: { select: { id: true } }, feedbacks: { select: { id: true }, take: 1 },
homework: { homework: {
include: { include: {
lesson: { lesson: {
@@ -69,7 +71,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
const totalPages = Math.ceil(total / PAGE_SIZE); const totalPages = Math.ceil(total / PAGE_SIZE);
const pendingCount = submissions.filter((s) => s.feedbacks.length === 0).length; const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
function pageUrl(p: number) { function pageUrl(p: number) {
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -109,16 +111,19 @@ 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 isPending = s.feedbacks.length === 0; const statusMap: Record<string, { label: string; bg: string; color: string; border: string }> = {
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)", border: "var(--foreground)" },
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)", border: "oklch(0.75 0.1 80)" },
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)", border: "oklch(0.7 0.15 145)" },
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)", border: "oklch(0.75 0.1 27)" },
};
const st = statusMap[s.status] ?? statusMap.PENDING;
return ( return (
<Link <Link
key={s.id} key={s.id}
href={`/curator/homework/${s.id}`} href={`/curator/homework/${s.id}`}
className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80" className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80"
style={{ style={{ border: `2px solid ${st.border}`, display: "flex" }}
border: `2px solid ${isPending ? "var(--foreground)" : "var(--border)"}`,
display: "flex",
}}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{s.user.name}</p> <p className="font-medium truncate">{s.user.name}</p>
@@ -131,14 +136,10 @@ export default async function HomeworkListPage({ searchParams }: Props) {
</div> </div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<span <span
className="text-xs px-2 py-0.5" className="text-xs px-2 py-0.5 font-medium"
style={{ style={{ background: st.bg, color: st.color }}
border: "1px solid var(--border)",
background: isPending ? "var(--foreground)" : "transparent",
color: isPending ? "var(--background)" : "var(--muted-foreground)",
}}
> >
{isPending ? "Новое" : "Проверено"} {st.label}
</span> </span>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}> <p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{new Date(s.submittedAt).toLocaleDateString("ru-RU")} {new Date(s.submittedAt).toLocaleDateString("ru-RU")}
+13 -1
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { LogoutButton } from "@/components/layout/logout-button"; import { LogoutButton } from "@/components/layout/logout-button";
import { AdminShell } from "@/components/admin/admin-shell"; import { AdminShell } from "@/components/admin/admin-shell";
import { getSetting } from "@/lib/settings"; import { getSetting } from "@/lib/settings";
import { prisma } from "@/lib/prisma";
export default async function CuratorLayout({ children }: { children: React.ReactNode }) { export default async function CuratorLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
@@ -19,7 +20,17 @@ export default async function CuratorLayout({ children }: { children: React.Reac
// Admin uses the admin shell with sidebar // Admin uses the admin shell with sidebar
if (session.user.role === "admin") { if (session.user.role === "admin") {
return <AdminShell userName={session.user.name}>{children}</AdminShell>; const questionsBadge = await prisma.studentQuestion.count({
where: {
messages: {
some: {
isRead: false,
author: { role: "student" },
},
},
},
});
return <AdminShell userName={session.user.name} questionsBadge={questionsBadge}>{children}</AdminShell>;
} }
return ( return (
@@ -33,6 +44,7 @@ export default async function CuratorLayout({ children }: { children: React.Reac
<nav className="flex-1 py-3 space-y-0.5 px-2"> <nav className="flex-1 py-3 space-y-0.5 px-2">
<NavLink href="/curator/dashboard">Обзор</NavLink> <NavLink href="/curator/dashboard">Обзор</NavLink>
<NavLink href="/curator/homework">ДЗ на проверку</NavLink> <NavLink href="/curator/homework">ДЗ на проверку</NavLink>
<NavLink href="/curator/questions">Вопросы</NavLink>
</nav> </nav>
<div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}> <div className="px-4 py-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
<p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p> <p className="text-xs mb-2 truncate" style={{ color: "#888" }}>{session.user.name}</p>
+12
View File
@@ -0,0 +1,12 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { QuestionSplitView } from "@/components/questions/QuestionSplitView";
export default async function CuratorQuestionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
if (session.user.role !== "admin" && session.user.role !== "curator") redirect("/dashboard");
return <QuestionSplitView currentUserId={session.user.id} />;
}
+29 -6
View File
@@ -1,7 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@@ -34,6 +31,17 @@
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
/* Типографическая шкала — канон ДС-2, +2px к дефолту Tailwind */
--text-xs: 14px;
--text-sm: 16px;
--text-base: 18px;
--text-lg: 20px;
--text-xl: 22px;
--text-2xl: 26px;
--text-3xl: 32px;
--text-4xl: 38px;
--text-5xl: 50px;
} }
/* ── Light mode: Second Brain palette ──────────────────────────────── */ /* ── Light mode: Second Brain palette ──────────────────────────────── */
@@ -87,6 +95,7 @@
body { body {
background-color: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-size: 18px;
} }
} }
@@ -108,7 +117,7 @@
justify-content: center; justify-content: center;
padding: 6px 16px; padding: 6px 16px;
font-family: var(--font-sans); font-family: var(--font-sans);
font-size: 0.875rem; font-size: 16px;
font-weight: 500; font-weight: 500;
border: var(--aubade-thickness) solid var(--foreground); border: var(--aubade-thickness) solid var(--foreground);
box-shadow: var(--aubade-shadow-offset) var(--aubade-shadow-offset) 0 0 var(--foreground); box-shadow: var(--aubade-shadow-offset) var(--aubade-shadow-offset) 0 0 var(--foreground);
@@ -137,7 +146,7 @@
background-color: var(--color-surface); background-color: var(--color-surface);
border: var(--aubade-thickness) solid transparent; border: var(--aubade-thickness) solid transparent;
padding: 2px 8px; padding: 2px 8px;
font-size: 0.65rem; font-size: 13px;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.1em; letter-spacing: 0.1em;
font-weight: 700; font-weight: 700;
@@ -148,6 +157,20 @@
border-color: var(--foreground); border-color: var(--foreground);
} }
/* ── Lesson content (TipTap nested lists fix) ──────────────────────── */
.prose :where(li > p) {
margin-top: 0;
margin-bottom: 0;
}
.prose :where(li > ul, li > ol) {
margin-top: 0.375em;
margin-bottom: 0.375em;
padding-left: 1.5em;
}
.prose :where(ul > li, ol > li) {
padding-left: 0.375em;
}
/* Admin sidebar (dark) */ /* Admin sidebar (dark) */
.admin-sidebar { .admin-sidebar {
background-color: var(--sidebar-bg); background-color: var(--sidebar-bg);
@@ -156,7 +179,7 @@
.admin-sidebar-nav-link { .admin-sidebar-nav-link {
display: block; display: block;
padding: 8px 12px; padding: 8px 12px;
font-size: 0.875rem; font-size: 16px;
color: var(--sidebar-text); color: var(--sidebar-text);
text-decoration: none; text-decoration: none;
border-left: 2px solid transparent; border-left: 2px solid transparent;
+21 -3
View File
@@ -9,12 +9,14 @@ const links = [
{ href: "/admin/categories", label: "Категории" }, { href: "/admin/categories", label: "Категории" },
{ href: "/admin/users", label: "Пользователи" }, { href: "/admin/users", label: "Пользователи" },
{ href: "/curator/homework", label: "ДЗ на проверку" }, { href: "/curator/homework", label: "ДЗ на проверку" },
{ href: "/admin/questions", label: "Вопросы" },
{ href: "/admin/quizzes", label: "Тесты" },
{ href: "/admin/comments", label: "Комментарии" }, { href: "/admin/comments", label: "Комментарии" },
{ href: "/admin/import-export", label: "Импорт / Экспорт" }, { href: "/admin/import-export", label: "Импорт / Экспорт" },
{ href: "/admin/settings", label: "Настройки" }, { href: "/admin/settings", label: "Настройки" },
]; ];
export function AdminNav() { export function AdminNav({ questionsBadge = 0 }: { questionsBadge?: number }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@@ -23,7 +25,7 @@ export function AdminNav() {
const active = const active =
pathname === href || pathname === href ||
(href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) || (href !== "/admin/dashboard" && href !== "/curator/homework" && pathname.startsWith(href)) ||
(href === "/curator/homework" && pathname.startsWith("/curator")); (href === "/curator/homework" && pathname.startsWith("/curator/homework"));
return ( return (
<Link <Link
key={href} key={href}
@@ -39,7 +41,23 @@ export function AdminNav() {
: undefined : undefined
} }
> >
{label} <span className="flex items-center justify-between w-full">
{label}
{href === "/admin/questions" && questionsBadge > 0 && (
<span
className="ml-2 inline-flex items-center justify-center rounded-full text-xs font-bold leading-none"
style={{
minWidth: "1.25rem",
height: "1.25rem",
padding: "0 0.3rem",
backgroundColor: "var(--destructive)",
color: "#fff",
}}
>
{questionsBadge}
</span>
)}
</span>
</Link> </Link>
); );
})} })}
+3 -1
View File
@@ -4,9 +4,11 @@ import { LogoutButton } from "@/components/layout/logout-button";
export function AdminShell({ export function AdminShell({
children, children,
userName, userName,
questionsBadge = 0,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
userName: string; userName: string;
questionsBadge?: number;
}) { }) {
return ( return (
<div className="min-h-screen flex"> <div className="min-h-screen flex">
@@ -23,7 +25,7 @@ export function AdminShell({
</p> </p>
</div> </div>
<nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto"> <nav className="flex-1 px-2 py-4 space-y-0.5 overflow-y-auto">
<AdminNav /> <AdminNav questionsBadge={questionsBadge} />
</nav> </nav>
<div className="px-4 py-4" style={{ borderTop: "2px solid var(--sidebar-border)" }}> <div className="px-4 py-4" style={{ borderTop: "2px solid var(--sidebar-border)" }}>
<p className="text-xs mb-3 truncate" style={{ color: "var(--sidebar-text)" }}> <p className="text-xs mb-3 truncate" style={{ color: "var(--sidebar-text)" }}>
+30 -11
View File
@@ -14,6 +14,7 @@ interface Course {
description: string | null; description: string | null;
coverImage: string | null; coverImage: string | null;
published: boolean; published: boolean;
allowAudio: boolean;
categoryId: string | null; categoryId: string | null;
} }
@@ -24,6 +25,7 @@ interface Category {
export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) { export function CourseEditForm({ course, categories = [] }: { course: Course; categories?: Category[] }) {
const [published, setPublished] = useState(course.published); const [published, setPublished] = useState(course.published);
const [allowAudio, setAllowAudio] = useState(course.allowAudio);
const [coverImage, setCoverImage] = useState(course.coverImage ?? ""); const [coverImage, setCoverImage] = useState(course.coverImage ?? "");
const [categoryId, setCategoryId] = useState(course.categoryId ?? ""); const [categoryId, setCategoryId] = useState(course.categoryId ?? "");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
@@ -45,6 +47,7 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
e.preventDefault(); e.preventDefault();
const fd = new FormData(e.currentTarget); const fd = new FormData(e.currentTarget);
fd.set("published", String(published)); fd.set("published", String(published));
fd.set("allowAudio", String(allowAudio));
fd.set("coverImage", coverImage); fd.set("coverImage", coverImage);
fd.set("categoryId", categoryId); fd.set("categoryId", categoryId);
startTransition(() => updateCourse(course.id, fd)); startTransition(() => updateCourse(course.id, fd));
@@ -99,17 +102,33 @@ export function CourseEditForm({ course, categories = [] }: { course: Course; ca
{uploading && <span className="text-sm text-slate-400">Загрузка...</span>} {uploading && <span className="text-sm text-slate-400">Загрузка...</span>}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="space-y-2">
<button <div className="flex items-center gap-2">
type="button" <button
role="switch" type="button"
aria-checked={published} role="switch"
onClick={() => setPublished(!published)} aria-checked={published}
className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`} onClick={() => setPublished(!published)}
> className={`relative w-10 h-6 rounded-full transition-colors ${published ? "bg-green-500" : "bg-slate-300"}`}
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} /> >
</button> <span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${published ? "translate-x-4" : ""}`} />
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span> </button>
<span className="text-sm text-slate-600">{published ? "Опубликован" : "Черновик"}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={allowAudio}
onClick={() => setAllowAudio(!allowAudio)}
className={`relative w-10 h-6 rounded-full transition-colors ${allowAudio ? "bg-blue-500" : "bg-slate-300"}`}
>
<span className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full shadow transition-transform ${allowAudio ? "translate-x-4" : ""}`} />
</button>
<span className="text-sm text-slate-600">
{allowAudio ? "🎤 Аудио-ответ в ДЗ включён" : "Аудио-ответ в ДЗ выключен"}
</span>
</div>
</div> </div>
<div className="flex justify-between pt-2"> <div className="flex justify-between pt-2">
<Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}> <Button type="button" variant="destructive" onClick={handleDelete} disabled={pending}>
+1 -1
View File
@@ -22,7 +22,7 @@ const inputStyle: React.CSSProperties = {
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
}; };
+1 -1
View File
@@ -11,7 +11,7 @@ const inputStyle: React.CSSProperties = {
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
}; };
const focusHandlers = { const focusHandlers = {
+1 -1
View File
@@ -18,7 +18,7 @@ const inputStyle: React.CSSProperties = {
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
}; };
const focusHandlers = { const focusHandlers = {
+1 -1
View File
@@ -3,7 +3,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { grantAccess, revokeAccess } from "@/app/admin/courses/[courseId]/actions"; import { grantAccess, revokeAccess } from "@/lib/actions/course-actions";
interface Student { interface Student {
id: string; id: string;
+2 -2
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { saveHomework, deleteHomework } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/homework-actions"; import { saveHomework, deleteHomework } from "@/lib/actions/homework-actions";
interface Props { interface Props {
lessonId: string; lessonId: string;
@@ -20,7 +20,7 @@ export function HomeworkEditor({ lessonId, initial }: Props) {
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
resize: "vertical" as const, resize: "vertical" as const,
minHeight: "120px", minHeight: "120px",
+4 -2
View File
@@ -64,8 +64,10 @@ export function HomeworkFilters({ courses }: { courses: Course[] }) {
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
> >
<option value="">Все статусы</option> <option value="">Все статусы</option>
<option value="pending">Ожидают проверки</option> <option value="pending">Новые</option>
<option value="reviewed">Проверено</option> <option value="reviewing">На рассмотрении</option>
<option value="approved">Одобрено</option>
<option value="rejected">Отклонено</option>
</select> </select>
{/* Course */} {/* Course */}
+52 -15
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useCallback, useTransition } from "react"; import { useState, useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
@@ -9,7 +9,6 @@ import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react"; import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
interface LessonData { interface LessonData {
id: string; id: string;
@@ -47,7 +46,8 @@ export function LessonEditor({
const [importing, setImporting] = useState(false); const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null); const [importError, setImportError] = useState<string | null>(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition(); const [saveError, setSaveError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const inputStyle = { const inputStyle = {
border: "2px solid var(--border)", border: "2px solid var(--border)",
@@ -55,7 +55,7 @@ export function LessonEditor({
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
} as React.CSSProperties; } as React.CSSProperties;
@@ -73,7 +73,7 @@ export function LessonEditor({
class: "prose prose-slate max-w-none min-h-[300px] focus:outline-none p-4", class: "prose prose-slate max-w-none min-h-[300px] focus:outline-none p-4",
}, },
}, },
}); }, [lesson.id]);
const uploadImage = useCallback(async () => { const uploadImage = useCallback(async () => {
const input = document.createElement("input"); const input = document.createElement("input");
@@ -133,18 +133,27 @@ export function LessonEditor({
} }
}, [editor]); }, [editor]);
function handleSave() { async function handleSave() {
if (!editor) return; if (!editor) return;
startTransition(async () => { setPending(true);
await saveLesson(lesson.id, courseId, moduleId, { setSaveError(null);
title, try {
kinescopeId, const res = await fetch(`/api/admin/lessons/${lesson.id}`, {
content: editor.getJSON(), method: "PATCH",
published, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, kinescopeId, content: editor.getJSON(), published }),
}); });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
}
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
}); } catch (err) {
setSaveError(err instanceof Error ? err.message : "Ошибка сохранения");
} finally {
setPending(false);
}
} }
function navigateTo(lessonId: string) { function navigateTo(lessonId: string) {
@@ -154,7 +163,22 @@ export function LessonEditor({
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Header controls */} {/* Header controls */}
<div className="flex items-center justify-between gap-2 flex-wrap"> <div
className="flex items-center justify-between gap-2 flex-wrap"
style={{
position: "sticky",
top: 0,
zIndex: 30,
background: "var(--background)",
marginLeft: "-1.5rem",
marginRight: "-1.5rem",
paddingLeft: "1.5rem",
paddingRight: "1.5rem",
paddingTop: "0.75rem",
paddingBottom: "0.75rem",
borderBottom: "2px solid var(--border)",
}}
>
{/* Left: published toggle + prev/next */} {/* Left: published toggle + prev/next */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
@@ -251,6 +275,12 @@ export function LessonEditor({
</p> </p>
)} )}
{saveError && (
<p className="text-xs px-3 py-2" style={{ background: "oklch(0.577 0.245 27.325 / 0.1)", color: "oklch(0.577 0.245 27.325)", border: "1px solid oklch(0.577 0.245 27.325 / 0.3)" }}>
Ошибка сохранения: {saveError}
</p>
)}
{/* Title */} {/* Title */}
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> <label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
@@ -289,7 +319,14 @@ export function LessonEditor({
{/* Toolbar */} {/* Toolbar */}
<div <div
className="flex flex-wrap gap-0.5 p-2" className="flex flex-wrap gap-0.5 p-2"
style={{ border: "2px solid var(--border)", borderBottom: "1px solid var(--border)", background: "var(--color-surface)" }} style={{
position: "sticky",
top: "62px",
zIndex: 20,
border: "2px solid var(--border)",
borderBottom: "1px solid var(--border)",
background: "var(--color-surface)",
}}
> >
{/* Text style */} {/* Text style */}
<ToolBtn onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}><strong>Ж</strong></ToolBtn> <ToolBtn onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}><strong>Ж</strong></ToolBtn>
+116 -15
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useRef } from "react";
import { Button } from "@/components/ui/button"; import { FileFormatBadge } from "@/components/shared/file-format-badge";
interface LessonFile { interface LessonFile {
id: string; id: string;
@@ -13,6 +13,10 @@ interface LessonFile {
export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: string; initialFiles: LessonFile[] }) { export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: string; initialFiles: LessonFile[] }) {
const [files, setFiles] = useState(initialFiles); const [files, setFiles] = useState(initialFiles);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [labelInput, setLabelInput] = useState("");
const [editingId, setEditingId] = useState<string | null>(null);
const [editingLabel, setEditingLabel] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) { async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -21,23 +25,52 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
fd.append("lessonId", lessonId); fd.append("lessonId", lessonId);
if (labelInput.trim()) fd.append("label", labelInput.trim());
const res = await fetch("/api/admin/lesson-files", { method: "POST", body: fd }); const res = await fetch("/api/admin/lesson-files", { method: "POST", body: fd });
const created = await res.json(); const created = await res.json();
if (created.id) setFiles((prev) => [...prev, created]); if (created.id) {
setFiles((prev) => [...prev, created]);
setLabelInput("");
}
setUploading(false); setUploading(false);
e.target.value = ""; e.target.value = "";
} }
async function handleDelete(fileId: string) { async function handleDelete(fileId: string, url: string) {
if (!confirm("Удалить файл?")) return; if (!confirm("Удалить файл?")) return;
await fetch("/api/admin/lesson-files", { await fetch("/api/admin/lesson-files", {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId }), body: JSON.stringify({ fileId, url }),
}); });
setFiles((prev) => prev.filter((f) => f.id !== fileId)); setFiles((prev) => prev.filter((f) => f.id !== fileId));
} }
async function saveLabel(fileId: string) {
const trimmed = editingLabel.trim();
if (!trimmed) return cancelEdit();
const res = await fetch("/api/admin/lesson-files", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileId, label: trimmed }),
});
const updated = await res.json();
if (updated.id) {
setFiles((prev) => prev.map((f) => (f.id === fileId ? { ...f, name: updated.name } : f)));
}
cancelEdit();
}
function startEdit(file: LessonFile) {
setEditingId(file.id);
setEditingLabel(file.name);
}
function cancelEdit() {
setEditingId(null);
setEditingLabel("");
}
function formatSize(bytes: number) { function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`; if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
@@ -49,24 +82,92 @@ export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: strin
{files.length > 0 && ( {files.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
{files.map((f) => ( {files.map((f) => (
<div key={f.id} className="flex items-center gap-3 px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}> <div
<span className="text-base">📎</span> key={f.id}
<a href={f.url} target="_blank" rel="noopener noreferrer" className="flex-1 underline font-medium">{f.name}</a> className="flex items-center gap-3 px-3 py-2.5 text-sm"
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span> style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}
<button onClick={() => handleDelete(f.id)} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}> >
<FileFormatBadge url={f.url} />
{editingId === f.id ? (
<input
autoFocus
value={editingLabel}
onChange={(e) => setEditingLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveLabel(f.id);
if (e.key === "Escape") cancelEdit();
}}
onBlur={() => saveLabel(f.id)}
className="flex-1 text-sm px-2 py-0.5"
style={{
border: "1px solid var(--foreground)",
background: "var(--background)",
outline: "none",
fontFamily: "inherit",
}}
/>
) : (
<button
type="button"
onClick={() => startEdit(f)}
className="flex-1 text-left font-medium"
title="Нажмите, чтобы изменить название"
style={{ color: "var(--foreground)" }}
>
{f.name}
</button>
)}
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
<button
type="button"
onClick={() => handleDelete(f.id, f.url)}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить Удалить
</button> </button>
</div> </div>
))} ))}
</div> </div>
)} )}
<div className="flex items-center gap-3">
<label className="btn-aubade text-xs cursor-pointer"> <div className="flex items-center gap-2">
<input
type="text"
value={labelInput}
onChange={(e) => setLabelInput(e.target.value)}
placeholder="Название (например, Презентация)"
className="flex-1 text-sm px-3 py-2"
style={{
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
fontFamily: "inherit",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="btn-aubade text-xs whitespace-nowrap"
style={{ flexShrink: 0, opacity: uploading ? 0.6 : 1 }}
>
{uploading ? "Загрузка..." : "+ Добавить файл"} {uploading ? "Загрузка..." : "+ Добавить файл"}
<input type="file" className="sr-only" onChange={handleUpload} disabled={uploading} /> </button>
</label> <input
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX до 100 МБ</span> ref={fileInputRef}
type="file"
className="sr-only"
onChange={handleUpload}
disabled={uploading}
accept=".pdf,.zip,.docx,.xlsx,.doc,.xls,.pptx,.ppt,.mp4,.mp3"
/>
</div> </div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX до 100 МБ</p>
</div> </div>
); );
} }
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState, useTransition } from "react";
import { saveQuiz, deleteQuiz } from "@/lib/actions/quiz-actions";
type QType = "TEXT" | "SINGLE" | "MULTIPLE";
interface Question {
id?: string;
text: string;
type: QType;
order: number;
}
interface Props {
lessonId: string;
initial: { id: string; questions: Question[] } | null;
}
const TYPE_LABELS: Record<QType, string> = {
TEXT: "Текстовый ответ",
SINGLE: "Один вариант",
MULTIPLE: "Несколько вариантов",
};
export function QuizEditor({ lessonId, initial }: Props) {
const [questions, setQuestions] = useState<Question[]>(
initial?.questions ?? []
);
const [saved, setSaved] = useState(false);
const [pending, startTransition] = useTransition();
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "16px",
fontFamily: "inherit",
};
function addQuestion() {
setQuestions((prev) => [
...prev,
{ text: "", type: "TEXT", order: prev.length },
]);
}
function updateQuestion(idx: number, patch: Partial<Question>) {
setQuestions((prev) =>
prev.map((q, i) => (i === idx ? { ...q, ...patch } : q))
);
}
function removeQuestion(idx: number) {
setQuestions((prev) =>
prev.filter((_, i) => i !== idx).map((q, i) => ({ ...q, order: i }))
);
}
function handleSave() {
if (questions.length === 0) return;
startTransition(async () => {
await saveQuiz(
lessonId,
questions.map((q, i) => ({ text: q.text.trim(), type: q.type, order: i }))
);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
});
}
function handleDelete() {
if (!confirm("Удалить квиз и все ответы студентов?")) return;
startTransition(async () => {
await deleteQuiz(lessonId);
setQuestions([]);
});
}
return (
<div className="space-y-4">
{questions.length === 0 && (
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Вопросов нет
</p>
)}
{questions.map((q, idx) => (
<div
key={idx}
className="space-y-2 px-4 py-3"
style={{ border: "2px solid var(--border)" }}
>
<div className="flex items-center justify-between gap-2">
<span
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Вопрос {idx + 1}
</span>
<button
onClick={() => removeQuestion(idx)}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</div>
<input
value={q.text}
onChange={(e) => updateQuestion(idx, { text: e.target.value })}
style={{ ...inputStyle, minHeight: "unset" }}
placeholder="Текст вопроса..."
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<div className="flex gap-2">
{(["TEXT", "SINGLE", "MULTIPLE"] as QType[]).map((t) => (
<button
key={t}
onClick={() => updateQuestion(idx, { type: t })}
className="text-xs px-2 py-1"
style={{
border: `2px solid ${q.type === t ? "var(--foreground)" : "var(--border)"}`,
background: q.type === t ? "var(--foreground)" : "transparent",
color: q.type === t ? "var(--background)" : "var(--foreground)",
}}
>
{TYPE_LABELS[t]}
</button>
))}
</div>
</div>
))}
<div className="flex items-center gap-2 flex-wrap">
<button onClick={addQuestion} className="btn-aubade text-xs px-3 py-1.5">
+ Добавить вопрос
</button>
<button
onClick={handleSave}
disabled={pending || questions.length === 0 || questions.some((q) => !q.text.trim())}
className="btn-aubade btn-aubade-accent text-xs px-4 py-1.5"
style={{
opacity:
pending || questions.length === 0 || questions.some((q) => !q.text.trim())
? 0.6
: 1,
}}
>
{pending ? "Сохранение..." : "Сохранить квиз"}
</button>
{initial && (
<button
onClick={handleDelete}
disabled={pending}
className="text-xs px-3 py-1.5"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить квиз
</button>
)}
{saved && (
<span
className="text-xs self-center"
style={{ color: "var(--muted-foreground)" }}
>
Сохранено
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { resetUserPassword } from "@/app/admin/users/[userId]/actions";
export function ResetPasswordButton({ userId }: { userId: string }) {
const [tempPassword, setTempPassword] = useState<string | null>(null);
const [isPending, setIsPending] = useState(false);
const [copied, setCopied] = useState(false);
async function handleReset() {
if (!confirm("Сгенерировать новый временный пароль для пользователя?")) return;
setIsPending(true);
setTempPassword(null);
try {
const { tempPassword: pw } = await resetUserPassword(userId);
setTempPassword(pw);
} catch (e) {
alert(e instanceof Error ? e.message : "Ошибка");
} finally {
setIsPending(false);
}
}
async function handleCopy() {
if (!tempPassword) return;
await navigator.clipboard.writeText(tempPassword);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="space-y-3">
<button
onClick={handleReset}
disabled={isPending}
className="btn-aubade text-sm"
style={isPending ? { opacity: 0.6, cursor: "not-allowed" } : undefined}
>
{isPending ? "Генерация..." : "Сбросить пароль"}
</button>
{tempPassword && (
<div className="p-3 text-sm space-y-2" style={{ border: "2px solid var(--border)", backgroundColor: "var(--color-highlight)" }}>
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Временный пароль передай пользователю
</p>
<div className="flex items-center gap-3">
<code className="text-base font-bold tracking-wider">{tempPassword}</code>
<button
onClick={handleCopy}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
{copied ? "Скопировано" : "Копировать"}
</button>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Пользователь сможет сменить пароль в профиле после входа.
</p>
</div>
)}
</div>
);
}
+57 -1
View File
@@ -13,7 +13,7 @@ const inputStyle = {
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.5rem 0.75rem", padding: "0.5rem 0.75rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
} as React.CSSProperties; } as React.CSSProperties;
@@ -432,6 +432,62 @@ export function SettingsForm({ initial }: { initial: Settings }) {
</Field> </Field>
</Section> </Section>
{/* ── 7. Логотип ── */}
<Section
title="Логотип"
hint="URL изображения логотипа школы. Отображается рядом с названием в шапке личного кабинета ученика."
>
<Field label="URL логотипа">
<input
value={s.logoUrl}
onChange={(e) => set("logoUrl", e.target.value)}
placeholder="https://..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Toggle
label="Показывать логотип в шапке"
hint="Если выключено — логотип скрыт, отображается только название школы."
checked={bool("showLogo")}
onChange={(v) => set("showLogo", v ? "true" : "false")}
/>
</Section>
{/* ── 8. Социальные сети ── */}
<Section
title="Социальные сети"
hint="Ссылки отображаются в подвале личного кабинета ученика."
>
<Field label="YouTube (URL канала)">
<input
value={s.socialYoutube}
onChange={(e) => set("socialYoutube", e.target.value)}
placeholder="https://youtube.com/@..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="VK (URL сообщества)">
<input
value={s.socialVk}
onChange={(e) => set("socialVk", e.target.value)}
placeholder="https://vk.com/..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
<Field label="Telegram (ссылка на канал или группу)">
<input
value={s.socialTelegram}
onChange={(e) => set("socialTelegram", e.target.value)}
placeholder="https://t.me/..."
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
{...focusHandlers}
/>
</Field>
</Section>
{/* Bottom save button */} {/* Bottom save button */}
<div className="flex justify-end pb-4"> <div className="flex justify-end pb-4">
<button <button
+1 -1
View File
@@ -25,7 +25,7 @@ import {
reorderLessons, reorderLessons,
toggleLessonPublished, toggleLessonPublished,
moveLessonToModule, moveLessonToModule,
} from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions"; } from "@/lib/actions/module-actions";
interface Lesson { interface Lesson {
id: string; id: string;
+1 -1
View File
@@ -17,7 +17,7 @@ import {
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import Link from "next/link"; import Link from "next/link";
import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions"; import { createModule, deleteModule, updateModule, reorderModules } from "@/lib/actions/course-actions";
interface Module { interface Module {
id: string; id: string;
@@ -0,0 +1,40 @@
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
export function StopImpersonateBanner({ userName }: { userName: string }) {
const [loading, setLoading] = useState(false);
async function handleStop() {
setLoading(true);
try {
await authClient.admin.stopImpersonating();
window.location.href = "/admin/users";
} catch {
setLoading(false);
}
}
return (
<div
className="flex items-center justify-between px-6 py-2 text-sm"
style={{
backgroundColor: "var(--color-highlight)",
borderBottom: "2px solid var(--foreground)",
}}
>
<span>
Вы просматриваете платформу как <strong>{userName}</strong>
</span>
<button
type="button"
onClick={handleStop}
disabled={loading}
className="btn-aubade text-xs px-3 py-1"
>
{loading ? "..." : "← Вернуться в админку"}
</button>
</div>
);
}
+185
View File
@@ -0,0 +1,185 @@
"use client";
import { useState, useTransition } from "react";
import { addBalanceTransaction, deleteBalanceTransaction } from "@/app/admin/users/[userId]/actions";
interface Transaction {
id: string;
amount: number;
description: string;
createdAt: Date;
}
interface Props {
userId: string;
transactions: Transaction[];
}
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.6rem",
fontSize: "16px",
fontFamily: "inherit",
} as React.CSSProperties;
const focusHandlers = {
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
function formatAmount(amount: number): string {
const sign = amount > 0 ? "+" : "";
return `${sign}${amount.toLocaleString("ru-RU", { minimumFractionDigits: 0, maximumFractionDigits: 2 })} ₽`;
}
export function UserBalanceBlock({ userId, transactions }: Props) {
const [showForm, setShowForm] = useState(false);
const [amountVal, setAmountVal] = useState("");
const [descVal, setDescVal] = useState("");
const [error, setError] = useState("");
const [pending, startTransition] = useTransition();
const [deletingId, setDeletingId] = useState<string | null>(null);
const balance = transactions.reduce((sum, t) => sum + t.amount, 0);
function handleAdd() {
setError("");
const num = parseFloat(amountVal.replace(",", "."));
if (isNaN(num) || num === 0) { setError("Введите ненулевую сумму"); return; }
if (!descVal.trim()) { setError("Добавьте описание"); return; }
startTransition(async () => {
await addBalanceTransaction(userId, { amount: amountVal, description: descVal });
setAmountVal("");
setDescVal("");
setShowForm(false);
});
}
function handleDelete(txId: string) {
setDeletingId(txId);
startTransition(async () => {
await deleteBalanceTransaction(userId, txId);
setDeletingId(null);
});
}
return (
<div className="space-y-4">
{/* Balance summary */}
<div className="flex items-center justify-between">
<div className="flex items-baseline gap-3">
<span
className="text-2xl font-bold"
style={{ color: balance > 0 ? "#3A6A3A" : balance < 0 ? "oklch(0.577 0.245 27.325)" : "var(--foreground)" }}
>
{formatAmount(balance)}
</span>
<span className="text-xs uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
на балансе
</span>
</div>
<button
type="button"
onClick={() => { setShowForm((v) => !v); setError(""); }}
className="btn-aubade btn-aubade-accent px-3 py-1.5 text-xs"
>
{showForm ? "Отмена" : "+ Операция"}
</button>
</div>
{/* Add form */}
{showForm && (
<div className="p-4 space-y-3" style={{ border: "2px solid var(--border)" }}>
<div className="flex gap-3">
<div className="space-y-1" style={{ width: 140 }}>
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Сумма,
</label>
<input
type="text"
value={amountVal}
onChange={(e) => setAmountVal(e.target.value)}
placeholder="-3490 или 1000"
style={{ ...inputStyle, width: "100%" }}
{...focusHandlers}
/>
</div>
<div className="space-y-1 flex-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Описание
</label>
<input
type="text"
value={descVal}
onChange={(e) => setDescVal(e.target.value)}
placeholder="Предоплата курса, возврат, партнёрка..."
style={{ ...inputStyle, width: "100%" }}
{...focusHandlers}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
/>
</div>
</div>
{error && <p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>{error}</p>}
<button
type="button"
onClick={handleAdd}
disabled={pending}
className="btn-aubade btn-aubade-accent px-4 py-1.5 text-xs"
style={{ opacity: pending ? 0.6 : 1 }}
>
{pending ? "Сохранение..." : "Добавить"}
</button>
</div>
)}
{/* Transaction list */}
{transactions.length > 0 ? (
<div className="space-y-1 max-h-64 overflow-y-auto">
{transactions.map((tx) => (
<div
key={tx.id}
className="flex items-center gap-3 px-3 py-2 text-xs group"
style={{ border: "2px solid var(--border)" }}
>
<span
className="font-bold"
style={{
minWidth: 80,
color: tx.amount > 0 ? "#3A6A3A" : "oklch(0.577 0.245 27.325)",
}}
>
{formatAmount(tx.amount)}
</span>
<span className="flex-1">{tx.description}</span>
<span style={{ color: "var(--muted-foreground)", whiteSpace: "nowrap" }}>
{new Date(tx.createdAt).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</span>
<button
type="button"
onClick={() => handleDelete(tx.id)}
disabled={deletingId === tx.id || pending}
className="opacity-0 group-hover:opacity-100 transition-opacity text-xs"
style={{ color: "oklch(0.577 0.245 27.325)", flexShrink: 0 }}
title="Удалить"
>
</button>
</div>
))}
</div>
) : (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>Операций ещё нет</p>
)}
</div>
);
}
@@ -0,0 +1,185 @@
"use client";
import { useState, useTransition } from "react";
import { updateUserContact } from "@/app/admin/users/[userId]/actions";
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.4rem 0.6rem",
fontSize: "16px",
fontFamily: "inherit",
} as React.CSSProperties;
const focusHandlers = {
onFocus: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--foreground)"),
onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) =>
(e.currentTarget.style.borderColor = "var(--border)"),
};
interface Props {
userId: string;
name: string;
email: string;
phone: string | null;
birthday: Date | null;
comment: string | null;
}
export function UserContactEditor({ userId, name, email, phone, birthday, comment }: Props) {
const [editing, setEditing] = useState(false);
const [nameVal, setNameVal] = useState(name);
const [emailVal, setEmailVal] = useState(email);
const [phoneVal, setPhoneVal] = useState(phone ?? "");
const [birthdayVal, setBirthdayVal] = useState(
birthday ? birthday.toISOString().slice(0, 10) : ""
);
const [commentVal, setCommentVal] = useState(comment ?? "");
const [pending, startTransition] = useTransition();
function handleSave() {
startTransition(async () => {
await updateUserContact(userId, {
name: nameVal,
email: emailVal,
phone: phoneVal,
birthday: birthdayVal,
comment: commentVal,
});
setEditing(false);
});
}
if (!editing) {
return (
<div className="space-y-3">
<div className="flex items-start gap-6 flex-wrap">
<div className="space-y-0.5">
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Телефон
</p>
<p className="text-sm">{phone || "—"}</p>
</div>
<div className="space-y-0.5">
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
День рождения
</p>
<p className="text-sm">
{birthday
? new Date(birthday).toLocaleDateString("ru-RU", { day: "numeric", month: "long", year: "numeric" })
: "—"}
</p>
</div>
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs underline self-end pb-0.5"
style={{ color: "var(--muted-foreground)" }}
>
Изменить
</button>
</div>
{comment && (
<div className="space-y-0.5">
<p className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Комментарий
</p>
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--foreground)" }}>{comment}</p>
</div>
)}
</div>
);
}
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Имя
</label>
<input
type="text"
value={nameVal}
onChange={(e) => setNameVal(e.target.value)}
style={inputStyle}
{...focusHandlers}
/>
</div>
<div className="space-y-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Email
</label>
<input
type="email"
value={emailVal}
onChange={(e) => setEmailVal(e.target.value)}
style={inputStyle}
{...focusHandlers}
/>
</div>
<div className="space-y-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Телефон
</label>
<input
type="tel"
value={phoneVal}
onChange={(e) => setPhoneVal(e.target.value)}
placeholder="+7 900 000-00-00"
style={inputStyle}
{...focusHandlers}
/>
</div>
<div className="space-y-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
День рождения
</label>
<input
type="date"
value={birthdayVal}
onChange={(e) => setBirthdayVal(e.target.value)}
style={inputStyle}
{...focusHandlers}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs uppercase tracking-widest font-bold" style={{ color: "var(--muted-foreground)" }}>
Комментарий
</label>
<textarea
value={commentVal}
onChange={(e) => setCommentVal(e.target.value)}
rows={3}
placeholder="Заметки об этом пользователе..."
style={{ ...inputStyle, resize: "vertical" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleSave}
disabled={pending}
className="btn-aubade btn-aubade-accent px-4 py-1.5 text-xs"
style={{ opacity: pending ? 0.6 : 1 }}
>
{pending ? "Сохранение..." : "Сохранить"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-xs underline"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</div>
</div>
);
}
@@ -1,9 +1,7 @@
"use client"; "use client";
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button"; import { bulkGrantAccess, revokeUserAccess } from "@/lib/actions/user-actions";
import { Input } from "@/components/ui/input";
import { bulkGrantAccess, revokeUserAccess } from "@/app/admin/users/[userId]/actions";
interface Course { interface Course {
id: string; id: string;
@@ -28,7 +26,7 @@ export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props
() => new Map(enrollments.map((e) => [e.courseId, e.expiresAt])) () => new Map(enrollments.map((e) => [e.courseId, e.expiresAt]))
); );
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [expiryDate, setExpiryDate] = useState(""); const [days, setDays] = useState("");
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const unenrolled = allCourses.filter((c) => !enrolledMap.has(c.id)); const unenrolled = allCourses.filter((c) => !enrolledMap.has(c.id));
@@ -44,12 +42,16 @@ export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props
function handleBulkGrant() { function handleBulkGrant() {
if (selected.size === 0) return; if (selected.size === 0) return;
const ids = [...selected]; const ids = [...selected];
const expiry = expiryDate || null; const daysNum = parseInt(days, 10);
const expiresAt = !isNaN(daysNum) && daysNum > 0
? new Date(Date.now() + daysNum * 86_400_000).toISOString()
: null;
const newMap = new Map(enrolledMap); const newMap = new Map(enrolledMap);
ids.forEach((id) => newMap.set(id, expiry ? new Date(expiry) : null)); ids.forEach((id) => newMap.set(id, expiresAt ? new Date(expiresAt) : null));
setEnrolledMap(newMap); setEnrolledMap(newMap);
setSelected(new Set()); setSelected(new Set());
startTransition(() => bulkGrantAccess(userId, ids, expiry)); setDays("");
startTransition(() => bulkGrantAccess(userId, ids, expiresAt));
} }
function handleRevoke(courseId: string) { function handleRevoke(courseId: string) {
@@ -119,9 +121,26 @@ export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div> <div>
<label className="text-xs uppercase tracking-wider block mb-1" style={{ color: "var(--muted-foreground)" }}> <label className="text-xs uppercase tracking-wider block mb-1" style={{ color: "var(--muted-foreground)" }}>
Срок доступа Дней (0 бессрочно)
</label> </label>
<Input type="date" value={expiryDate} onChange={(e) => setExpiryDate(e.target.value)} className="w-44" /> <input
type="number"
min="0"
value={days}
onChange={(e) => setDays(e.target.value)}
placeholder="0"
style={{
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.6rem",
fontSize: "16px",
fontFamily: "inherit",
width: "6rem",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div> </div>
<div className="pt-5"> <div className="pt-5">
<button onClick={handleBulkGrant} disabled={pending} className="btn-aubade btn-aubade-accent"> <button onClick={handleBulkGrant} disabled={pending} className="btn-aubade btn-aubade-accent">
+28 -5
View File
@@ -13,15 +13,24 @@ const inputStyle: React.CSSProperties = {
fontFamily: "inherit", fontFamily: "inherit",
}; };
export function UsersSearch({ initialSearch, initialRole }: { initialSearch: string; initialRole: string }) { export function UsersSearch({
initialSearch,
initialRole,
initialBalance,
}: {
initialSearch: string;
initialRole: string;
initialBalance: string;
}) {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const [, startTransition] = useTransition(); const [, startTransition] = useTransition();
function update(search: string, role: string) { function update(search: string, role: string, balance: string) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (search) params.set("search", search);
if (role) params.set("role", role); if (role) params.set("role", role);
if (balance) params.set("balance", balance);
startTransition(() => router.push(`${pathname}?${params.toString()}`)); startTransition(() => router.push(`${pathname}?${params.toString()}`));
} }
@@ -36,7 +45,7 @@ export function UsersSearch({ initialSearch, initialRole }: { initialSearch: str
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => { onBlur={(e) => {
e.currentTarget.style.borderColor = "var(--border)"; e.currentTarget.style.borderColor = "var(--border)";
update(e.currentTarget.value.trim(), initialRole); update(e.currentTarget.value.trim(), initialRole, initialBalance);
}} }}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/> />
@@ -44,7 +53,7 @@ export function UsersSearch({ initialSearch, initialRole }: { initialSearch: str
<select <select
defaultValue={initialRole} defaultValue={initialRole}
onChange={(e) => update(initialSearch, e.target.value)} onChange={(e) => update(initialSearch, e.target.value, initialBalance)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }} style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")} onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
@@ -55,7 +64,21 @@ export function UsersSearch({ initialSearch, initialRole }: { initialSearch: str
<option value="admin">Администраторы</option> <option value="admin">Администраторы</option>
</select> </select>
{(initialSearch || initialRole) && ( <button
type="button"
onClick={() => update(initialSearch, initialRole, initialBalance === "nonzero" ? "" : "nonzero")}
className="text-xs px-3"
style={{
border: "2px solid var(--border)",
background: initialBalance === "nonzero" ? "var(--foreground)" : "transparent",
color: initialBalance === "nonzero" ? "var(--background)" : "var(--muted-foreground)",
cursor: "pointer",
}}
>
С балансом
</button>
{(initialSearch || initialRole || initialBalance) && (
<button <button
type="button" type="button"
onClick={() => startTransition(() => router.push(pathname))} onClick={() => startTransition(() => router.push(pathname))}
+53 -13
View File
@@ -90,6 +90,43 @@ function UserPopup({ user }: { user: UserRow }) {
); );
} }
function ImpersonateButton({ userId }: { userId: string }) {
const [loading, setLoading] = useState(false);
async function handleImpersonate() {
setLoading(true);
try {
const res = await fetch("/api/auth/admin/impersonate-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId }),
credentials: "include",
});
if (!res.ok) throw new Error(await res.text());
window.location.href = "/dashboard";
} catch (e) {
console.error("Impersonation failed:", e);
setLoading(false);
}
}
return (
<button
type="button"
onClick={handleImpersonate}
disabled={loading}
className="text-xs px-2 py-1 transition-colors"
style={{
border: "1px solid var(--border)",
color: loading ? "var(--muted-foreground)" : "var(--foreground)",
background: "transparent",
}}
>
{loading ? "..." : "Войти как"}
</button>
);
}
export function UsersTable({ users }: { users: UserRow[] }) { export function UsersTable({ users }: { users: UserRow[] }) {
const [hoveredId, setHoveredId] = useState<string | null>(null); const [hoveredId, setHoveredId] = useState<string | null>(null);
@@ -134,21 +171,24 @@ export function UsersTable({ users }: { users: UserRow[] }) {
<td className="px-5 py-3 text-sm text-slate-400"> <td className="px-5 py-3 text-sm text-slate-400">
{new Date(user.createdAt).toLocaleDateString("ru-RU")} {new Date(user.createdAt).toLocaleDateString("ru-RU")}
</td> </td>
{/* Hover popup trigger */} {/* Actions */}
<td className="px-3 py-3 relative"> <td className="px-3 py-3 relative">
<div <div className="flex items-center gap-2">
className="relative inline-block" {user.role !== "admin" && <ImpersonateButton userId={user.id} />}
onMouseEnter={() => setHoveredId(user.id)} <div
onMouseLeave={() => setHoveredId(null)} className="relative inline-block"
> onMouseEnter={() => setHoveredId(user.id)}
<button onMouseLeave={() => setHoveredId(null)}
type="button"
className="text-xs px-2 py-1"
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
> >
··· <button
</button> type="button"
{hoveredId === user.id && <UserPopup user={user} />} className="text-xs px-2 py-1"
style={{ border: "1px solid var(--border)", color: "var(--muted-foreground)" }}
>
···
</button>
{hoveredId === user.id && <UserPopup user={user} />}
</div>
</div> </div>
</td> </td>
</tr> </tr>

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