Files
lms-sb/src/app/curator/homework/[submissionId]/page.tsx
T
admins 48a9398905 Add optional audio response for students in homework submissions
- Course: add allowAudio toggle (per-course setting, off by default)
- HomeworkSubmission: add audioUrl field
- Student: AudioRecorder in homework form when allowAudio is enabled
- Student: show audio player in submission view and curator feedback view
- Curator: show student audio on submission detail page
- AudioRecorder: accept uploadUrl prop (reused for student/curator)
- API: /api/student/audio-upload route for S3 upload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 14:17:46 +05:00

280 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }>;
}
function formatSize(bytes: number) {
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
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;
const submission = await prisma.homeworkSubmission.findUnique({
where: { id: submissionId },
include: {
user: { select: { name: true, email: true } },
feedbacks: {
include: { curator: { select: { name: true } } },
orderBy: { createdAt: "asc" },
},
homework: {
include: {
lesson: {
select: {
id: true,
title: true,
content: true,
module: {
select: {
title: true,
course: { select: { title: true, slug: true } },
},
},
},
},
},
},
},
});
if (!submission) notFound();
const files = (submission.files as { name: string; url: string; size: number }[]) ?? [];
const lesson = submission.homework.lesson;
const course = lesson.module.course;
return (
<div className="p-8 max-w-2xl">
{/* 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 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">
<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>
{submission.text ? (
<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>
)}
</div>
{/* Student audio */}
{submission.audioUrl && (
<div className="mb-4">
<p
className="text-xs font-bold uppercase tracking-widest mb-2"
style={{ color: "var(--muted-foreground)" }}
>
Аудио студента
</p>
<audio controls src={submission.audioUrl} style={{ width: "100%", height: 40 }} />
</div>
)}
{/* Student files */}
{files.length > 0 && (
<div className="mb-6">
<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
key={f.url}
href={f.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 px-3 py-2 text-sm"
style={{ border: "2px solid var(--border)" }}
>
<span>📎</span>
<span className="flex-1 underline">{f.name}</span>
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
</a>
))}
</div>
</div>
)}
{/* 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)" }}>
{new Date(fb.createdAt).toLocaleString("ru-RU")}
</span>
</div>
<p className="text-sm whitespace-pre-wrap mb-2">{fb.text}</p>
{fbFiles.length > 0 && (
<div className="space-y-1 mt-2">
{fbFiles.map((f) => (
<a
key={f.url}
href={f.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-xs underline"
>
<span>📎</span>
<span>{f.name}</span>
<span style={{ color: "var(--muted-foreground)" }}>
{formatSize(f.size)}
</span>
</a>
))}
</div>
)}
{fb.audioUrl && (
<div className="mt-2">
<audio controls src={fb.audioUrl} style={{ height: 32 }} />
</div>
)}
</div>
);
})}
</div>
)}
{/* Feedback form */}
<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>
);
}