Files
lms-sb/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx
T

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>
);
}