Files
lms-sb/src/components/admin/sortable-lessons.tsx
T
admins 768a38b9d3 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 <noreply@anthropic.com>
2026-04-08 13:32:30 +05:00

300 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useTransition } from "react";
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
useSortable,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Link from "next/link";
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);
const [pending, startTransition] = useTransition();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: lesson.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
};
function handleUpdate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
startTransition(() => updateLesson(lesson.id, courseId, moduleId, fd));
setEditing(false);
}
function handleDelete() {
if (!confirm(`Удалить урок "${lesson.title}"?`)) return;
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 (
<div
ref={setNodeRef}
style={{ ...style, border: "2px solid var(--border)", background: "var(--color-surface)", opacity: pending ? 0.5 : 1 }}
className="flex items-center gap-2 px-3 py-2.5"
>
{/* Drag handle */}
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-lg select-none shrink-0"
style={{ color: "var(--muted-foreground)" }}
aria-label="Перетащить"
>
</button>
{/* Kinescope indicator */}
<span
title={lesson.kinescopeId ? `Kinescope: ${lesson.kinescopeId}` : "Без видео"}
className="shrink-0"
style={{ color: lesson.kinescopeId ? "#3A6A3A" : "var(--border)" }}
>
<Video size={13} />
</span>
{editing ? (
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
<input
name="title"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
required
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">
Сохранить
</button>
<button
type="button"
onClick={() => { setEditing(false); setEditValue(lesson.title); }}
className="text-xs px-3 py-1"
style={{ color: "var(--muted-foreground)" }}
>
Отмена
</button>
</form>
) : (
<>
<span className="flex-1 text-sm truncate">{lesson.title}</span>
{/* Published toggle */}
<button
type="button"
onClick={handleTogglePublished}
disabled={pending}
title={lesson.published ? "Скрыть" : "Опубликовать"}
className="shrink-0 flex items-center gap-1 text-xs px-2 py-0.5 transition-opacity hover:opacity-70"
style={{
border: "1px solid var(--border)",
background: lesson.published ? "var(--accent)" : "transparent",
color: lesson.published ? "var(--foreground)" : "var(--muted-foreground)",
}}
>
{lesson.published ? <Eye size={11} /> : <EyeOff size={11} />}
{lesson.published ? "Опубл." : "Черновик"}
</button>
{/* Edit */}
<Link
href={`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`}
className="btn-aubade text-xs px-3 py-1 shrink-0"
>
Ред.
</Link>
{/* Rename */}
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs shrink-0"
style={{ color: "var(--muted-foreground)" }}
>
Переим.
</button>
{/* Move to module */}
{otherModules.length > 0 && (
<select
defaultValue=""
onChange={(e) => handleMove(e.target.value)}
className="text-xs shrink-0"
style={{
border: "1px solid var(--border)",
background: "var(--background)",
color: "var(--muted-foreground)",
padding: "2px 4px",
cursor: "pointer",
maxWidth: 120,
}}
title="Переместить в модуль"
>
<option value="" disabled>Переместить</option>
{otherModules.map((m) => (
<option key={m.id} value={m.id}>{m.title}</option>
))}
</select>
)}
{/* Delete */}
<button
type="button"
onClick={handleDelete}
disabled={pending}
className="text-xs shrink-0"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</>
)}
</div>
);
}
export function SortableLessons({
courseId,
moduleId,
lessons,
otherModules = [],
}: {
courseId: string;
moduleId: string;
lessons: Lesson[];
otherModules?: OtherModule[];
}) {
const [items, setItems] = useState(lessons);
const [creating, startTransition] = useTransition();
const sensors = useSensors(useSensor(PointerSensor));
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = items.findIndex((l) => l.id === active.id);
const newIndex = items.findIndex((l) => l.id === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
startTransition(() => reorderLessons(moduleId, courseId, newItems.map((l) => l.id)));
}
function handleCreate(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const fd = new FormData(e.currentTarget);
e.currentTarget.reset();
startTransition(() => createLesson(moduleId, courseId, fd));
}
return (
<div className="space-y-2">
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={items.map((l) => l.id)} strategy={verticalListSortingStrategy}>
{items.map((lesson) => (
<SortableLesson
key={lesson.id}
lesson={lesson}
courseId={courseId}
moduleId={moduleId}
otherModules={otherModules}
/>
))}
</SortableContext>
</DndContext>
{items.length === 0 && (
<p className="text-sm py-3" style={{ color: "var(--muted-foreground)" }}>
Уроков пока нет. Добавьте первый.
</p>
)}
{/* Quick create form */}
<form onSubmit={handleCreate} className="pt-3 space-y-2">
<div className="flex gap-2">
<input
name="title"
placeholder="Название урока"
required
disabled={creating}
className="flex-1 max-w-xs px-3 py-2 text-sm"
style={{ border: "2px solid var(--border)", background: "var(--background)", outline: "none" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<input
name="kinescopeId"
placeholder="Kinescope ID (опционально)"
disabled={creating}
className="flex-1 max-w-xs px-3 py-2 text-sm font-mono"
style={{ border: "2px solid var(--border)", background: "var(--background)", outline: "none" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
<button type="submit" disabled={creating} className="btn-aubade text-xs px-4 py-2 shrink-0">
{creating ? "Создание..." : "+ Урок"}
</button>
</div>
</form>
</div>
);
}