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 && ( +
+

+ Материалы урока +

+
+ {lesson.files.map((file) => ( + (e.currentTarget.style.borderColor = "var(--foreground)")} + onMouseLeave={(e) => (e.currentTarget.style.borderColor = "var(--border)")} + > + 📎 + {file.name} + + {formatSize(file.size)} + + + Скачать + + + ))} +
+
+ )} + + {/* 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 ( -
-
-

Second Brain

-
- {session.user.name} - -
-
-
-

- Добро пожаловать, {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.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 ( +
+
+ + Second Brain + +
+ {session.user.name} + +
+
+
{children}
+
+ ); +} 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 ; +}