Compare commits

...

25 Commits

Author SHA1 Message Date
admins 3b57b14d9b Add file attachments to questions (new question form + admin reply)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:26:17 +05:00
admins 367764b71e Expand /questions/new form: wider container, larger inputs, hint texts
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 18:10:33 +05:00
admins acf7ee49aa Replace single-line reply input with resizable textarea in QuestionSplitView 2026-05-19 18:01:27 +05:00
admins 751c012f3d Add /admin/comments page with delete and pagination
- Add admin auth guard (redirect to /dashboard if not admin)
- Add delete-action.ts with deleteComment(commentId) soft-delete action
- Fix pagination label to show "Страница X из Y · Всего: N"
- Existing actions.ts, comments-table.tsx, and admin-nav entry preserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:31:38 +05:00
admins 7084806aac Add search, filters, pagination to homework list
- Replace client HomeworkFilters component with server-side <form method="GET">
- Switch search param from `search` to `q`, add lesson title to search scope
- Change status filter from DB status field to feedback-count logic (pending=no feedbacks, reviewed=has feedbacks)
- Update pagination label to "Страница X из Y · Всего: N"
- Preserve all existing submission links and layout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:29:47 +05:00
admins b2fa98051f Add quick enroll button to admin users table
Adds a "+ Доступ" button to each user row in the admin users table.
Clicking it opens a centered modal with a course dropdown, optional
expiry date, and a Server Action that upserts CourseEnrollment, logs
to AccessLog, and sends sendCourseAccessEmail on new enrollments.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:27:57 +05:00
admins 4f5b5c535a Add search, filters, pagination to admin users table
- Add emailVerified filter (true/false/any) to UsersSearch component
- Wire emailVerified param through page searchParams, where clause, and pageUrl helper
- Preserve emailVerified in pagination links alongside existing search/role/balance params
- Update pagination label to "Страница X из Y · Всего: N"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 14:25:53 +05:00
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
52 changed files with 4462 additions and 69 deletions
+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.
@@ -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
@@ -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");
+48
View File
@@ -37,6 +37,9 @@ model User {
accessLogs AccessLog[] @relation("AccessLogUser") accessLogs AccessLog[] @relation("AccessLogUser")
adminAccessLogs AccessLog[] @relation("AccessLogAdmin") adminAccessLogs AccessLog[] @relation("AccessLogAdmin")
balanceTransactions BalanceTransaction[] balanceTransactions BalanceTransaction[]
questions StudentQuestion[]
closedQuestions StudentQuestion[] @relation("QuestionClosedBy")
questionMessages StudentQuestionMessage[]
} }
model Session { model Session {
@@ -111,6 +114,7 @@ model Course {
modules Module[] modules Module[]
enrollments CourseEnrollment[] enrollments CourseEnrollment[]
accessLogs AccessLog[] accessLogs AccessLog[]
questions StudentQuestion[]
} }
model Module { model Module {
@@ -312,6 +316,50 @@ model LessonComment {
replies LessonComment[] @relation("CommentReplies") 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 // Balance
// ───────────────────────────────────────────── // ─────────────────────────────────────────────
+1 -1
View File
@@ -10,7 +10,7 @@ export default async function ForgotPasswordPage() {
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName} {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>
+1 -1
View File
@@ -18,7 +18,7 @@ export default async function LoginPage({
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName} {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>
+1 -1
View File
@@ -16,7 +16,7 @@ export default async function RegisterPage() {
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{settings.schoolName} {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>
+1 -1
View File
@@ -26,7 +26,7 @@ export function RegisterForm({ showTermsCheckbox, privacyPolicyUrl, termsUrl, of
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;
+1 -1
View File
@@ -11,7 +11,7 @@ export default async function ResetPasswordPage() {
<h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}> <h1 className="text-2xl font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
{schoolName} {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>
@@ -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;
@@ -40,6 +40,27 @@ export async function submitHomework(
data: { text, files: files as object[], audioUrl: audioUrl ?? null, 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[], audioUrl: audioUrl ?? null }, data: { homeworkId, userId: session.user.id, text, files: files as object[], audioUrl: audioUrl ?? null },
+3
View File
@@ -44,6 +44,9 @@ export default async function StudentLayout({ children }: { children: React.Reac
{schoolName} {schoolName}
</Link> </Link>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link href="/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)" }}> <Link href="/profile" className="text-sm hover:underline" style={{ color: "var(--muted-foreground)" }}>
{session.user.name} {session.user.name}
</Link> </Link>
+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>
);
}
+220
View File
@@ -0,0 +1,220 @@
"use client";
import { useState, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export default function NewQuestionPage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [text, setText] = useState("");
const [files, setFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
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, files }),
});
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-2xl mx-auto px-6 py-10">
<div className="mb-8">
<Link
href="/questions"
className="text-sm"
style={{ color: "var(--muted-foreground)", textDecoration: "underline" }}
>
Все вопросы
</Link>
</div>
<h1 className="text-2xl font-bold mb-2" style={{ color: "var(--foreground)" }}>
Новый вопрос
</h1>
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
Опишите свой вопрос подробно куратор ответит в ближайшее время.
</p>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div>
<label
className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }}
>
Тема вопроса
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Кратко опишите суть вопроса"
required
className="w-full text-sm px-4 py-3 outline-none"
style={{
border: "2px solid var(--border)",
background: "var(--color-surface)",
color: "var(--foreground)",
}}
/>
<p className="text-xs mt-1.5" style={{ color: "var(--muted-foreground)" }}>
Например: «Не получается настроить плагин» или «Вопрос по уроку 3»
</p>
</div>
<div>
<label
className="block text-sm font-bold mb-2"
style={{ color: "var(--foreground)" }}
>
Описание
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Подробно опишите вопрос или проблему. Что именно не получается? Что уже пробовали? Прикрепите скриншот или ссылку, если нужно."
required
rows={10}
className="w-full text-sm px-4 py-3 outline-none resize-y"
style={{
border: "2px solid var(--border)",
background: "var(--color-surface)",
color: "var(--foreground)",
minHeight: "200px",
}}
/>
</div>
{/* File attachments */}
<div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
{files.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
📎 <span>{f.name}</span>
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-xs px-3 py-2"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить файл"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf, md · до 10 МБ
</span>
</div>
</div>
{error && (
<p className="text-sm" style={{ color: "var(--destructive)" }}>
{error}
</p>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={loading || uploading || !title.trim() || !text.trim()}
className="text-sm font-bold px-8 py-3"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: loading || uploading || !title.trim() || !text.trim() ? 0.5 : 1,
}}
>
{loading ? "Отправка..." : "Отправить →"}
</button>
</div>
</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>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
export async function deleteComment(commentId: string): Promise<{ ok: boolean }> {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Нет доступа");
await prisma.lessonComment.update({
where: { id: commentId },
data: { deleted: true },
});
revalidatePath("/admin/comments");
return { ok: true };
}
+7 -1
View File
@@ -2,6 +2,9 @@ import { prisma } from "@/lib/prisma";
import Link from "next/link"; import Link from "next/link";
import { Suspense } from "react"; import { Suspense } from "react";
import { CommentsTable } from "@/components/admin/comments-table"; import { CommentsTable } from "@/components/admin/comments-table";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export const metadata = { title: "Комментарии" }; export const metadata = { title: "Комментарии" };
@@ -12,6 +15,9 @@ interface Props {
} }
export default async function AdminCommentsPage({ searchParams }: Props) { export default async function AdminCommentsPage({ searchParams }: Props) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") redirect("/dashboard");
const { page = "1", search = "" } = await searchParams; const { page = "1", search = "" } = 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;
@@ -106,7 +112,7 @@ export default async function AdminCommentsPage({ searchParams }: Props) {
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link> <Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)} )}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span> <span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
</div> </div>
)} )}
</div> </div>
+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} />;
}
+75
View File
@@ -0,0 +1,75 @@
"use server";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { sendCourseAccessEmail } from "@/lib/email";
import { revalidatePath } from "next/cache";
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || session.user.role !== "admin") throw new Error("Forbidden");
return session;
}
export async function grantCourseAccess(
userId: string,
courseId: string,
expiresAt: Date | null
): Promise<{ ok: true } | { error: string }> {
try {
const session = await requireAdmin();
const [user, course] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.course.findUnique({ where: { id: courseId }, select: { title: true } }),
]);
if (!user) return { error: "Пользователь не найден" };
if (!course) return { error: "Курс не найден" };
const existing = await prisma.courseEnrollment.findUnique({
where: { userId_courseId: { userId, courseId } },
});
await prisma.courseEnrollment.upsert({
where: { userId_courseId: { userId, courseId } },
update: { expiresAt },
create: { userId, courseId, expiresAt },
});
await prisma.accessLog.create({
data: {
courseId,
userId,
action: "granted",
method: "quick",
grantedById: session.user.id,
},
});
// Send email only on new enrollment (not on update)
if (!existing) {
await sendCourseAccessEmail(user.email, user.name ?? user.email, course.title).catch(
(e) => console.error("[enroll-action] sendCourseAccessEmail:", e)
);
}
revalidatePath("/admin/users");
revalidatePath(`/admin/users/${userId}`);
return { ok: true };
} catch (e) {
console.error("[enroll-action] grantCourseAccess:", e);
return { error: "Произошла ошибка. Попробуйте ещё раз." };
}
}
export async function getPublishedCourses(): Promise<{ id: string; title: string }[]> {
await requireAdmin();
return prisma.course.findMany({
where: { published: true },
select: { id: true, title: true },
orderBy: { title: "asc" },
});
}
+7 -4
View File
@@ -8,11 +8,11 @@ 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; balance?: string }>; searchParams: Promise<{ search?: string; role?: string; page?: string; balance?: string; emailVerified?: string }>;
} }
export default async function UsersPage({ searchParams }: Props) { export default async function UsersPage({ searchParams }: Props) {
const { search = "", role = "", page = "1", balance = "" } = await searchParams; const { search = "", role = "", page = "1", balance = "", emailVerified = "" } = 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;
@@ -37,6 +37,8 @@ export default async function UsersPage({ searchParams }: Props) {
} }
: {}), : {}),
...(role ? { role } : {}), ...(role ? { role } : {}),
...(emailVerified === "true" ? { emailVerified: true } : {}),
...(emailVerified === "false" ? { emailVerified: false } : {}),
...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}), ...(balanceUserIds !== null ? { id: { in: balanceUserIds } } : {}),
}; };
@@ -78,6 +80,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 (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance); 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()}`;
@@ -101,7 +104,7 @@ export default async function UsersPage({ searchParams }: Props) {
{/* Filters */} {/* Filters */}
<Suspense> <Suspense>
<UsersSearch initialSearch={search} initialRole={role} initialBalance={balance} /> <UsersSearch initialSearch={search} initialRole={role} initialEmailVerified={emailVerified} initialBalance={balance} />
</Suspense> </Suspense>
<UsersTable users={tableUsers} /> <UsersTable users={tableUsers} />
@@ -132,7 +135,7 @@ export default async function UsersPage({ searchParams }: Props) {
{currentPage < totalPages && ( {currentPage < totalPages && (
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link> <Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)} )}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>стр. {currentPage} из {totalPages}</span> <span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>Страница {currentPage} из {totalPages} · Всего: {total}</span>
</div> </div>
)} )}
</div> </div>
+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);
}
+123
View File
@@ -0,0 +1,123 @@
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";
interface FileAttachment {
name: string;
url: string;
size: number;
}
function buildS3Prefix(): string {
const endpoint = process.env.S3_ENDPOINT ?? "";
const bucket = process.env.S3_BUCKET ?? "";
return `${endpoint}/${bucket}/`;
}
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, files } = body as {
title: string;
text: string;
courseId?: string;
files?: FileAttachment[];
};
if (!title?.trim() || !text?.trim()) {
return NextResponse.json({ error: "title and text are 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 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(),
files: safeFiles?.length ? (safeFiles as object[]) : undefined,
},
},
},
});
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 });
}
@@ -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 });
}
@@ -92,7 +92,7 @@ export function FeedbackForm({
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",
+82 -31
View File
@@ -1,13 +1,12 @@
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import Link from "next/link"; import Link from "next/link";
import { HomeworkFilters } from "@/components/admin/homework-filters";
import { Suspense } from "react"; import { Suspense } from "react";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
interface Props { interface Props {
searchParams: Promise<{ searchParams: Promise<{
search?: string; q?: string;
status?: string; status?: string;
courseId?: string; courseId?: string;
page?: string; page?: string;
@@ -15,20 +14,22 @@ interface Props {
} }
export default async function HomeworkListPage({ searchParams }: Props) { export default async function HomeworkListPage({ searchParams }: Props) {
const { search = "", status = "", courseId = "", page = "1" } = await searchParams; const sp = await searchParams;
const currentPage = Math.max(1, parseInt(page) || 1); const q = sp.q ?? "";
const status = sp.status ?? "";
const courseId = sp.courseId ?? "";
const currentPage = Math.max(1, parseInt(sp.page ?? "1") || 1);
const skip = (currentPage - 1) * PAGE_SIZE; const skip = (currentPage - 1) * PAGE_SIZE;
// Build where clause // Build where clause
const where = { const where = {
...(search ...(q
? { ? {
user: { OR: [
OR: [ { user: { name: { contains: q, mode: "insensitive" as const } } },
{ name: { contains: search, mode: "insensitive" as const } }, { user: { email: { contains: q, mode: "insensitive" as const } } },
{ email: { contains: search, mode: "insensitive" as const } }, { homework: { lesson: { title: { contains: q, mode: "insensitive" as const } } } },
], ],
},
} }
: {}), : {}),
...(courseId ...(courseId
@@ -38,10 +39,8 @@ export default async function HomeworkListPage({ searchParams }: Props) {
}, },
} }
: {}), : {}),
...(status === "pending" ? { status: "PENDING" } : {}), ...(status === "pending" ? { feedbacks: { none: {} } } : {}),
...(status === "reviewing" ? { status: "REVIEWING" } : {}), ...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
...(status === "approved" ? { status: "APPROVED" } : {}),
...(status === "rejected" ? { status: "REJECTED" } : {}),
}; };
const [submissions, total, courses] = await Promise.all([ const [submissions, total, courses] = await Promise.all([
@@ -71,17 +70,25 @@ 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.status === "PENDING").length;
function pageUrl(p: number) { function pageUrl(p: number) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (search) params.set("search", search); if (q) params.set("q", q);
if (status) params.set("status", status); if (status) params.set("status", status);
if (courseId) params.set("courseId", courseId); if (courseId) params.set("courseId", courseId);
params.set("page", String(p)); params.set("page", String(p));
return `/curator/homework?${params.toString()}`; return `/curator/homework?${params.toString()}`;
} }
const inputStyle: React.CSSProperties = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
padding: "0.4rem 0.75rem",
fontSize: "0.8rem",
fontFamily: "inherit",
color: "var(--foreground)",
};
return ( return (
<div className="p-8 max-w-3xl"> <div className="p-8 max-w-3xl">
<div className="mb-6"> <div className="mb-6">
@@ -92,12 +99,59 @@ export default async function HomeworkListPage({ searchParams }: Props) {
Домашние задания Домашние задания
</h1> </h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}> <p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{total} {total === 1 ? "работа" : "работ"} · {pendingCount} ожидают проверки {total} {total === 1 ? "работа" : "работ"}
</p> </p>
</div> </div>
{/* Filters */}
<Suspense> <Suspense>
<HomeworkFilters courses={courses} /> <form method="GET" className="flex flex-wrap gap-2 mb-5">
<input
name="q"
defaultValue={q}
placeholder="Поиск по ученику или уроку"
style={{ ...inputStyle, width: 260 }}
/>
<select
name="courseId"
defaultValue={courseId}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer", maxWidth: 220 }}
>
<option value="">Все курсы</option>
{courses.map((c) => (
<option key={c.id} value={c.id}>{c.title}</option>
))}
</select>
<select
name="status"
defaultValue={status}
style={{ ...inputStyle, appearance: "none", paddingRight: "1.5rem", cursor: "pointer" }}
>
<option value="">Все</option>
<option value="pending">Без ответа</option>
<option value="reviewed">С отзывом</option>
</select>
<button
type="submit"
className="px-3 py-1 text-xs font-medium"
style={{ border: "2px solid var(--foreground)", background: "var(--foreground)", color: "var(--background)", cursor: "pointer" }}
>
Найти
</button>
{(q || status || courseId) && (
<Link
href="/curator/homework"
className="px-3 py-1 text-xs"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
Сбросить
</Link>
)}
</form>
</Suspense> </Suspense>
{submissions.length === 0 ? ( {submissions.length === 0 ? (
@@ -111,19 +165,16 @@ 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 statusMap: Record<string, { label: string; bg: string; color: string; border: string }> = { const hasReview = s.feedbacks.length > 0;
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)", border: "var(--foreground)" }, const reviewBadge = hasReview
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)", border: "oklch(0.75 0.1 80)" }, ? { label: "С отзывом", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" }
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)", border: "oklch(0.7 0.15 145)" }, : { label: "Без ответа", bg: "var(--foreground)", color: "var(--background)" };
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={{ border: `2px solid ${st.border}`, display: "flex" }} style={{ border: "2px solid 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>
@@ -137,9 +188,9 @@ export default async function HomeworkListPage({ searchParams }: Props) {
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<span <span
className="text-xs px-2 py-0.5 font-medium" className="text-xs px-2 py-0.5 font-medium"
style={{ background: st.bg, color: st.color }} style={{ background: reviewBadge.bg, color: reviewBadge.color }}
> >
{st.label} {reviewBadge.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")}
@@ -186,7 +237,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
<Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link> <Link href={pageUrl(currentPage + 1)} className="btn-aubade px-3 py-1 text-xs"></Link>
)} )}
<span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}> <span className="ml-2 text-xs" style={{ color: "var(--muted-foreground)" }}>
стр. {currentPage} из {totalPages} Страница {currentPage} из {totalPages} · Всего: {total}
</span> </span>
</div> </div>
)} )}
+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} />;
}
+15 -3
View File
@@ -31,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 ──────────────────────────────── */
@@ -84,6 +95,7 @@
body { body {
background-color: var(--background); background-color: var(--background);
color: var(--foreground); color: var(--foreground);
font-size: 18px;
} }
} }
@@ -105,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);
@@ -134,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;
@@ -167,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;
+20 -3
View File
@@ -9,13 +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/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 (
@@ -24,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}
@@ -40,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)" }}>
+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
@@ -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",
+1 -1
View File
@@ -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;
+252
View File
@@ -0,0 +1,252 @@
"use client";
import { useState, useTransition, useEffect, useRef } from "react";
import { grantCourseAccess, getPublishedCourses } from "@/app/admin/users/enroll-action";
interface Course {
id: string;
title: string;
}
interface Props {
userId: string;
userName: string;
}
export function QuickEnrollButton({ userId, userName }: Props) {
const [open, setOpen] = useState(false);
const [courses, setCourses] = useState<Course[]>([]);
const [loadingCourses, setLoadingCourses] = useState(false);
const [courseId, setCourseId] = useState("");
const [expiresAt, setExpiresAt] = useState("");
const [status, setStatus] = useState<"idle" | "success" | "error">("idle");
const [errorMsg, setErrorMsg] = useState("");
const [pending, startTransition] = useTransition();
const dialogRef = useRef<HTMLDivElement>(null);
// Load courses when modal opens
useEffect(() => {
if (!open) return;
setLoadingCourses(true);
getPublishedCourses()
.then((data) => {
setCourses(data);
if (data.length > 0) setCourseId(data[0].id);
})
.finally(() => setLoadingCourses(false));
}, [open]);
// Close on Escape
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") handleClose();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open]);
function handleOpen() {
setStatus("idle");
setErrorMsg("");
setExpiresAt("");
setCourseId("");
setOpen(true);
}
function handleClose() {
if (pending) return;
setOpen(false);
}
function handleSubmit() {
if (!courseId) return;
const expiry = expiresAt ? new Date(expiresAt) : null;
startTransition(async () => {
const result = await grantCourseAccess(userId, courseId, expiry);
if ("error" in result) {
setStatus("error");
setErrorMsg(result.error);
} else {
setStatus("success");
setTimeout(() => setOpen(false), 1200);
}
});
}
return (
<>
{/* Trigger button */}
<button
type="button"
onClick={handleOpen}
className="text-xs px-2 py-1 transition-colors"
style={{
border: "1px solid var(--border)",
color: "var(--foreground)",
background: "transparent",
fontWeight: 600,
whiteSpace: "nowrap",
}}
title={`Выдать доступ к курсу — ${userName}`}
>
+ Доступ
</button>
{/* Modal overlay */}
{open && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
style={{ background: "rgba(50,50,50,0.45)" }}
onClick={(e) => {
if (e.target === e.currentTarget) handleClose();
}}
>
<div
ref={dialogRef}
className="w-full max-w-sm p-6 space-y-4"
style={{
background: "var(--background)",
border: "2px solid var(--foreground)",
boxShadow: "6px 6px 0 0 var(--foreground)",
}}
>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Выдать доступ
</p>
<p className="text-sm font-semibold mt-0.5" style={{ color: "var(--foreground)" }}>
{userName}
</p>
</div>
<button
type="button"
onClick={handleClose}
disabled={pending}
className="text-xs leading-none"
style={{ color: "var(--muted-foreground)", fontSize: "18px", lineHeight: 1 }}
>
×
</button>
</div>
{/* Course select */}
<div>
<label
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
style={{ color: "var(--muted-foreground)" }}
>
Курс
</label>
{loadingCourses ? (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка
</p>
) : courses.length === 0 ? (
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Нет опубликованных курсов
</p>
) : (
<select
value={courseId}
onChange={(e) => setCourseId(e.target.value)}
disabled={pending}
style={{
width: "100%",
border: "2px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
padding: "0.4rem 0.5rem",
fontSize: "13px",
fontFamily: "inherit",
outline: "none",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
{courses.map((c) => (
<option key={c.id} value={c.id}>
{c.title}
</option>
))}
</select>
)}
</div>
{/* Expiry date (optional) */}
<div>
<label
className="block text-xs font-bold uppercase tracking-widest mb-1.5"
style={{ color: "var(--muted-foreground)" }}
>
Срок доступа (необязательно)
</label>
<input
type="date"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
disabled={pending}
min={new Date().toISOString().slice(0, 10)}
style={{
width: "100%",
border: "2px solid var(--border)",
background: "var(--background)",
color: expiresAt ? "var(--foreground)" : "var(--muted-foreground)",
padding: "0.4rem 0.5rem",
fontSize: "13px",
fontFamily: "inherit",
outline: "none",
}}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
Оставьте пустым для бессрочного доступа
</p>
</div>
{/* Error message */}
{status === "error" && (
<p className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
{errorMsg}
</p>
)}
{/* Success message */}
{status === "success" && (
<p className="text-xs font-semibold" style={{ color: "#3A6A3A" }}>
Доступ выдан
</p>
)}
{/* Actions */}
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={handleSubmit}
disabled={pending || loadingCourses || !courseId || status === "success"}
className="btn-aubade btn-aubade-accent text-xs px-4 py-2"
>
{pending ? "Выдаю…" : "Выдать"}
</button>
<button
type="button"
onClick={handleClose}
disabled={pending}
className="btn-aubade text-xs px-4 py-2"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</div>
</div>
</div>
)}
</>
);
}
+1 -1
View File
@@ -36,7 +36,7 @@ export function QuizEditor({ 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",
}; };
+1 -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;
+1 -1
View File
@@ -20,7 +20,7 @@ const inputStyle = {
background: "var(--background)", background: "var(--background)",
outline: "none", outline: "none",
padding: "0.4rem 0.6rem", padding: "0.4rem 0.6rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
} as React.CSSProperties; } as React.CSSProperties;
+1 -1
View File
@@ -9,7 +9,7 @@ const inputStyle = {
outline: "none", outline: "none",
width: "100%", width: "100%",
padding: "0.4rem 0.6rem", padding: "0.4rem 0.6rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
} as React.CSSProperties; } as React.CSSProperties;
@@ -134,7 +134,7 @@ export function UserEnrollmentManager({ userId, allCourses, enrollments }: Props
background: "var(--background)", background: "var(--background)",
outline: "none", outline: "none",
padding: "0.4rem 0.6rem", padding: "0.4rem 0.6rem",
fontSize: "0.875rem", fontSize: "16px",
fontFamily: "inherit", fontFamily: "inherit",
width: "6rem", width: "6rem",
}} }}
+20 -5
View File
@@ -16,20 +16,23 @@ const inputStyle: React.CSSProperties = {
export function UsersSearch({ export function UsersSearch({
initialSearch, initialSearch,
initialRole, initialRole,
initialEmailVerified,
initialBalance, initialBalance,
}: { }: {
initialSearch: string; initialSearch: string;
initialRole: string; initialRole: string;
initialEmailVerified: string;
initialBalance: 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, balance: string) { function update(search: string, role: string, emailVerified: 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 (emailVerified) params.set("emailVerified", emailVerified);
if (balance) params.set("balance", balance); if (balance) params.set("balance", balance);
startTransition(() => router.push(`${pathname}?${params.toString()}`)); startTransition(() => router.push(`${pathname}?${params.toString()}`));
} }
@@ -45,7 +48,7 @@ export function UsersSearch({
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, initialBalance); update(e.currentTarget.value.trim(), initialRole, initialEmailVerified, initialBalance);
}} }}
onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }} onKeyDown={(e) => { if (e.key === "Enter") e.currentTarget.blur(); }}
/> />
@@ -53,7 +56,7 @@ export function UsersSearch({
<select <select
defaultValue={initialRole} defaultValue={initialRole}
onChange={(e) => update(initialSearch, e.target.value, initialBalance)} onChange={(e) => update(initialSearch, e.target.value, initialEmailVerified, 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)")}
@@ -64,9 +67,21 @@ export function UsersSearch({
<option value="admin">Администраторы</option> <option value="admin">Администраторы</option>
</select> </select>
<select
defaultValue={initialEmailVerified}
onChange={(e) => update(initialSearch, initialRole, e.target.value, initialBalance)}
style={{ ...inputStyle, appearance: "none", cursor: "pointer" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Любой статус</option>
<option value="true">Email подтверждён</option>
<option value="false">Не подтверждён</option>
</select>
<button <button
type="button" type="button"
onClick={() => update(initialSearch, initialRole, initialBalance === "nonzero" ? "" : "nonzero")} onClick={() => update(initialSearch, initialRole, initialEmailVerified, initialBalance === "nonzero" ? "" : "nonzero")}
className="text-xs px-3" className="text-xs px-3"
style={{ style={{
border: "2px solid var(--border)", border: "2px solid var(--border)",
@@ -78,7 +93,7 @@ export function UsersSearch({
С балансом С балансом
</button> </button>
{(initialSearch || initialRole || initialBalance) && ( {(initialSearch || initialRole || initialEmailVerified || initialBalance) && (
<button <button
type="button" type="button"
onClick={() => startTransition(() => router.push(pathname))} onClick={() => startTransition(() => router.push(pathname))}
+2
View File
@@ -3,6 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { QuickEnrollButton } from "@/components/admin/quick-enroll-modal";
type Enrollment = { type Enrollment = {
courseId: string; courseId: string;
@@ -175,6 +176,7 @@ export function UsersTable({ users }: { users: UserRow[] }) {
<td className="px-3 py-3 relative"> <td className="px-3 py-3 relative">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user.role !== "admin" && <ImpersonateButton userId={user.id} />} {user.role !== "admin" && <ImpersonateButton userId={user.id} />}
<QuickEnrollButton userId={user.id} userName={user.name ?? user.email} />
<div <div
className="relative inline-block" className="relative inline-block"
onMouseEnter={() => setHoveredId(user.id)} onMouseEnter={() => setHoveredId(user.id)}
@@ -0,0 +1,454 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
interface FileAttachment {
name: string;
url: string;
size: number;
}
interface Message {
id: string;
authorId: string;
text: string;
files: FileAttachment[] | null;
isRead: boolean;
createdAt: string;
author: { id: string; name: string; role: string };
}
interface QuestionSummary {
id: string;
title: string;
status: "OPEN" | "CLOSED";
unreadCount: number;
updatedAt: string;
user: { id: string; name: string };
course: { id: string; title: string } | null;
}
interface QuestionDetail {
id: string;
title: string;
status: "OPEN" | "CLOSED";
createdAt: string;
user: { id: string; name: string };
messages: Message[];
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString("ru", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export function QuestionSplitView({ currentUserId }: { currentUserId: string }) {
const [questions, setQuestions] = useState<QuestionSummary[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<QuestionDetail | null>(null);
const [tab, setTab] = useState<"open" | "closed">("open");
const [replyText, setReplyText] = useState("");
const [replyFiles, setReplyFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false);
const [closing, setClosing] = useState(false);
const [error, setError] = useState("");
const [listError, setListError] = useState("");
const [listLoading, setListLoading] = useState(true);
const fileInputRef = useRef<HTMLInputElement>(null);
const fetchList = useCallback(async () => {
setListError("");
setListLoading(true);
try {
const res = await fetch("/api/questions");
if (!res.ok) {
setListError("Не удалось загрузить вопросы");
return;
}
const data = await res.json();
setQuestions(data);
} catch {
setListError("Не удалось загрузить вопросы");
} finally {
setListLoading(false);
}
}, []);
useEffect(() => {
fetchList();
}, [fetchList]);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setReplyFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function selectQuestion(id: string) {
setSelectedId(id);
setReplyText("");
setReplyFiles([]);
setError("");
const res = await fetch(`/api/questions/${id}`);
if (res.ok) {
const data = await res.json();
setDetail({
...data,
messages: data.messages.map((m: Message & { createdAt: string }) => ({
...m,
files: m.files as FileAttachment[] | null,
})),
});
fetchList();
}
}
async function handleReply() {
if (uploading) return;
if (!replyText.trim() && replyFiles.length === 0) return;
if (!selectedId) return;
setSending(true);
setError("");
try {
const res = await fetch(`/api/questions/${selectedId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: replyText.trim(), files: replyFiles }),
});
if (!res.ok) throw new Error("Ошибка отправки");
const msg = await res.json();
setDetail((prev) =>
prev ? { ...prev, messages: [...prev.messages, msg] } : null
);
setReplyText("");
setReplyFiles([]);
fetchList();
} catch {
setError("Не удалось отправить ответ");
} finally {
setSending(false);
}
}
async function handleClose() {
if (!selectedId) return;
setClosing(true);
try {
const res = await fetch(`/api/questions/${selectedId}/close`, {
method: "PATCH",
});
if (!res.ok) throw new Error("Ошибка закрытия");
setDetail((prev) => (prev ? { ...prev, status: "CLOSED" } : null));
fetchList();
} catch {
setError("Не удалось закрыть вопрос");
} finally {
setClosing(false);
}
}
const filtered = questions.filter((q) =>
tab === "open" ? q.status === "OPEN" : q.status === "CLOSED"
);
return (
<div className="flex h-[calc(100vh-56px)]">
{/* Left panel */}
<div
className="flex flex-col"
style={{ width: "45%", borderRight: "2px solid var(--border)" }}
>
{/* Tabs */}
<div className="flex p-2 gap-1" style={{ borderBottom: "1px solid var(--border)" }}>
{(["open", "closed"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className="text-xs px-3 py-1 rounded-sm font-medium"
style={
tab === t
? { background: "var(--foreground)", color: "var(--background)" }
: { background: "var(--muted)", color: "var(--muted-foreground)" }
}
>
{t === "open"
? `Открытые ${questions.filter((q) => q.status === "OPEN").length}`
: "Закрытые"}
</button>
))}
</div>
{/* Question list */}
<div className="flex-1 overflow-y-auto">
{listError && (
<p className="text-xs p-4" style={{ color: "var(--destructive)" }}>
{listError}
</p>
)}
{listLoading && (
<p className="text-xs p-4" style={{ color: "var(--muted-foreground)" }}>
Загрузка...
</p>
)}
{!listLoading && filtered.length === 0 && (
<p className="text-xs p-4" style={{ color: "var(--muted-foreground)" }}>
Нет вопросов
</p>
)}
{filtered.map((q) => (
<button
key={q.id}
onClick={() => selectQuestion(q.id)}
className="w-full text-left p-3 block"
style={{
borderLeft:
selectedId === q.id
? "3px solid var(--foreground)"
: "3px solid transparent",
borderBottom: "1px solid var(--border)",
background:
q.unreadCount > 0
? "#E8F0D8"
: selectedId === q.id
? "var(--muted)"
: "transparent",
}}
>
<p className="text-sm font-bold truncate" style={{ color: "var(--foreground)" }}>
{q.user.name}
</p>
<p className="text-xs truncate" style={{ color: "var(--muted-foreground)" }}>
{q.title}
</p>
<p className="text-xs mt-0.5" style={{ color: "#999" }}>
{formatDate(q.updatedAt)}
{q.unreadCount > 0 && (
<span style={{ color: "#c00", marginLeft: 4 }}>🔴 новое</span>
)}
</p>
</button>
))}
</div>
</div>
{/* Right panel */}
<div className="flex flex-col overflow-hidden" style={{ width: "55%" }}>
{!detail ? (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm" style={{ color: "var(--muted-foreground)" }}>
Выберите вопрос
</p>
</div>
) : (
<>
{/* Thread header */}
<div
className="flex items-start justify-between p-4 shrink-0"
style={{ borderBottom: "1px solid var(--border)" }}
>
<div>
<p className="text-sm font-bold" style={{ color: "var(--foreground)" }}>
{detail.title}
</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{detail.user.name} ·{" "}
{new Date(detail.createdAt).toLocaleDateString("ru", {
day: "numeric",
month: "long",
})}
</p>
</div>
{detail.status === "OPEN" && (
<button
onClick={handleClose}
disabled={closing}
className="text-xs px-3 py-1.5 rounded-sm shrink-0"
style={{
background: "var(--muted)",
border: "1px solid var(--border)",
color: "var(--foreground)",
}}
>
{closing ? "..." : "✓ Закрыть вопрос"}
</button>
)}
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 flex flex-col gap-3">
{detail.messages.map((msg) => {
const isMine = msg.authorId === currentUserId;
return (
<div
key={msg.id}
className="max-w-[85%] px-3 py-2 rounded-sm text-sm"
style={{
alignSelf: isMine ? "flex-end" : "flex-start",
background: isMine ? "var(--muted)" : "var(--background)",
border: isMine ? "none" : "2px solid #E8F0D8",
}}
>
<p className="text-xs mb-1" style={{ color: "var(--muted-foreground)" }}>
{msg.author.name} · {formatDate(msg.createdAt)}
</p>
<p style={{ color: "var(--foreground)" }}>{msg.text}</p>
{msg.files && msg.files.length > 0 && (
<div className="flex flex-col gap-1 mt-2">
{msg.files.map((f) => (
<a
key={f.url}
href={f.url.startsWith("https://") ? f.url : "#"}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
style={{
background: "var(--background)",
border: "1px solid var(--border)",
color: "var(--foreground)",
width: "fit-content",
}}
>
📎 {f.name}
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
</a>
))}
</div>
)}
</div>
);
})}
</div>
{/* Reply form */}
<div
className="p-3 shrink-0"
style={{ borderTop: "1px solid var(--border)" }}
>
<div
className="flex flex-col gap-2"
style={{ opacity: detail.status === "CLOSED" ? 0.5 : 1 }}
>
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleReply();
}
}}
disabled={detail.status === "CLOSED"}
placeholder={
detail.status === "CLOSED" ? "Вопрос закрыт" : "Написать ответ..."
}
rows={4}
className="w-full text-sm px-3 py-2 outline-none resize-y"
style={{
border: "1px solid var(--border)",
background: "var(--background)",
color: "var(--foreground)",
}}
/>
{replyFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{replyFiles.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-1 text-xs px-2 py-1"
style={{ background: "var(--muted)", border: "1px solid var(--border)" }}
>
📎 {f.name}
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
<button
type="button"
onClick={() => setReplyFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading || detail.status === "CLOSED"}
className="text-xs px-2 py-1"
style={{ background: "var(--muted)", border: "1px solid var(--border)" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf · 10 МБ
</span>
</div>
<button
onClick={handleReply}
disabled={sending || uploading || ((!replyText.trim()) && replyFiles.length === 0) || detail.status === "CLOSED"}
className="text-xs font-bold px-4 py-2"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: sending || uploading || ((!replyText.trim()) && replyFiles.length === 0) ? 0.5 : 1,
}}
>
{sending ? "..." : "Отправить →"}
</button>
</div>
</div>
{error && (
<p className="text-xs mt-1" style={{ color: "var(--destructive)" }}>
{error}
</p>
)}
</div>
</>
)}
</div>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface FileAttachment {
name: string;
url: string;
size: number;
}
interface Message {
id: string;
authorId: string;
text: string;
files: FileAttachment[] | null;
isRead: boolean;
createdAt: string;
author: { id: string; name: string; role: string };
}
interface QuestionThreadProps {
questionId: string;
questionStatus: "OPEN" | "CLOSED";
currentUserId: string;
initialMessages: Message[];
}
function formatDate(iso: string) {
return new Date(iso).toLocaleString("ru", {
day: "numeric",
month: "long",
hour: "2-digit",
minute: "2-digit",
});
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function QuestionThread({
questionId,
questionStatus,
currentUserId,
initialMessages,
}: QuestionThreadProps) {
const [messages, setMessages] = useState<Message[]>(initialMessages);
const [text, setText] = useState("");
const [files, setFiles] = useState<FileAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
async function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const selected = Array.from(e.target.files ?? []);
if (!selected.length) return;
setUploading(true);
setError("");
try {
const uploaded: FileAttachment[] = [];
for (const f of selected) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/student/question-upload", { method: "POST", body: form });
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка загрузки файла");
}
uploaded.push(await res.json());
}
setFiles((prev) => [...prev, ...uploaded]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка загрузки");
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
async function handleSend() {
if (uploading) return;
if (!text.trim() && files.length === 0) return;
setSending(true);
setError("");
try {
const res = await fetch(`/api/questions/${questionId}/messages`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), files }),
});
if (!res.ok) {
const d = await res.json();
throw new Error(d.error ?? "Ошибка отправки");
}
const msg = await res.json();
setMessages((prev) => [...prev, msg]);
setText("");
setFiles([]);
} catch (err) {
setError(err instanceof Error ? err.message : "Ошибка");
} finally {
setSending(false);
}
}
return (
<div className="flex flex-col gap-2 w-full">
{/* Messages */}
<div className="flex flex-col gap-2 mb-4 max-h-96 overflow-y-auto pr-1">
{messages.map((msg) => {
const isMine = msg.authorId === currentUserId;
const isNew = !msg.isRead && !isMine;
return (
<div
key={msg.id}
className="max-w-[88%] px-3 py-2 rounded-sm text-sm"
style={{
alignSelf: isMine ? "flex-end" : "flex-start",
background: isMine ? "var(--muted)" : "var(--background)",
border: isMine ? "none" : `2px solid #E8F0D8`,
borderLeft: !isMine && isNew ? "3px solid #323232" : undefined,
}}
>
<p
className="text-xs mb-1"
style={{ color: "var(--muted-foreground)" }}
>
{isMine ? "Ты" : msg.author.name} · {formatDate(msg.createdAt)}
{isNew && (
<strong style={{ color: "#323232" }}> · 🔵 новое</strong>
)}
</p>
<p style={{ color: "var(--foreground)" }}>{msg.text}</p>
{msg.files && msg.files.length > 0 && (
<div className="flex flex-col gap-1 mt-2">
{msg.files.map((f, i) => (
<a
key={i}
href={f.url.startsWith("https://") ? f.url : "#"}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm"
style={{
background: "var(--background)",
border: "1px solid #AAAAAA",
color: "var(--foreground)",
width: "fit-content",
}}
>
📎 <span>{f.name}</span>
<span style={{ color: "#999" }}>· {formatFileSize(f.size)}</span>
</a>
))}
</div>
)}
</div>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Queued files preview */}
{files.length > 0 && (
<div className="flex flex-wrap gap-1 mb-2">
{files.map((f, i) => (
<div
key={i}
className="flex items-center gap-1 text-xs px-2 py-1 rounded-sm"
style={{ background: "var(--color-surface)", border: "1px solid var(--border)" }}
>
📎 {f.name}
<button
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
className="ml-1 text-xs"
style={{ color: "var(--muted-foreground)" }}
>
×
</button>
</div>
))}
</div>
)}
{/* Reply form */}
<div
className="rounded-sm p-2"
style={{
border: "2px solid #AAAAAA",
background: "var(--color-surface)",
opacity: questionStatus === "CLOSED" ? 0.5 : 1,
pointerEvents: questionStatus === "CLOSED" ? "none" : "auto",
}}
>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={questionStatus === "CLOSED" ? "Вопрос закрыт" : "Написать сообщение..."}
rows={3}
disabled={questionStatus === "CLOSED"}
className="w-full text-sm outline-none resize-none bg-transparent"
style={{ border: "none", color: "var(--foreground)" }}
/>
<div
className="flex items-center justify-between pt-2 mt-1"
style={{ borderTop: "1px solid var(--muted)" }}
>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept=".jpg,.jpeg,.png,.pdf,.md,.txt"
className="hidden"
onChange={handleFileSelect}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="text-xs px-2 py-1 rounded-sm"
style={{ background: "var(--color-surface)", border: "1px solid #AAAAAA" }}
>
{uploading ? "Загрузка..." : "📎 Прикрепить"}
</button>
<span className="text-xs" style={{ color: "#999" }}>
jpg, png, pdf, md · до 10 МБ
</span>
</div>
<button
type="button"
onClick={handleSend}
disabled={sending || (!text.trim() && files.length === 0)}
className="text-xs font-bold px-4 py-1.5 rounded-sm"
style={{
background: "var(--foreground)",
color: "var(--background)",
border: "none",
opacity: sending ? 0.6 : 1,
}}
>
{sending ? "..." : "Отправить →"}
</button>
</div>
{error && (
<p className="text-xs mt-1" style={{ color: "var(--destructive)" }}>
{error}
</p>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -54,7 +54,7 @@ export function HomeworkSection({ homework, submission, slug, lessonId, allowAud
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: "140px", minHeight: "140px",
+1 -1
View File
@@ -23,7 +23,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",
resize: "vertical" as const, resize: "vertical" as const,
minHeight: "80px", minHeight: "80px",
+107
View File
@@ -190,6 +190,29 @@ export async function sendTestEmail(to: string) {
}).catch((e) => console.error("[email] sendTestEmail:", e)); }).catch((e) => console.error("[email] sendTestEmail:", e));
} }
export async function sendAccessEmail(
to: string,
name: string,
courseTitle: string,
tempPassword: string
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Доступ к курсу «${courseTitle}» открыт`,
html: base(`
<p ${p}>Привет!</p>
<p ${p}>Вам открыт доступ к курсу <strong>«${courseTitle}»</strong> на образовательной платформе <strong>${school}</strong>.</p>
<p ${p}>Ваши данные для входа:</p>
${quote(`Логин: ${to}\nПароль: ${tempPassword}`)}
<p ${p}>После входа рекомендуем сразу сменить пароль в разделе «Профиль».</p>
<p ${pLast}>Если пароль не подходит — воспользуйтесь ссылкой «Забыли пароль?» на странице входа.</p>
${btn(`${BASE_URL}/login`, "Войти на платформу")}
`, school),
}).catch((e) => console.error("[email] sendAccessEmail:", e));
}
export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) { export async function sendPasswordResetEmail(to: string, name: string, resetUrl: string) {
const school = await getSchoolName(); const school = await getSchoolName();
await getResend().emails.send({ await getResend().emails.send({
@@ -205,3 +228,87 @@ export async function sendPasswordResetEmail(to: string, name: string, resetUrl:
`, school), `, school),
}).catch((e) => console.error("[email] sendPasswordResetEmail:", e)); }).catch((e) => console.error("[email] sendPasswordResetEmail:", e));
} }
export async function sendQuestionCreatedEmail(
to: string,
recipientName: string,
studentName: string,
questionTitle: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Новый вопрос от ${studentName}`,
html: base(`
<p ${p}>Привет, ${recipientName}!</p>
<p ${p}>Студент <strong>${studentName}</strong> задал новый вопрос:</p>
${quote(questionTitle)}
<p ${pLast}>Откройте панель, чтобы ответить:</p>
${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")}
`, school),
}).catch((e) => console.error("[email] sendQuestionCreatedEmail:", e));
}
export async function sendQuestionReplyEmail(
to: string,
studentName: string,
questionTitle: string,
questionId: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Ответ на ваш вопрос`,
html: base(`
<p ${p}>Привет, ${studentName}!</p>
<p ${p}>Школа ответила на ваш вопрос:</p>
${quote(questionTitle)}
<p ${pLast}>Откройте тред чтобы прочитать ответ:</p>
${btn(`${BASE_URL}/questions/${questionId}`, "Читать ответ")}
`, school),
}).catch((e) => console.error("[email] sendQuestionReplyEmail:", e));
}
export async function sendQuestionFollowUpEmail(
to: string,
recipientName: string,
studentName: string,
questionTitle: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Новый ответ от студента — ${questionTitle}`,
html: base(`
<p ${p}>Привет, ${recipientName}!</p>
<p ${p}>Студент <strong>${studentName}</strong> добавил сообщение в вопрос:</p>
${quote(questionTitle)}
<p ${pLast}>Откройте панель, чтобы ответить:</p>
${btn(`${BASE_URL}/admin/questions`, "Перейти к вопросам")}
`, school),
}).catch((e) => console.error("[email] sendQuestionFollowUpEmail:", e));
}
export async function sendHomeworkUpdatedEmail(
to: string,
recipientName: string,
studentName: string,
lessonTitle: string,
submissionId: string,
) {
const school = await getSchoolName();
await getResend().emails.send({
from: FROM,
to,
subject: `Студент обновил работу — ${lessonTitle}`,
html: base(`
<p ${p}>Привет, ${recipientName}!</p>
<p ${p}>Студент <strong>${studentName}</strong> обновил работу по уроку <strong>«${lessonTitle}»</strong>.</p>
<p ${pLast}>Откройте работу чтобы проверить изменения:</p>
${btn(`${BASE_URL}/curator/homework/${submissionId}`, "Открыть работу")}
`, school),
}).catch((e) => console.error("[email] sendHomeworkUpdatedEmail:", e));
}