Add course management improvements: tree view, module descriptions, lesson toggles
- SortableModules: add description textarea in edit form, show description in row - CourseDetailPage: fetch lessons per module, add CourseTree overview section - ModulePage: fetch sibling modules, pass as otherModules to SortableLessons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,13 +10,20 @@ interface Props {
|
|||||||
export default async function ModulePage({ params }: Props) {
|
export default async function ModulePage({ params }: Props) {
|
||||||
const { courseId, moduleId } = await params;
|
const { courseId, moduleId } = await params;
|
||||||
|
|
||||||
const module = await prisma.module.findUnique({
|
const [module, allModules] = await Promise.all([
|
||||||
where: { id: moduleId },
|
prisma.module.findUnique({
|
||||||
include: {
|
where: { id: moduleId },
|
||||||
course: { select: { title: true } },
|
include: {
|
||||||
lessons: { orderBy: { order: "asc" } },
|
course: { select: { title: true } },
|
||||||
},
|
lessons: { orderBy: { order: "asc" } },
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
prisma.module.findMany({
|
||||||
|
where: { courseId, NOT: { id: moduleId } },
|
||||||
|
select: { id: true, title: true },
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!module || module.courseId !== courseId) notFound();
|
if (!module || module.courseId !== courseId) notFound();
|
||||||
|
|
||||||
@@ -41,7 +48,12 @@ export default async function ModulePage({ params }: Props) {
|
|||||||
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
Уроки модуля
|
Уроки модуля
|
||||||
</p>
|
</p>
|
||||||
<SortableLessons courseId={courseId} moduleId={moduleId} lessons={module.lessons} />
|
<SortableLessons
|
||||||
|
courseId={courseId}
|
||||||
|
moduleId={moduleId}
|
||||||
|
lessons={module.lessons}
|
||||||
|
otherModules={allModules}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
|||||||
import { CourseEditForm } from "@/components/admin/course-edit-form";
|
import { CourseEditForm } from "@/components/admin/course-edit-form";
|
||||||
import { SortableModules } from "@/components/admin/sortable-modules";
|
import { SortableModules } from "@/components/admin/sortable-modules";
|
||||||
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
|
import { EnrollmentManager } from "@/components/admin/enrollment-manager";
|
||||||
|
import { CourseTree } from "@/components/admin/course-tree";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ courseId: string }>;
|
params: Promise<{ courseId: string }>;
|
||||||
@@ -18,7 +19,13 @@ export default async function CourseDetailPage({ params }: Props) {
|
|||||||
include: {
|
include: {
|
||||||
modules: {
|
modules: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
include: { _count: { select: { lessons: true } } },
|
include: {
|
||||||
|
_count: { select: { lessons: true } },
|
||||||
|
lessons: {
|
||||||
|
orderBy: { order: "asc" },
|
||||||
|
select: { id: true, title: true, published: true, kinescopeId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
enrollments: {
|
enrollments: {
|
||||||
select: { userId: true, expiresAt: true },
|
select: { userId: true, expiresAt: true },
|
||||||
@@ -73,6 +80,16 @@ export default async function CourseDetailPage({ params }: Props) {
|
|||||||
<SortableModules courseId={courseId} modules={course.modules} />
|
<SortableModules courseId={courseId} modules={course.modules} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Course tree overview */}
|
||||||
|
{course.modules.length > 0 && (
|
||||||
|
<section className="card-aubade p-6 mb-6">
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
Структура курса
|
||||||
|
</p>
|
||||||
|
<CourseTree courseId={courseId} modules={course.modules} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Access management */}
|
{/* Access management */}
|
||||||
<section className="card-aubade p-6">
|
<section className="card-aubade p-6">
|
||||||
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
<p className="text-xs font-bold uppercase tracking-widest mb-5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { createModule, deleteModule, updateModule, reorderModules } from "@/app/
|
|||||||
interface Module {
|
interface Module {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
description: string | null;
|
||||||
order: number;
|
order: number;
|
||||||
_count: { lessons: number };
|
_count: { lessons: number };
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ interface Module {
|
|||||||
function SortableModule({ mod, courseId }: { mod: Module; courseId: string }) {
|
function SortableModule({ mod, courseId }: { mod: Module; courseId: string }) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editValue, setEditValue] = useState(mod.title);
|
const [editValue, setEditValue] = useState(mod.title);
|
||||||
|
const [editDesc, setEditDesc] = useState(mod.description ?? "");
|
||||||
const [pending, startTransition] = useTransition();
|
const [pending, startTransition] = useTransition();
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
useSortable({ id: mod.id });
|
useSortable({ id: mod.id });
|
||||||
@@ -69,31 +71,50 @@ function SortableModule({ mod, courseId }: { mod: Module; courseId: string }) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
|
<form onSubmit={handleUpdate} className="flex flex-col gap-2 flex-1">
|
||||||
<input
|
<div className="flex items-center gap-2">
|
||||||
name="title"
|
<input
|
||||||
value={editValue}
|
name="title"
|
||||||
onChange={(e) => setEditValue(e.target.value)}
|
value={editValue}
|
||||||
autoFocus
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
required
|
autoFocus
|
||||||
className="flex-1 px-2 py-1 text-sm"
|
required
|
||||||
style={{ border: "2px solid var(--foreground)", background: "var(--background)", outline: "none" }}
|
placeholder="Название модуля"
|
||||||
|
className="flex-1 px-2 py-1 text-sm"
|
||||||
|
style={{ border: "2px solid var(--foreground)", background: "var(--background)", outline: "none" }}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={pending} className="btn-aubade text-xs px-3 py-1 shrink-0">
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditing(false); setEditValue(mod.title); setEditDesc(mod.description ?? ""); }}
|
||||||
|
className="text-xs px-3 py-1 shrink-0"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={editDesc}
|
||||||
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
placeholder="Описание модуля (опционально)"
|
||||||
|
rows={2}
|
||||||
|
className="w-full px-2 py-1 text-sm resize-none"
|
||||||
|
style={{ border: "1px solid var(--border)", background: "var(--background)", outline: "none" }}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={pending} className="btn-aubade text-xs px-3 py-1">
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setEditing(false); setEditValue(mod.title); }}
|
|
||||||
className="text-xs px-3 py-1"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 font-medium text-sm">{mod.title}</span>
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-medium text-sm">{mod.title}</span>
|
||||||
|
{mod.description && (
|
||||||
|
<p className="text-xs truncate mt-0.5" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{mod.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
<span className="text-xs" style={{ color: "var(--muted-foreground)" }}>
|
||||||
{mod._count.lessons} {mod._count.lessons === 1 ? "урок" : mod._count.lessons < 5 ? "урока" : "уроков"}
|
{mod._count.lessons} {mod._count.lessons === 1 ? "урок" : mod._count.lessons < 5 ? "урока" : "уроков"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user