48a9398905
- 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>
280 lines
9.2 KiB
TypeScript
280 lines
9.2 KiB
TypeScript
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>
|
||
);
|
||
}
|