Stage 2: student lesson viewer, Kinescope player, PDF files, prev/next nav, My Courses dashboard
This commit is contained in:
Generated
+11
@@ -14,6 +14,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
@@ -2762,6 +2763,16 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@kurkle/color": {
|
||||||
"version": "0.3.4",
|
"version": "0.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@kinescope/react-kinescope-player": "^0.5.4",
|
||||||
"@prisma/adapter-pg": "^7.6.0",
|
"@prisma/adapter-pg": "^7.6.0",
|
||||||
"@prisma/client": "^7.6.0",
|
"@prisma/client": "^7.6.0",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,106 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { redirect } from "next/navigation";
|
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() {
|
export default async function StudentDashboard() {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
|
||||||
if (!session) redirect("/login");
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-amber-50">
|
<main className="max-w-4xl mx-auto px-6 py-10 w-full">
|
||||||
<header className="bg-white border-b border-amber-100 px-6 py-4 flex items-center justify-between">
|
<h1 className="text-2xl font-bold mb-1">Мои курсы</h1>
|
||||||
<h1 className="text-xl font-bold text-amber-900">Second Brain</h1>
|
<p className="text-sm mb-8" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<div className="flex items-center gap-4">
|
{active.length} активных курсов
|
||||||
<span className="text-sm text-gray-600">{session.user.name}</span>
|
</p>
|
||||||
<LogoutButton />
|
|
||||||
</div>
|
{active.length === 0 ? (
|
||||||
</header>
|
<div className="card-aubade p-12 text-center">
|
||||||
<main className="max-w-4xl mx-auto px-6 py-10">
|
|
||||||
<h2 className="text-2xl font-semibold text-gray-800 mb-2">
|
|
||||||
Добро пожаловать, {session.user.name}!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-500 mb-8">Ваши курсы появятся здесь.</p>
|
|
||||||
<div className="bg-white rounded-2xl border border-amber-100 p-8 text-center text-gray-400">
|
|
||||||
<p className="text-4xl mb-3">📚</p>
|
<p className="text-4xl mb-3">📚</p>
|
||||||
<p>Доступных курсов пока нет.</p>
|
<p className="font-medium">Доступных курсов пока нет</p>
|
||||||
<p className="text-sm mt-1">Обратитесь к администратору за доступом.</p>
|
<p className="text-sm mt-1" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Обратитесь к администратору за доступом
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{active.map(({ course, expiresAt }) => {
|
||||||
|
const totalLessons = course.modules.reduce((s, m) => s + m._count.lessons, 0);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={course.id}
|
||||||
|
href={`/courses/${course.slug}`}
|
||||||
|
className="card-aubade p-0 overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
{course.coverImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={course.coverImage} alt={course.title} className="w-full aspect-video object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full aspect-video flex items-center justify-center text-4xl" style={{ backgroundColor: "var(--color-surface)" }}>
|
||||||
|
📚
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-4 flex-1">
|
||||||
|
<h2 className="font-bold text-base leading-tight">{course.title}</h2>
|
||||||
|
{course.description && (
|
||||||
|
<p className="text-xs mt-1 line-clamp-2" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{course.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{course._count.modules} модулей · {totalLessons} уроков
|
||||||
|
</span>
|
||||||
|
{expiresAt && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
до {new Date(expiresAt).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{expired.length > 0 && (
|
||||||
|
<div className="mt-10">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-3" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Доступ истёк
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{expired.map(({ course, expiresAt }) => (
|
||||||
|
<div key={course.id} className="flex items-center justify-between px-4 py-3 opacity-50" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<span className="text-sm font-medium">{course.title}</span>
|
||||||
|
<span className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
Истёк {new Date(expiresAt!).toLocaleDateString("ru-RU")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--background)" }}>
|
||||||
|
<header
|
||||||
|
className="sticky top-0 z-10 flex items-center justify-between px-6 py-3"
|
||||||
|
style={{ borderBottom: "2px solid var(--border)", backgroundColor: "var(--background)" }}
|
||||||
|
>
|
||||||
|
<Link href="/dashboard" className="font-bold tracking-wide" style={{ color: "var(--foreground)" }}>
|
||||||
|
Second Brain
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-sm" style={{ color: "var(--muted-foreground)" }}>{session.user.name}</span>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex-1 flex flex-col">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LessonEditor } from "@/components/admin/lesson-editor";
|
import { LessonEditor } from "@/components/admin/lesson-editor";
|
||||||
|
import { LessonFilesManager } from "@/components/admin/lesson-files-manager";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ courseId: string; moduleId: string; lessonId: string }>;
|
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({
|
const lesson = await prisma.lesson.findUnique({
|
||||||
where: { id: lessonId },
|
where: { id: lessonId },
|
||||||
include: {
|
include: {
|
||||||
|
files: { orderBy: { createdAt: "asc" } },
|
||||||
module: {
|
module: {
|
||||||
include: { course: { select: { title: true } } },
|
include: { course: { select: { title: true } } },
|
||||||
},
|
},
|
||||||
@@ -23,31 +25,38 @@ export default async function LessonEditorPage({ params }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-4xl">
|
<div className="p-8 max-w-4xl">
|
||||||
<nav className="text-sm text-slate-400 mb-6">
|
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||||
<Link href="/admin/courses" className="hover:text-slate-600">Курсы</Link>
|
<Link href="/admin/courses" className="hover:underline">Курсы</Link>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}`} className="hover:text-slate-600">
|
<Link href={`/admin/courses/${courseId}`} className="hover:underline">{lesson.module.course.title}</Link>
|
||||||
{lesson.module.course.title}
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:text-slate-600">
|
<Link href={`/admin/courses/${courseId}/modules/${moduleId}`} className="hover:underline">{lesson.module.title}</Link>
|
||||||
{lesson.module.title}
|
|
||||||
</Link>
|
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
<span className="text-slate-700">{lesson.title}</span>
|
<span style={{ color: "var(--foreground)" }}>{lesson.title}</span>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Lesson editor */}
|
||||||
|
<div className="card-aubade p-6 mb-6">
|
||||||
<LessonEditor
|
<LessonEditor
|
||||||
lesson={{
|
lesson={{
|
||||||
id: lesson.id,
|
id: lesson.id,
|
||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
kinescopeId: lesson.kinescopeId ?? "",
|
kinescopeId: lesson.kinescopeId ?? "",
|
||||||
content: lesson.content as object ?? {},
|
content: (lesson.content as object) ?? {},
|
||||||
published: lesson.published,
|
published: lesson.published,
|
||||||
}}
|
}}
|
||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
moduleId={moduleId}
|
moduleId={moduleId}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Files section */}
|
||||||
|
<div className="card-aubade p-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-4" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Файлы и материалы
|
||||||
|
</p>
|
||||||
|
<LessonFilesManager lessonId={lessonId} initialFiles={lesson.files} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{files.map((f) => (
|
||||||
|
<div key={f.id} className="flex items-center gap-3 px-3 py-2.5 text-sm" style={{ border: "2px solid var(--border)", background: "var(--color-surface)" }}>
|
||||||
|
<span className="text-base">📎</span>
|
||||||
|
<a href={f.url} target="_blank" rel="noopener noreferrer" className="flex-1 underline font-medium">{f.name}</a>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>{formatSize(f.size)}</span>
|
||||||
|
<button onClick={() => handleDelete(f.id)} className="text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<label className="btn-aubade text-xs cursor-pointer">
|
||||||
|
{uploading ? "Загрузка..." : "+ Добавить файл"}
|
||||||
|
<input type="file" className="sr-only" onChange={handleUpload} disabled={uploading} />
|
||||||
|
</label>
|
||||||
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>PDF, ZIP, DOCX, XLSX — до 100 МБ</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import KinescopeReactPlayer from "@kinescope/react-kinescope-player";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
videoId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KinescopePlayer({ videoId }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="w-full aspect-video" style={{ border: "2px solid var(--border)" }}>
|
||||||
|
<KinescopeReactPlayer
|
||||||
|
videoId={videoId}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 */}
|
||||||
|
<button
|
||||||
|
className="md:hidden fixed bottom-4 right-4 z-20 btn-aubade px-3 py-2 text-sm"
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
>
|
||||||
|
{open ? "✕" : "☰ Уроки"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<aside
|
||||||
|
className={`w-64 shrink-0 flex flex-col overflow-y-auto ${open ? "flex" : "hidden md:flex"}`}
|
||||||
|
style={{
|
||||||
|
borderRight: "2px solid var(--border)",
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
|
maxHeight: "calc(100vh - 53px)",
|
||||||
|
position: "sticky",
|
||||||
|
top: "53px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Course title */}
|
||||||
|
<div className="px-4 py-4" style={{ borderBottom: "2px solid var(--border)" }}>
|
||||||
|
<Link
|
||||||
|
href={`/courses/${course.slug}`}
|
||||||
|
className="font-bold text-sm leading-snug block"
|
||||||
|
style={{ color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{course.title}
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-xs mt-1 block underline"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
← Все курсы
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modules and lessons */}
|
||||||
|
<nav className="flex-1 py-2">
|
||||||
|
{course.modules.map((mod) => (
|
||||||
|
<div key={mod.id}>
|
||||||
|
<p
|
||||||
|
className="px-4 py-2 text-xs font-bold uppercase tracking-widest"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
{mod.title}
|
||||||
|
</p>
|
||||||
|
{mod.lessons.map((lesson) => {
|
||||||
|
const active = pathname.includes(lesson.id);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={lesson.id}
|
||||||
|
href={`/courses/${course.slug}/lessons/${lesson.id}`}
|
||||||
|
className="block px-4 py-2 text-sm leading-snug border-l-2 transition-colors"
|
||||||
|
style={{
|
||||||
|
borderLeftColor: active ? "var(--foreground)" : "transparent",
|
||||||
|
backgroundColor: active ? "var(--color-highlight)" : "transparent",
|
||||||
|
color: active ? "var(--foreground)" : "var(--foreground)",
|
||||||
|
fontWeight: active ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lesson.title}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <EditorContent editor={editor} />;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user