285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
import { prisma } from "@/lib/prisma";
|
|
import { notFound } from "next/navigation";
|
|
import Link from "next/link";
|
|
import { headers } from "next/headers";
|
|
import { auth } from "@/lib/auth";
|
|
import { KinescopePlayer } from "@/components/player/kinescope-player";
|
|
import { LessonContent } from "@/components/student/lesson-content";
|
|
import { LessonCompleteButton } from "@/components/student/lesson-complete-button";
|
|
import { HomeworkSection } from "@/components/student/homework-section";
|
|
import { QuizSection } from "@/components/student/quiz-section";
|
|
import { LessonComments } from "@/components/student/lesson-comments";
|
|
import { FileFormatBadge } from "@/components/shared/file-format-badge";
|
|
|
|
interface Props {
|
|
params: Promise<{ slug: string; lessonId: string }>;
|
|
}
|
|
|
|
export default async function LessonPage({ params }: Props) {
|
|
const { slug, lessonId } = await params;
|
|
|
|
const session = await auth.api.getSession({ headers: await headers() });
|
|
const isAdmin = session?.user.role === "admin";
|
|
|
|
const [lesson, progress, comments] = await Promise.all([
|
|
prisma.lesson.findUnique({
|
|
where: { id: lessonId, ...(isAdmin ? {} : { published: true }) },
|
|
include: {
|
|
files: { orderBy: { createdAt: "asc" } },
|
|
homework: true,
|
|
quiz: {
|
|
include: { questions: { orderBy: { order: "asc" } } },
|
|
},
|
|
module: {
|
|
include: {
|
|
course: {
|
|
include: {
|
|
modules: {
|
|
orderBy: { order: "asc" },
|
|
include: {
|
|
lessons: {
|
|
where: { published: true },
|
|
orderBy: { order: "asc" },
|
|
select: { id: true, title: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
session && !isAdmin
|
|
? prisma.lessonProgress.findUnique({
|
|
where: { userId_lessonId: { userId: session.user.id, lessonId } },
|
|
})
|
|
: null,
|
|
prisma.lessonComment.findMany({
|
|
where: { lessonId, parentId: null },
|
|
orderBy: { createdAt: "asc" },
|
|
include: {
|
|
user: { select: { id: true, name: true } },
|
|
replies: {
|
|
orderBy: { createdAt: "asc" },
|
|
include: { user: { select: { id: true, name: true } } },
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
// Fetch homework submission for this student
|
|
const homeworkSubmission = lesson?.homework && session && !isAdmin
|
|
? await prisma.homeworkSubmission.findFirst({
|
|
where: { homeworkId: lesson.homework.id, userId: session.user.id },
|
|
include: {
|
|
feedbacks: {
|
|
include: { curator: { select: { name: true } } },
|
|
orderBy: { createdAt: "asc" },
|
|
},
|
|
},
|
|
})
|
|
: null;
|
|
|
|
const quizAttempt = lesson?.quiz && session && !isAdmin
|
|
? await prisma.quizAttempt.findFirst({
|
|
where: { quizId: lesson.quiz.id, userId: session.user.id },
|
|
})
|
|
: null;
|
|
|
|
if (!lesson || lesson.module.course.slug !== slug) notFound();
|
|
|
|
const isCompleted = !!progress;
|
|
|
|
// Build ordered flat list of all lessons for prev/next
|
|
const allLessons = lesson.module.course.modules.flatMap((m) => m.lessons);
|
|
const idx = allLessons.findIndex((l) => l.id === lessonId);
|
|
const prevLesson = idx > 0 ? allLessons[idx - 1] : null;
|
|
const nextLesson = idx < allLessons.length - 1 ? allLessons[idx + 1] : null;
|
|
|
|
const hasContent = lesson.content && Object.keys(lesson.content as object).length > 0;
|
|
|
|
function formatSize(bytes: number) {
|
|
if (bytes < 1024) return `${bytes} Б`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
|
|
return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
|
|
}
|
|
|
|
return (
|
|
<article className="max-w-3xl mx-auto px-6 py-8">
|
|
{/* Admin edit bar */}
|
|
{isAdmin && (
|
|
<div className="flex items-center justify-end mb-4">
|
|
<Link
|
|
href={`/admin/courses/${lesson.module.course.id}/modules/${lesson.moduleId}/lessons/${lessonId}`}
|
|
className="btn-aubade text-xs px-3 py-1.5"
|
|
>
|
|
Редактировать урок
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Title */}
|
|
<h1 className="text-2xl font-bold mb-6 leading-snug">{lesson.title}</h1>
|
|
|
|
{/* Video */}
|
|
{lesson.kinescopeId && (
|
|
<div className="mb-8">
|
|
<KinescopePlayer videoId={lesson.kinescopeId} poster={lesson.coverImage ?? undefined} />
|
|
{/* DEBUG: remove after fix */}
|
|
{isAdmin && <p style={{fontSize:11,color:"gray",marginTop:4}}>coverImage: {lesson.coverImage ?? "NULL"}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{/* Text content */}
|
|
{hasContent && (
|
|
<div className="mb-8">
|
|
<LessonContent content={lesson.content as object} />
|
|
</div>
|
|
)}
|
|
|
|
{/* Files */}
|
|
{lesson.files.length > 0 && (
|
|
<div className="mb-8">
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
|
Материалы урока
|
|
</p>
|
|
<div className="space-y-2">
|
|
{lesson.files.map((file) => (
|
|
<a
|
|
key={file.id}
|
|
href={file.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:[border-color:var(--foreground)]"
|
|
style={{ border: "2px solid var(--border)" }}
|
|
>
|
|
<FileFormatBadge url={file.url} />
|
|
<span className="flex-1 font-medium">{file.name}</span>
|
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
|
{formatSize(file.size)}
|
|
</span>
|
|
<span className="text-xs underline" style={{ color: "var(--muted-foreground)" }}>
|
|
Скачать
|
|
</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Homework */}
|
|
{lesson.homework && !isAdmin && (
|
|
<div className="mb-8">
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
|
Домашнее задание
|
|
</p>
|
|
<HomeworkSection
|
|
homework={lesson.homework}
|
|
submission={homeworkSubmission ? {
|
|
...homeworkSubmission,
|
|
files: (homeworkSubmission.files as { name: string; url: string; size: number }[]) ?? [],
|
|
audioUrl: homeworkSubmission.audioUrl ?? null,
|
|
feedbacks: homeworkSubmission.feedbacks.map((fb) => ({
|
|
...fb,
|
|
files: (fb.files as { name: string; url: string; size: number }[]) ?? [],
|
|
audioUrl: fb.audioUrl ?? null,
|
|
})),
|
|
} : null}
|
|
slug={slug}
|
|
lessonId={lessonId}
|
|
allowAudio={lesson.module.course.allowAudio}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Quiz */}
|
|
{lesson.quiz && (
|
|
<div className="mb-8">
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
|
Тест{isAdmin && <span className="ml-2 opacity-50">(предпросмотр)</span>}
|
|
</p>
|
|
{isAdmin ? (
|
|
<div className="space-y-4 opacity-70">
|
|
{lesson.quiz.questions.map((q, idx) => (
|
|
<div key={q.id} className="space-y-1">
|
|
<p className="text-sm font-medium">{idx + 1}. {q.text}</p>
|
|
<div className="px-4 py-3 text-sm" style={{ border: "2px solid var(--border)", color: "var(--muted-foreground)" }}>
|
|
Поле для ответа студента
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<QuizSection
|
|
quiz={lesson.quiz}
|
|
attempt={quizAttempt ? { answers: quizAttempt.answers as Record<string, string> } : null}
|
|
slug={slug}
|
|
lessonId={lessonId}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Complete button + Prev/Next navigation */}
|
|
<div
|
|
className="flex items-center justify-between pt-6 mt-6"
|
|
style={{ borderTop: "2px solid var(--border)" }}
|
|
>
|
|
{prevLesson ? (
|
|
<Link
|
|
href={`/courses/${slug}/lessons/${prevLesson.id}`}
|
|
className="btn-aubade text-sm max-w-[40%]"
|
|
>
|
|
← {prevLesson.title}
|
|
</Link>
|
|
) : (
|
|
<div />
|
|
)}
|
|
|
|
{!isAdmin && !lesson.homework && !lesson.quiz && (
|
|
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={isCompleted} />
|
|
)}
|
|
{!isAdmin && (lesson.homework || lesson.quiz) && isCompleted && (
|
|
<LessonCompleteButton lessonId={lessonId} slug={slug} isCompleted={true} readOnly={true} />
|
|
)}
|
|
|
|
{nextLesson ? (
|
|
<Link
|
|
href={`/courses/${slug}/lessons/${nextLesson.id}`}
|
|
className="btn-aubade btn-aubade-accent text-sm max-w-[40%] text-right"
|
|
>
|
|
{nextLesson.title} →
|
|
</Link>
|
|
) : (
|
|
<div className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
|
{isAdmin ? "Последний урок курса" : ""}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Comments */}
|
|
{session && (
|
|
<div
|
|
className="mt-10 pt-8"
|
|
style={{ borderTop: "2px solid var(--border)" }}
|
|
>
|
|
<p className="text-xs font-bold uppercase tracking-widest mb-6" style={{ color: "var(--muted-foreground)" }}>
|
|
Обсуждение ({
|
|
comments.filter(c => !c.deleted).length +
|
|
comments.flatMap(c => c.replies).filter(r => !r.deleted).length
|
|
})
|
|
</p>
|
|
<LessonComments
|
|
lessonId={lessonId}
|
|
slug={slug}
|
|
comments={comments}
|
|
currentUserId={session.user.id}
|
|
currentUserRole={session.user.role ?? "student"}
|
|
/>
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|