Add homework review workflow: statuses, audio, file attachments, tabs

- HomeworkSubmission: add status (PENDING/REVIEWING/APPROVED/REJECTED) + statusAt
- HomeworkFeedback: add files (Json) + audioUrl fields
- Curator detail page: meta table, content tabs, feedback history with audio/files
- FeedbackForm: file upload, audio recorder (Web Audio API + S3), action buttons
- AudioRecorder component: record → preview → upload to S3
- ContentTabs: toggle between homework description and lesson content (TipTap read-only)
- Homework list: 4-color status badges with proper filtering
- API routes: /api/curator/upload and /api/curator/audio-upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-08 14:01:55 +05:00
parent 768a38b9d3
commit 3855bbd4be
15 changed files with 743 additions and 98 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "HomeworkSubmission" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING';
ALTER TABLE "HomeworkSubmission" ADD COLUMN "statusAt" TIMESTAMP(3);
@@ -0,0 +1,2 @@
ALTER TABLE "HomeworkFeedback" ADD COLUMN "files" JSONB;
ALTER TABLE "HomeworkFeedback" ADD COLUMN "audioUrl" TEXT;
+4
View File
@@ -263,6 +263,8 @@ model HomeworkSubmission {
userId String
text String?
files Json?
status String @default("PENDING") // PENDING | REVIEWING | APPROVED | REJECTED
statusAt DateTime?
submittedAt DateTime @default(now())
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
@@ -275,6 +277,8 @@ model HomeworkFeedback {
submissionId String
curatorId String
text String
files Json? // [{name, url, size}]
audioUrl String?
createdAt DateTime @default(now())
submission HomeworkSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const ext = file.type.includes("ogg") ? "ogg" : file.type.includes("wav") ? "wav" : "webm";
const key = `feedback-audio/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type || "audio/webm");
return NextResponse.json({ url });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { uploadFile } from "@/lib/s3";
import { randomUUID } from "crypto";
export async function POST(req: NextRequest) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const form = await req.formData();
const file = form.get("file") as File | null;
if (!file) return NextResponse.json({ error: "Missing file" }, { status: 400 });
const ext = file.name.split(".").pop() ?? "bin";
const key = `feedback/${session.user.id}/${randomUUID()}.${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
const url = await uploadFile(key, buffer, file.type);
return NextResponse.json({ name: file.name, url, size: file.size });
}
+1 -1
View File
@@ -13,7 +13,7 @@ export default async function CuratorDashboard() {
prisma.homeworkSubmission.count(),
prisma.homeworkFeedback.count({
where: {
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
createdAt: { gte: new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) },
curatorId: session.user.id,
},
}),
@@ -6,17 +6,43 @@ import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { sendFeedbackReceivedEmail } from "@/lib/email";
export async function submitFeedback(submissionId: string, text: string) {
async function requireCurator() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session || (session.user.role !== "curator" && session.user.role !== "admin")) {
throw new Error("Forbidden");
}
return session;
}
await prisma.homeworkFeedback.create({
data: { submissionId, curatorId: session.user.id, text },
});
export async function submitFeedback(
submissionId: string,
data: {
text: string;
files?: { name: string; url: string; size: number }[];
audioUrl?: string | null;
action: "approve" | "reject";
}
) {
const session = await requireCurator();
const status = data.action === "approve" ? "APPROVED" : "REJECTED";
// Send email to student
await prisma.$transaction([
prisma.homeworkFeedback.create({
data: {
submissionId,
curatorId: session.user.id,
text: data.text,
files: data.files ?? [],
audioUrl: data.audioUrl ?? null,
},
}),
prisma.homeworkSubmission.update({
where: { id: submissionId },
data: { status, statusAt: new Date() },
}),
]);
// Send email notification to student
const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
@@ -26,6 +52,7 @@ export async function submitFeedback(submissionId: string, text: string) {
lesson: {
select: {
title: true,
id: true,
module: { select: { course: { select: { slug: true } } } },
},
},
@@ -36,12 +63,12 @@ export async function submitFeedback(submissionId: string, text: string) {
if (submission) {
const { lesson } = submission.homework;
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${submission.homework.lessonId}`;
const lessonUrl = `${process.env.BETTER_AUTH_URL ?? "https://school.second-brain.ru"}/courses/${lesson.module.course.slug}/lessons/${lesson.id}`;
await sendFeedbackReceivedEmail(
submission.user.email,
submission.user.name,
lesson.title,
text,
data.text,
lessonUrl
);
}
@@ -49,3 +76,19 @@ export async function submitFeedback(submissionId: string, text: string) {
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
}
export async function setReviewing(submissionId: string) {
await requireCurator();
await prisma.homeworkSubmission.update({
where: { id: submissionId },
data: { status: "REVIEWING", statusAt: new Date() },
});
revalidatePath("/curator/homework");
revalidatePath(`/curator/homework/${submissionId}`);
}
export async function deleteSubmission(submissionId: string) {
await requireCurator();
await prisma.homeworkSubmission.delete({ where: { id: submissionId } });
revalidatePath("/curator/homework");
}
@@ -0,0 +1,53 @@
"use client";
import { useState } from "react";
import { ContentViewer } from "@/components/curator/content-viewer";
interface Props {
homeworkDescription: string;
lessonContent: unknown;
}
export function ContentTabs({ homeworkDescription, lessonContent }: Props) {
const [tab, setTab] = useState<"homework" | "lesson">("homework");
return (
<div>
{/* Tab bar */}
<div className="flex items-center gap-0" style={{ borderBottom: "2px solid var(--border)" }}>
{(["homework", "lesson"] as const).map((t) => {
const label = t === "homework" ? "Содержимое ДЗ" : "Содержимое урока";
const active = tab === t;
return (
<button
key={t}
type="button"
onClick={() => setTab(t)}
className="px-4 py-2 text-xs font-medium"
style={{
borderBottom: active ? "2px solid var(--foreground)" : "2px solid transparent",
marginBottom: -2,
color: active ? "var(--foreground)" : "var(--muted-foreground)",
background: "transparent",
}}
>
{label}
</button>
);
})}
</div>
{/* Content */}
<div
className="px-4 py-4 text-sm"
style={{ border: "2px solid var(--border)", borderTop: "none" }}
>
{tab === "homework" ? (
<div className="whitespace-pre-wrap">{homeworkDescription}</div>
) : (
<ContentViewer content={lessonContent} />
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteSubmission } from "./actions";
export function DeleteSubmissionButton({
submissionId,
userName,
}: {
submissionId: string;
userName: string;
}) {
const [pending, startTransition] = useTransition();
const router = useRouter();
function handleDelete() {
if (!confirm(`Удалить работу студента ${userName}? Это действие нельзя отменить.`)) return;
startTransition(async () => {
await deleteSubmission(submissionId);
router.push("/curator/homework");
});
}
return (
<button
type="button"
onClick={handleDelete}
disabled={pending}
className="text-xs px-3 py-1.5"
style={{
border: "1px solid var(--border)",
color: "oklch(0.577 0.245 27.325)",
opacity: pending ? 0.5 : 1,
}}
>
🗑 Удалить работу
</button>
);
}
@@ -1,33 +1,91 @@
"use client";
import { useState, useTransition } from "react";
import { useState, useTransition, useRef } from "react";
import { useRouter } from "next/navigation";
import { submitFeedback } from "./actions";
import Link from "next/link";
import { submitFeedback, setReviewing } from "./actions";
import { AudioRecorder } from "@/components/curator/audio-recorder";
export function FeedbackForm({ submissionId }: { submissionId: string }) {
interface FileItem {
name: string;
url: string;
size: number;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
export function FeedbackForm({
submissionId,
currentStatus,
}: {
submissionId: string;
currentStatus: string;
}) {
const [text, setText] = useState("");
const [files, setFiles] = useState<FileItem[]>([]);
const [audioUrl, setAudioUrl] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [pending, startTransition] = useTransition();
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const picked = Array.from(e.target.files ?? []);
if (!picked.length) return;
setUploading(true);
const uploaded: FileItem[] = [];
for (const f of picked) {
const form = new FormData();
form.append("file", f);
const res = await fetch("/api/curator/upload", { method: "POST", body: form });
const data = await res.json();
if (data.url) uploaded.push({ name: data.name, url: data.url, size: data.size });
}
setFiles((prev) => [...prev, ...uploaded]);
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
function handleAction(action: "approve" | "reject") {
if (!text.trim()) return;
startTransition(async () => {
await submitFeedback(submissionId, text.trim());
await submitFeedback(submissionId, {
text: text.trim(),
files,
audioUrl,
action,
});
router.push("/curator/homework");
});
}
function handleReviewing() {
startTransition(async () => {
await setReviewing(submissionId);
});
}
const isWorking = pending || uploading;
return (
<form onSubmit={handleSubmit} className="space-y-3">
<p className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Написать фидбек
<div className="space-y-4">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
Ваш ответ
</p>
{/* Text */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
required
placeholder="Напишите обратную связь студенту..."
disabled={isWorking}
style={{
border: "2px solid var(--border)",
background: "var(--background)",
@@ -42,14 +100,110 @@ export function FeedbackForm({ submissionId }: { submissionId: string }) {
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button
type="submit"
disabled={pending || !text.trim()}
className="btn-aubade btn-aubade-accent px-5 py-2 text-sm"
style={{ opacity: pending || !text.trim() ? 0.6 : 1 }}
{/* File upload */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<label
className="btn-aubade text-xs px-3 py-1.5 cursor-pointer"
style={{ opacity: isWorking ? 0.5 : 1 }}
>
{pending ? "Отправка..." : "Отправить фидбек"}
📎 Прикрепить файл
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileChange}
disabled={isWorking}
/>
</label>
{uploading && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка...
</span>
)}
</div>
{files.length > 0 && (
<div className="space-y-1">
{files.map((f, i) => (
<div
key={f.url}
className="flex items-center gap-2 px-3 py-1.5 text-xs"
style={{ border: "1px solid var(--border)" }}
>
<span>📎</span>
<span className="flex-1 truncate">{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<button
type="button"
onClick={() => setFiles((prev) => prev.filter((_, j) => j !== i))}
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
×
</button>
</form>
</div>
))}
</div>
)}
</div>
{/* Audio recorder */}
<AudioRecorder value={audioUrl} onChange={setAudioUrl} />
{/* Action buttons */}
<div className="flex items-center gap-2 flex-wrap pt-1">
<button
type="button"
onClick={() => handleAction("approve")}
disabled={isWorking || !text.trim()}
className="btn-aubade-accent px-4 py-2 text-sm"
style={{ opacity: isWorking || !text.trim() ? 0.5 : 1 }}
>
{pending ? "Отправка..." : "Отправить ответ"}
</button>
{currentStatus !== "REVIEWING" && (
<button
type="button"
onClick={handleReviewing}
disabled={isWorking}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.08 80)",
color: "oklch(0.4 0.1 80)",
opacity: isWorking ? 0.5 : 1,
}}
>
На рассмотрение
</button>
)}
<button
type="button"
onClick={() => handleAction("reject")}
disabled={isWorking || !text.trim()}
className="px-4 py-2 text-sm"
style={{
border: "2px solid var(--border)",
background: "oklch(0.9 0.06 27)",
color: "oklch(0.45 0.2 27)",
opacity: isWorking || !text.trim() ? 0.5 : 1,
}}
>
Отклонить и отправить
</button>
<Link
href="/curator/homework"
className="px-4 py-2 text-sm"
style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}
>
К списку ДЗ
</Link>
</div>
</div>
);
}
+178 -44
View File
@@ -2,6 +2,8 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { FeedbackForm } from "./feedback-form";
import { ContentTabs } from "./content-tabs";
import { DeleteSubmissionButton } from "./delete-button";
interface Props {
params: Promise<{ submissionId: string }>;
@@ -13,6 +15,24 @@ function formatSize(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; bg: string; color: string }> = {
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)" },
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)" },
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)" },
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)" },
};
const s = map[status] ?? map.PENDING;
return (
<span
className="text-xs px-2 py-0.5 font-medium"
style={{ background: s.bg, color: s.color }}
>
{s.label}
</span>
);
}
export default async function SubmissionPage({ params }: Props) {
const { submissionId } = await params;
@@ -22,14 +42,21 @@ export default async function SubmissionPage({ params }: Props) {
user: { select: { name: true, email: true } },
feedbacks: {
include: { curator: { select: { name: true } } },
orderBy: { createdAt: "desc" },
orderBy: { createdAt: "asc" },
},
homework: {
include: {
lesson: {
select: {
id: true,
title: true,
module: { select: { title: true, course: { select: { title: true } } } },
content: true,
module: {
select: {
title: true,
course: { select: { title: true, slug: true } },
},
},
},
},
},
@@ -40,59 +67,108 @@ export default async function SubmissionPage({ params }: Props) {
if (!submission) notFound();
const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
const isReviewed = submission.feedbacks.length > 0;
const lesson = submission.homework.lesson;
const course = lesson.module.course;
return (
<div className="p-8 max-w-2xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
<Link href="/curator/homework" className="hover:underline">ДЗ на проверку</Link>
{/* Breadcrumb */}
<nav
className="text-xs mb-6 uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
<Link href="/curator/homework" className="hover:underline">
ДЗ на проверку
</Link>
<span className="mx-2">/</span>
<span style={{ color: "var(--foreground)" }}>{submission.user.name}</span>
</nav>
{/* Meta */}
{/* Meta table */}
<div
className="mb-6"
style={{ border: "2px solid var(--border)" }}
>
{[
{ label: "Автор", value: submission.user.name },
{ label: "Логин", value: submission.user.email },
{
label: "Урок",
value: (
<Link
href={`/courses/${course.slug}/lessons/${lesson.id}`}
target="_blank"
className="hover:underline"
style={{ color: "var(--foreground)" }}
>
{lesson.title}
</Link>
),
},
{ label: "Курс", value: course.title },
{ label: "Статус", value: <StatusBadge status={submission.status} /> },
{
label: "Время последнего изменения статуса",
value: submission.statusAt
? new Date(submission.statusAt).toLocaleString("ru-RU")
: new Date(submission.submittedAt).toLocaleString("ru-RU"),
},
].map(({ label, value }) => (
<div
key={label}
className="flex items-start gap-4 px-4 py-2.5 text-sm"
style={{ borderBottom: "1px solid var(--border)" }}
>
<span
className="w-52 shrink-0 font-medium"
style={{ color: "var(--muted-foreground)" }}
>
{label}
</span>
<span>{value}</span>
</div>
))}
</div>
{/* Content tabs */}
<div className="mb-6">
<h1 className="text-xl font-bold">{submission.homework.lesson.title}</h1>
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
{submission.homework.lesson.module.course.title} · {submission.homework.lesson.module.title}
</p>
</div>
{/* Student info */}
<div className="flex items-center justify-between px-4 py-3 mb-6" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
<div>
<p className="font-medium text-sm">{submission.user.name}</p>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>{submission.user.email}</p>
</div>
<p className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Сдано {new Date(submission.submittedAt).toLocaleDateString("ru-RU")}
</p>
</div>
{/* Homework description */}
<div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Задание</p>
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
{submission.homework.description}
</div>
<ContentTabs
homeworkDescription={submission.homework.description}
lessonContent={lesson.content}
/>
</div>
{/* Student answer */}
<div className="mb-4">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Ответ студента</p>
<p
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: "var(--muted-foreground)" }}
>
Ответ студента
</p>
{submission.text ? (
<div className="px-4 py-3 text-sm whitespace-pre-wrap" style={{ border: "2px solid var(--border)" }}>
<div
className="px-4 py-3 text-sm whitespace-pre-wrap"
style={{ border: "2px solid var(--border)" }}
>
{submission.text}
</div>
) : (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>Текст не добавлен</p>
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
Текст не добавлен
</p>
)}
</div>
{/* Files */}
{/* Student files */}
{files.length > 0 && (
<div className="mb-6">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: "var(--muted-foreground)" }}>Прикреплённые файлы</p>
<p
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: "var(--muted-foreground)" }}
>
Файлы студента
</p>
<div className="space-y-1">
{files.map((f) => (
<a
@@ -105,28 +181,86 @@ export default async function SubmissionPage({ params }: Props) {
>
<span>📎</span>
<span className="flex-1 underline">{f.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
</a>
))}
</div>
</div>
)}
{/* Existing feedback */}
{submission.feedbacks.map((fb) => (
<div key={fb.id} className="mb-4 px-4 py-3" style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}>
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-bold uppercase tracking-widest">Фидбек</p>
{/* Existing feedbacks */}
{submission.feedbacks.length > 0 && (
<div className="mb-6 space-y-3">
<p
className="text-xs font-bold uppercase tracking-widest"
style={{ color: "var(--muted-foreground)" }}
>
История фидбека
</p>
{submission.feedbacks.map((fb) => {
const fbFiles = (fb.files as { name: string; url: string; size: number }[]) ?? [];
return (
<div
key={fb.id}
className="px-4 py-3"
style={{ border: "2px solid var(--foreground)", background: "var(--accent)" }}
>
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-bold uppercase tracking-widest">
{fb.curator.name}
</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{fb.curator.name} · {new Date(fb.createdAt).toLocaleDateString("ru-RU")}
{new Date(fb.createdAt).toLocaleString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{fb.text}</p>
</div>
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
{fbFiles.length > 0 && (
<div className="space-y-1 mt-2">
{fbFiles.map((f) => (
<a
key={f.url}
href={f.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs underline"
>
<span>📎</span>
<span>{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
</a>
))}
</div>
)}
{fb.audioUrl && (
<div className="mt-2">
<audio controls src={fb.audioUrl} style={{ height: 32 }} />
</div>
)}
</div>
);
})}
</div>
)}
{/* Feedback form */}
{!isReviewed && <FeedbackForm submissionId={submissionId} />}
<div
className="p-5 mb-4"
style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}
>
<FeedbackForm
submissionId={submissionId}
currentStatus={submission.status}
/>
</div>
{/* Delete */}
<div className="flex justify-end">
<DeleteSubmissionButton submissionId={submissionId} userName={submission.user.name} />
</div>
</div>
);
}
+17 -16
View File
@@ -38,8 +38,10 @@ export default async function HomeworkListPage({ searchParams }: Props) {
},
}
: {}),
...(status === "pending" ? { feedbacks: { none: {} } } : {}),
...(status === "reviewed" ? { feedbacks: { some: {} } } : {}),
...(status === "pending" ? { status: "PENDING" } : {}),
...(status === "reviewing" ? { status: "REVIEWING" } : {}),
...(status === "approved" ? { status: "APPROVED" } : {}),
...(status === "rejected" ? { status: "REJECTED" } : {}),
};
const [submissions, total, courses] = await Promise.all([
@@ -50,7 +52,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
take: PAGE_SIZE,
include: {
user: { select: { name: true, email: true } },
feedbacks: { select: { id: true } },
feedbacks: { select: { id: true }, take: 1 },
homework: {
include: {
lesson: {
@@ -69,7 +71,7 @@ export default async function HomeworkListPage({ searchParams }: Props) {
const totalPages = Math.ceil(total / PAGE_SIZE);
const pendingCount = submissions.filter((s) => s.feedbacks.length === 0).length;
const pendingCount = submissions.filter((s) => s.status === "PENDING").length;
function pageUrl(p: number) {
const params = new URLSearchParams();
@@ -109,16 +111,19 @@ export default async function HomeworkListPage({ searchParams }: Props) {
) : (
<div className="space-y-1.5">
{submissions.map((s) => {
const isPending = s.feedbacks.length === 0;
const statusMap: Record<string, { label: string; bg: string; color: string; border: string }> = {
PENDING: { label: "Новое", bg: "var(--foreground)", color: "var(--background)", border: "var(--foreground)" },
REVIEWING: { label: "На рассмотрении", bg: "oklch(0.9 0.08 80)", color: "oklch(0.4 0.1 80)", border: "oklch(0.75 0.1 80)" },
APPROVED: { label: "Одобрено", bg: "oklch(0.88 0.1 145)", color: "oklch(0.35 0.15 145)", border: "oklch(0.7 0.15 145)" },
REJECTED: { label: "Отклонено", bg: "oklch(0.9 0.06 27)", color: "oklch(0.45 0.2 27)", border: "oklch(0.75 0.1 27)" },
};
const st = statusMap[s.status] ?? statusMap.PENDING;
return (
<Link
key={s.id}
href={`/curator/homework/${s.id}`}
className="flex items-center gap-4 px-4 py-3 text-sm transition-opacity hover:opacity-80"
style={{
border: `2px solid ${isPending ? "var(--foreground)" : "var(--border)"}`,
display: "flex",
}}
style={{ border: `2px solid ${st.border}`, display: "flex" }}
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{s.user.name}</p>
@@ -131,14 +136,10 @@ export default async function HomeworkListPage({ searchParams }: Props) {
</div>
<div className="text-right shrink-0">
<span
className="text-xs px-2 py-0.5"
style={{
border: "1px solid var(--border)",
background: isPending ? "var(--foreground)" : "transparent",
color: isPending ? "var(--background)" : "var(--muted-foreground)",
}}
className="text-xs px-2 py-0.5 font-medium"
style={{ background: st.bg, color: st.color }}
>
{isPending ? "Новое" : "Проверено"}
{st.label}
</span>
<p className="text-xs mt-1" style={{ color: "var(--muted-foreground)" }}>
{new Date(s.submittedAt).toLocaleDateString("ru-RU")}
+4 -2
View File
@@ -64,8 +64,10 @@ export function HomeworkFilters({ courses }: { courses: Course[] }) {
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
>
<option value="">Все статусы</option>
<option value="pending">Ожидают проверки</option>
<option value="reviewed">Проверено</option>
<option value="pending">Новые</option>
<option value="reviewing">На рассмотрении</option>
<option value="approved">Одобрено</option>
<option value="rejected">Отклонено</option>
</select>
{/* Course */}
+132
View File
@@ -0,0 +1,132 @@
"use client";
import { useState, useRef } from "react";
interface AudioRecorderProps {
value: string | null;
onChange: (url: string | null) => void;
}
export function AudioRecorder({ value, onChange }: AudioRecorderProps) {
const [state, setState] = useState<"idle" | "recording" | "recorded" | "uploading">("idle");
const [localUrl, setLocalUrl] = useState<string | null>(null);
const mediaRecorder = useRef<MediaRecorder | null>(null);
const chunks = useRef<Blob[]>([]);
const mimeType = useRef<string>("audio/webm");
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mr = new MediaRecorder(stream);
mimeType.current = mr.mimeType || "audio/webm";
chunks.current = [];
mr.ondataavailable = (e) => {
if (e.data.size > 0) chunks.current.push(e.data);
};
mr.onstop = () => {
const blob = new Blob(chunks.current, { type: mimeType.current });
setLocalUrl(URL.createObjectURL(blob));
setState("recorded");
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorder.current = mr;
mr.start();
setState("recording");
} catch {
alert("Нет доступа к микрофону");
}
}
function stopRecording() {
mediaRecorder.current?.stop();
}
async function uploadAudio() {
setState("uploading");
const blob = new Blob(chunks.current, { type: mimeType.current });
const form = new FormData();
form.append("file", blob, "audio-response.webm");
const res = await fetch("/api/curator/audio-upload", { method: "POST", body: form });
const data = await res.json();
if (data.url) {
onChange(data.url);
}
setState("idle");
}
function reset() {
setLocalUrl(null);
chunks.current = [];
setState("idle");
}
if (value) {
return (
<div className="flex items-center gap-3">
<span className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Аудио-ответ:
</span>
<audio controls src={value} style={{ height: 32 }} />
<button
type="button"
onClick={() => onChange(null)}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</div>
);
}
return (
<div className="flex items-center gap-2 flex-wrap">
{state === "idle" && (
<button
type="button"
onClick={startRecording}
className="btn-aubade text-xs px-3 py-1.5 flex items-center gap-1.5"
>
🎤 Записать аудио-ответ
</button>
)}
{state === "recording" && (
<button
type="button"
onClick={stopRecording}
className="btn-aubade-accent text-xs px-3 py-1.5 flex items-center gap-1.5 animate-pulse"
>
Остановить запись
</button>
)}
{state === "recorded" && localUrl && (
<div className="flex items-center gap-2">
<audio controls src={localUrl} style={{ height: 32 }} />
<button
type="button"
onClick={uploadAudio}
className="btn-aubade text-xs px-3 py-1.5"
>
Загрузить
</button>
<button
type="button"
onClick={reset}
className="text-xs"
style={{ color: "var(--muted-foreground)" }}
>
Перезаписать
</button>
</div>
)}
{state === "uploading" && (
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
Загрузка аудио...
</span>
)}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import Link from "@tiptap/extension-link";
import Underline from "@tiptap/extension-underline";
export function ContentViewer({ content }: { content: unknown }) {
const editor = useEditor({
extensions: [StarterKit, Link, Underline],
content: content as object,
editable: false,
immediatelyRender: false,
});
if (!content) {
return (
<p className="text-sm italic" style={{ color: "var(--muted-foreground)" }}>
Контент урока не добавлен
</p>
);
}
return (
<div
className="prose prose-sm max-w-none text-sm"
style={{ color: "var(--foreground)" }}
>
<EditorContent editor={editor} />
</div>
);
}