From 768a38b9d3f81981b6ee002c645124052efe47ba Mon Sep 17 00:00:00 2001 From: dmitriylaukhin Date: Wed, 8 Apr 2026 13:32:30 +0500 Subject: [PATCH] Add course tree, lesson actions, and module description schema - CourseTree: expandable module/lesson overview with Eye/Video icons - SortableLessons: Kinescope ID in create form, published toggle, move-to-module dropdown - Actions: toggleLessonPublished, moveLessonToModule, updateModule with description - Schema: add description field to Module model + migration Co-Authored-By: Claude Sonnet 4.6 --- .../migration.sql | 1 + prisma/schema.prisma | 13 +- src/app/admin/courses/[courseId]/actions.ts | 3 +- .../[courseId]/modules/[moduleId]/actions.ts | 48 ++++- src/components/admin/course-tree.tsx | 173 ++++++++++++++++++ src/components/admin/sortable-lessons.tsx | 152 +++++++++++---- 6 files changed, 351 insertions(+), 39 deletions(-) create mode 100644 prisma/migrations/20260408200000_add_module_description/migration.sql create mode 100644 src/components/admin/course-tree.tsx diff --git a/prisma/migrations/20260408200000_add_module_description/migration.sql b/prisma/migrations/20260408200000_add_module_description/migration.sql new file mode 100644 index 0000000..6ecd3bc --- /dev/null +++ b/prisma/migrations/20260408200000_add_module_description/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "Module" ADD COLUMN "description" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 462d932..daab18d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -108,12 +108,13 @@ model Course { } model Module { - id String @id @default(cuid()) - courseId String - title String - order Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + courseId String + title String + description String? + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt course Course @relation(fields: [courseId], references: [id], onDelete: Cascade) lessons Lesson[] diff --git a/src/app/admin/courses/[courseId]/actions.ts b/src/app/admin/courses/[courseId]/actions.ts index c6e4b23..744a69e 100644 --- a/src/app/admin/courses/[courseId]/actions.ts +++ b/src/app/admin/courses/[courseId]/actions.ts @@ -27,7 +27,8 @@ export async function createModule(courseId: string, formData: FormData) { export async function updateModule(moduleId: string, courseId: string, formData: FormData) { await requireAdmin(); const title = formData.get("title") as string; - await prisma.module.update({ where: { id: moduleId }, data: { title } }); + const description = (formData.get("description") as string | null)?.trim() || null; + await prisma.module.update({ where: { id: moduleId }, data: { title, description } }); revalidatePath(`/admin/courses/${courseId}`); } diff --git a/src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts b/src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts index 0dde80d..b710b19 100644 --- a/src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts +++ b/src/app/admin/courses/[courseId]/modules/[moduleId]/actions.ts @@ -14,8 +14,11 @@ async function requireAdmin() { export async function createLesson(moduleId: string, courseId: string, formData: FormData) { await requireAdmin(); const title = formData.get("title") as string; + const kinescopeId = (formData.get("kinescopeId") as string | null)?.trim() || null; const count = await prisma.lesson.count({ where: { moduleId } }); - const lesson = await prisma.lesson.create({ data: { moduleId, title, order: count } }); + const lesson = await prisma.lesson.create({ + data: { moduleId, title, kinescopeId, order: count }, + }); revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); redirect(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`); } @@ -42,3 +45,46 @@ export async function reorderLessons(moduleId: string, courseId: string, ordered ); revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); } + +export async function toggleLessonPublished( + lessonId: string, + courseId: string, + moduleId: string, + currentValue: boolean +) { + await requireAdmin(); + await prisma.lesson.update({ + where: { id: lessonId }, + data: { published: !currentValue }, + }); + revalidatePath(`/admin/courses/${courseId}/modules/${moduleId}`); + revalidatePath(`/admin/courses/${courseId}`); +} + +export async function moveLessonToModule( + lessonId: string, + targetModuleId: string, + courseId: string, + sourceModuleId: string +) { + await requireAdmin(); + // verify target module belongs to same course + const target = await prisma.module.findFirst({ + where: { id: targetModuleId, courseId }, + }); + if (!target) throw new Error("Module not found"); + + const maxOrder = await prisma.lesson.aggregate({ + where: { moduleId: targetModuleId }, + _max: { order: true }, + }); + + await prisma.lesson.update({ + where: { id: lessonId }, + data: { moduleId: targetModuleId, order: (maxOrder._max.order ?? -1) + 1 }, + }); + + revalidatePath(`/admin/courses/${courseId}/modules/${sourceModuleId}`); + revalidatePath(`/admin/courses/${courseId}/modules/${targetModuleId}`); + revalidatePath(`/admin/courses/${courseId}`); +} diff --git a/src/components/admin/course-tree.tsx b/src/components/admin/course-tree.tsx new file mode 100644 index 0000000..b6002f3 --- /dev/null +++ b/src/components/admin/course-tree.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ChevronDown, ChevronRight, Video, FileText, Eye, EyeOff } from "lucide-react"; + +type Lesson = { + id: string; + title: string; + published: boolean; + kinescopeId: string | null; +}; + +type Module = { + id: string; + title: string; + description: string | null; + lessons: Lesson[]; +}; + +export function CourseTree({ + courseId, + modules, +}: { + courseId: string; + modules: Module[]; +}) { + // All modules expanded by default + const [expanded, setExpanded] = useState>( + () => new Set(modules.map((m) => m.id)) + ); + + function toggle(id: string) { + setExpanded((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + } + + const totalLessons = modules.reduce((s, m) => s + m.lessons.length, 0); + const publishedLessons = modules.reduce( + (s, m) => s + m.lessons.filter((l) => l.published).length, + 0 + ); + + return ( +
+ {/* Summary bar */} +
+ {modules.length} модулей · {totalLessons} уроков + + + {publishedLessons} / {totalLessons} опубликовано + + + +
+ +
+ {modules.map((mod, mi) => { + const isOpen = expanded.has(mod.id); + const modPublished = mod.lessons.filter((l) => l.published).length; + return ( +
+ {/* Module header */} + + + {/* Lessons list */} + {isOpen && ( +
+ {mod.lessons.length === 0 ? ( +

+ Уроков нет +

+ ) : ( + mod.lessons.map((lesson, li) => ( +
0 ? "1px solid var(--border)" : undefined, + background: "var(--background)", + }} + > + {/* Indent */} + + {/* Index */} + + {li + 1} + + {/* Published indicator */} + + {lesson.published ? : } + + {/* Kinescope indicator */} + + {lesson.kinescopeId ? + {/* Title */} + {lesson.title} + {/* Edit link */} + + Ред. → + +
+ )) + )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/admin/sortable-lessons.tsx b/src/components/admin/sortable-lessons.tsx index a28c6eb..b72e504 100644 --- a/src/components/admin/sortable-lessons.tsx +++ b/src/components/admin/sortable-lessons.tsx @@ -17,23 +17,39 @@ import { } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import Link from "next/link"; -import { createLesson, deleteLesson, updateLesson, reorderLessons } from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions"; +import { Eye, EyeOff, Video } from "lucide-react"; +import { + createLesson, + deleteLesson, + updateLesson, + reorderLessons, + toggleLessonPublished, + moveLessonToModule, +} from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions"; interface Lesson { id: string; title: string; order: number; published: boolean; + kinescopeId: string | null; +} + +interface OtherModule { + id: string; + title: string; } function SortableLesson({ lesson, courseId, moduleId, + otherModules, }: { lesson: Lesson; courseId: string; moduleId: string; + otherModules: OtherModule[]; }) { const [editing, setEditing] = useState(false); const [editValue, setEditValue] = useState(lesson.title); @@ -59,23 +75,42 @@ function SortableLesson({ startTransition(() => deleteLesson(lesson.id, courseId, moduleId)); } + function handleTogglePublished() { + startTransition(() => toggleLessonPublished(lesson.id, courseId, moduleId, lesson.published)); + } + + function handleMove(targetModuleId: string) { + if (!targetModuleId) return; + startTransition(() => moveLessonToModule(lesson.id, targetModuleId, courseId, moduleId)); + } + return (
+ {/* Drag handle */} + {/* Kinescope indicator */} + + + {editing ? (
) : ( <> - {lesson.title} - {lesson.title} + + {/* Published toggle */} + + + {/* Edit */} - Редактировать → + Ред. → + + {/* Rename */} + + {/* Move to module */} + {otherModules.length > 0 && ( + + )} + + {/* Delete */} + {/* Quick create form */} + +
+ (e.currentTarget.style.borderColor = "var(--foreground)")} + onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} + /> + (e.currentTarget.style.borderColor = "var(--foreground)")} + onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")} + /> + +
);