diff --git a/package-lock.json b/package-lock.json
index 5fd58cb..069abb6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@kinescope/react-kinescope-player": "^0.5.4",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tailwindcss/typography": "^0.5.19",
@@ -2762,6 +2763,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@kinescope/react-kinescope-player": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/@kinescope/react-kinescope-player/-/react-kinescope-player-0.5.4.tgz",
+ "integrity": "sha512-2IYyHqTV8DwKvhMOyO+CeOeS0D15eG21N0SB1qq9LwXsG+kccrEW/XBxhDGmRX15PamPLWiggmdljtmM06lp1A==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
diff --git a/package.json b/package.json
index b24b65f..14f18ed 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@kinescope/react-kinescope-player": "^0.5.4",
"@prisma/adapter-pg": "^7.6.0",
"@prisma/client": "^7.6.0",
"@tailwindcss/typography": "^0.5.19",
diff --git a/src/app/(student)/courses/[slug]/layout.tsx b/src/app/(student)/courses/[slug]/layout.tsx
new file mode 100644
index 0000000..e834773
--- /dev/null
+++ b/src/app/(student)/courses/[slug]/layout.tsx
@@ -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 (
+
+
+
+ {children}
+
+
+ );
+}
diff --git a/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx
new file mode 100644
index 0000000..b15eae3
--- /dev/null
+++ b/src/app/(student)/courses/[slug]/lessons/[lessonId]/page.tsx
@@ -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 (
+
+ {/* Title */}
+ {lesson.title}
+
+ {/* Video */}
+ {lesson.kinescopeId && (
+
+
+
+ )}
+
+ {/* Text content */}
+ {hasContent && (
+
+
+
+ )}
+
+ {/* Files */}
+ {lesson.files.length > 0 && (
+
+
+ Материалы урока
+
+
+
+ )}
+
+ {/* Prev / Next navigation */}
+
+ {prevLesson ? (
+
+ ← {prevLesson.title}
+
+ ) : (
+
+ )}
+ {nextLesson ? (
+
+ {nextLesson.title} →
+
+ ) : (
+
+ Последний урок курса
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(student)/courses/[slug]/page.tsx b/src/app/(student)/courses/[slug]/page.tsx
new file mode 100644
index 0000000..59d1d25
--- /dev/null
+++ b/src/app/(student)/courses/[slug]/page.tsx
@@ -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 (
+
+
📭
+
Уроков пока нет
+
+ Курс в разработке. Загляните позже.
+
+
+ );
+}
diff --git a/src/app/(student)/dashboard/page.tsx b/src/app/(student)/dashboard/page.tsx
index a57af67..bab860f 100644
--- a/src/app/(student)/dashboard/page.tsx
+++ b/src/app/(student)/dashboard/page.tsx
@@ -1,33 +1,106 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
-import { LogoutButton } from "@/components/layout/logout-button";
+import { prisma } from "@/lib/prisma";
+import Link from "next/link";
export default async function StudentDashboard() {
const session = await auth.api.getSession({ headers: await headers() });
-
if (!session) redirect("/login");
+ const enrollments = await prisma.courseEnrollment.findMany({
+ where: { userId: session.user.id },
+ include: {
+ course: {
+ include: {
+ modules: {
+ include: { _count: { select: { lessons: true } } },
+ },
+ _count: { select: { modules: true } },
+ },
+ },
+ },
+ orderBy: { enrolledAt: "desc" },
+ });
+
+ const now = new Date();
+ const active = enrollments.filter((e) => !e.expiresAt || e.expiresAt > now);
+ const expired = enrollments.filter((e) => e.expiresAt && e.expiresAt <= now);
+
return (
-
-
-
-
- Добро пожаловать, {session.user.name}!
-
- Ваши курсы появятся здесь.
-
+
+ Мои курсы
+
+ {active.length} активных курсов
+
+
+ {active.length === 0 ? (
+
📚
-
Доступных курсов пока нет.
-
Обратитесь к администратору за доступом.
+
Доступных курсов пока нет
+
+ Обратитесь к администратору за доступом
+
-
-
+ ) : (
+
+ {active.map(({ course, expiresAt }) => {
+ const totalLessons = course.modules.reduce((s, m) => s + m._count.lessons, 0);
+ return (
+
+ {course.coverImage ? (
+ // eslint-disable-next-line @next/next/no-img-element
+

+ ) : (
+
+ 📚
+
+ )}
+
+
{course.title}
+ {course.description && (
+
+ {course.description}
+
+ )}
+
+
+ {course._count.modules} модулей · {totalLessons} уроков
+
+ {expiresAt && (
+
+ до {new Date(expiresAt).toLocaleDateString("ru-RU")}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ {expired.length > 0 && (
+
+
+ Доступ истёк
+
+
+ {expired.map(({ course, expiresAt }) => (
+
+ {course.title}
+
+ Истёк {new Date(expiresAt!).toLocaleDateString("ru-RU")}
+
+
+ ))}
+
+
+ )}
+
);
}
diff --git a/src/app/(student)/layout.tsx b/src/app/(student)/layout.tsx
new file mode 100644
index 0000000..04b7953
--- /dev/null
+++ b/src/app/(student)/layout.tsx
@@ -0,0 +1,28 @@
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { redirect } from "next/navigation";
+import Link from "next/link";
+import { LogoutButton } from "@/components/layout/logout-button";
+
+export default async function StudentLayout({ children }: { children: React.ReactNode }) {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session) redirect("/login");
+
+ return (
+
+ );
+}
diff --git a/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx b/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx
index 30f0817..faa6ab9 100644
--- a/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx
+++ b/src/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/page.tsx
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import Link from "next/link";
import { LessonEditor } from "@/components/admin/lesson-editor";
+import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
interface Props {
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
@@ -13,6 +14,7 @@ export default async function LessonEditorPage({ params }: Props) {
const lesson = await prisma.lesson.findUnique({
where: { id: lessonId },
include: {
+ files: { orderBy: { createdAt: "asc" } },
module: {
include: { course: { select: { title: true } } },
},
@@ -23,31 +25,38 @@ export default async function LessonEditorPage({ params }: Props) {
return (
-
);
}
diff --git a/src/app/api/admin/lesson-files/route.ts b/src/app/api/admin/lesson-files/route.ts
new file mode 100644
index 0000000..8e55e36
--- /dev/null
+++ b/src/app/api/admin/lesson-files/route.ts
@@ -0,0 +1,41 @@
+import { NextRequest, NextResponse } from "next/server";
+import { headers } from "next/headers";
+import { auth } from "@/lib/auth";
+import { prisma } from "@/lib/prisma";
+import { uploadFile, deleteFile } from "@/lib/s3";
+import { randomUUID } from "crypto";
+
+export async function POST(req: NextRequest) {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session || session.user.role !== "admin") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const form = await req.formData();
+ const file = form.get("file") as File | null;
+ const lessonId = form.get("lessonId") as string | null;
+ if (!file || !lessonId) return NextResponse.json({ error: "Missing fields" }, { status: 400 });
+
+ const ext = file.name.split(".").pop() ?? "bin";
+ const key = `lessons/${lessonId}/${randomUUID()}.${ext}`;
+ const buffer = Buffer.from(await file.arrayBuffer());
+ const url = await uploadFile(key, buffer, file.type);
+
+ const lessonFile = await prisma.lessonFile.create({
+ data: { lessonId, name: file.name, url, size: file.size },
+ });
+
+ return NextResponse.json(lessonFile);
+}
+
+export async function DELETE(req: NextRequest) {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session || session.user.role !== "admin") {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const { fileId, key } = await req.json();
+ if (key) await deleteFile(key).catch(() => {});
+ await prisma.lessonFile.delete({ where: { id: fileId } });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/components/admin/lesson-files-manager.tsx b/src/components/admin/lesson-files-manager.tsx
new file mode 100644
index 0000000..5bca7c9
--- /dev/null
+++ b/src/components/admin/lesson-files-manager.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+
+interface LessonFile {
+ id: string;
+ name: string;
+ url: string;
+ size: number;
+}
+
+export function LessonFilesManager({ lessonId, initialFiles }: { lessonId: string; initialFiles: LessonFile[] }) {
+ const [files, setFiles] = useState(initialFiles);
+ const [uploading, setUploading] = useState(false);
+
+ async function handleUpload(e: React.ChangeEvent
) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setUploading(true);
+ const fd = new FormData();
+ fd.append("file", file);
+ fd.append("lessonId", lessonId);
+ const res = await fetch("/api/admin/lesson-files", { method: "POST", body: fd });
+ const created = await res.json();
+ if (created.id) setFiles((prev) => [...prev, created]);
+ setUploading(false);
+ e.target.value = "";
+ }
+
+ async function handleDelete(fileId: string) {
+ if (!confirm("Удалить файл?")) return;
+ await fetch("/api/admin/lesson-files", {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ fileId }),
+ });
+ setFiles((prev) => prev.filter((f) => f.id !== fileId));
+ }
+
+ 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 (
+
+ {files.length > 0 && (
+
+ {files.map((f) => (
+
+
📎
+
{f.name}
+
{formatSize(f.size)}
+
+
+ ))}
+
+ )}
+
+
+ PDF, ZIP, DOCX, XLSX — до 100 МБ
+
+
+ );
+}
diff --git a/src/components/player/kinescope-player.tsx b/src/components/player/kinescope-player.tsx
new file mode 100644
index 0000000..5df4149
--- /dev/null
+++ b/src/components/player/kinescope-player.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import KinescopeReactPlayer from "@kinescope/react-kinescope-player";
+
+interface Props {
+ videoId: string;
+}
+
+export function KinescopePlayer({ videoId }: Props) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/student/course-sidebar.tsx b/src/components/student/course-sidebar.tsx
new file mode 100644
index 0000000..5fc060f
--- /dev/null
+++ b/src/components/student/course-sidebar.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useState } from "react";
+
+interface Lesson {
+ id: string;
+ title: string;
+}
+
+interface Module {
+ id: string;
+ title: string;
+ lessons: Lesson[];
+}
+
+interface Course {
+ slug: string;
+ title: string;
+ modules: Module[];
+}
+
+export function CourseSidebar({ course }: { course: Course }) {
+ const pathname = usePathname();
+ const [open, setOpen] = useState(true);
+
+ return (
+ <>
+ {/* Mobile toggle */}
+
+
+
+ >
+ );
+}
diff --git a/src/components/student/lesson-content.tsx b/src/components/student/lesson-content.tsx
new file mode 100644
index 0000000..abc7c37
--- /dev/null
+++ b/src/components/student/lesson-content.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useEditor, EditorContent } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Image from "@tiptap/extension-image";
+import Link from "@tiptap/extension-link";
+
+export function LessonContent({ content }: { content: object }) {
+ const editor = useEditor({
+ extensions: [
+ StarterKit,
+ Image.configure({ inline: false }),
+ Link.configure({ openOnClick: true }),
+ ],
+ content,
+ editable: false,
+ editorProps: {
+ attributes: {
+ class: "prose prose-slate max-w-none focus:outline-none",
+ },
+ },
+ });
+
+ return ;
+}