Stage 2: student lesson viewer, Kinescope player, PDF files, prev/next nav, My Courses dashboard
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { redirect } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import { CourseSidebar } from "@/components/student/course-sidebar";
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export default async function CourseLayout({ children, params }: Props) {
|
||||
const { slug } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) redirect("/login");
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { slug, published: true },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { published: true },
|
||||
orderBy: { order: "asc" },
|
||||
select: { id: true, title: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) notFound();
|
||||
|
||||
const enrollment = await prisma.courseEnrollment.findUnique({
|
||||
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
|
||||
});
|
||||
|
||||
if (!enrollment) redirect("/dashboard");
|
||||
|
||||
if (enrollment.expiresAt && enrollment.expiresAt < new Date()) {
|
||||
redirect("/dashboard?expired=1");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1">
|
||||
<CourseSidebar course={course} />
|
||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { notFound } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { KinescopePlayer } from "@/components/player/kinescope-player";
|
||||
import { LessonContent } from "@/components/student/lesson-content";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string; lessonId: string }>;
|
||||
}
|
||||
|
||||
export default async function LessonPage({ params }: Props) {
|
||||
const { slug, lessonId } = await params;
|
||||
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lessonId, published: true },
|
||||
include: {
|
||||
files: { orderBy: { createdAt: "asc" } },
|
||||
module: {
|
||||
include: {
|
||||
course: {
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { published: true },
|
||||
orderBy: { order: "asc" },
|
||||
select: { id: true, title: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson || lesson.module.course.slug !== slug) notFound();
|
||||
|
||||
// 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">
|
||||
{/* 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} />
|
||||
</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"
|
||||
style={{ border: "2px solid var(--border)", display: "flex" }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
|
||||
>
|
||||
<span className="text-lg">📎</span>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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-[45%]"
|
||||
>
|
||||
← {prevLesson.title}
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{nextLesson ? (
|
||||
<Link
|
||||
href={`/courses/${slug}/lessons/${nextLesson.id}`}
|
||||
className="btn-aubade btn-aubade-accent text-sm max-w-[45%] text-right"
|
||||
>
|
||||
{nextLesson.title} →
|
||||
</Link>
|
||||
) : (
|
||||
<div className="text-sm" style={{ color: "var(--muted-foreground)" }}>
|
||||
Последний урок курса
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ slug: string }>;
|
||||
}
|
||||
|
||||
export default async function CoursePage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { slug, published: true },
|
||||
include: {
|
||||
modules: {
|
||||
orderBy: { order: "asc" },
|
||||
include: {
|
||||
lessons: {
|
||||
where: { published: true },
|
||||
orderBy: { order: "asc" },
|
||||
select: { id: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!course) notFound();
|
||||
|
||||
// Redirect to the first published lesson
|
||||
for (const mod of course.modules) {
|
||||
if (mod.lessons.length > 0) {
|
||||
redirect(`/courses/${slug}/lessons/${mod.lessons[0].id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// No lessons yet — show placeholder
|
||||
return (
|
||||
<div className="p-10 text-center">
|
||||
<p className="text-4xl mb-4">📭</p>
|
||||
<p className="font-bold text-lg">Уроков пока нет</p>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||
Курс в разработке. Загляните позже.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user