Stage 1: Course/Module/Lesson CRUD admin UI with TipTap editor
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
"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 { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createLesson, deleteLesson, updateLesson, reorderLessons } from "@/app/admin/courses/[courseId]/modules/[moduleId]/actions";
|
||||
|
||||
interface Lesson {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
function SortableLesson({
|
||||
lesson,
|
||||
courseId,
|
||||
moduleId,
|
||||
}: {
|
||||
lesson: Lesson;
|
||||
courseId: string;
|
||||
moduleId: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
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.5 : 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));
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex items-center gap-3 bg-slate-50 border border-slate-200 rounded-xl px-4 py-3">
|
||||
<button type="button" {...attributes} {...listeners} className="text-slate-300 hover:text-slate-500 cursor-grab active:cursor-grabbing">
|
||||
⋮⋮
|
||||
</button>
|
||||
{editing ? (
|
||||
<form onSubmit={handleUpdate} className="flex items-center gap-2 flex-1">
|
||||
<Input name="title" defaultValue={lesson.title} autoFocus className="h-8 text-sm" />
|
||||
<Button type="submit" size="sm" disabled={pending}>Сохранить</Button>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>Отмена</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 font-medium text-slate-700">{lesson.title}</span>
|
||||
<Badge variant={lesson.published ? "default" : "secondary"} className="text-xs">
|
||||
{lesson.published ? "Опубликован" : "Черновик"}
|
||||
</Badge>
|
||||
<Link
|
||||
href={`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lesson.id}`}
|
||||
className="text-xs text-amber-600 hover:underline"
|
||||
>
|
||||
Редактировать
|
||||
</Link>
|
||||
<button type="button" onClick={() => setEditing(true)} className="text-xs text-slate-500 hover:text-slate-700">
|
||||
Переименовать
|
||||
</button>
|
||||
<button type="button" onClick={handleDelete} disabled={pending} className="text-xs text-red-400 hover:text-red-600">
|
||||
Удалить
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableLessons({
|
||||
courseId,
|
||||
moduleId,
|
||||
lessons,
|
||||
}: {
|
||||
courseId: string;
|
||||
moduleId: string;
|
||||
lessons: Lesson[];
|
||||
}) {
|
||||
const [items, setItems] = useState(lessons);
|
||||
const [, 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-3">
|
||||
<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} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{items.length === 0 && (
|
||||
<p className="text-sm text-slate-400 py-2">Уроков пока нет. Добавьте первый.</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleCreate} className="flex gap-2 pt-2">
|
||||
<Input name="title" placeholder="Название нового урока" required className="max-w-xs" />
|
||||
<Button type="submit">+ Урок</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user