Stage 1: Course/Module/Lesson CRUD admin UI with TipTap editor
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
"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 { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { createModule, deleteModule, updateModule, reorderModules } from "@/app/admin/courses/[courseId]/actions";
|
||||
|
||||
interface Module {
|
||||
id: string;
|
||||
title: string;
|
||||
order: number;
|
||||
_count: { lessons: number };
|
||||
}
|
||||
|
||||
function SortableModule({
|
||||
mod,
|
||||
courseId,
|
||||
}: {
|
||||
mod: Module;
|
||||
courseId: string;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||
useSortable({ id: mod.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(() => updateModule(mod.id, courseId, fd));
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
if (!confirm(`Удалить модуль "${mod.title}"? Все уроки будут удалены.`)) return;
|
||||
startTransition(() => deleteModule(mod.id, courseId));
|
||||
}
|
||||
|
||||
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={mod.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">{mod.title}</span>
|
||||
<span className="text-sm text-slate-400">{mod._count.lessons} уроков</span>
|
||||
<Link
|
||||
href={`/admin/courses/${courseId}/modules/${mod.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 SortableModules({ courseId, modules }: { courseId: string; modules: Module[] }) {
|
||||
const [items, setItems] = useState(modules);
|
||||
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((m) => m.id === active.id);
|
||||
const newIndex = items.findIndex((m) => m.id === over.id);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newItems);
|
||||
startTransition(() => reorderModules(courseId, newItems.map((m) => m.id)));
|
||||
}
|
||||
|
||||
function handleCreate(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
e.currentTarget.reset();
|
||||
startTransition(() => createModule(courseId, fd));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={items.map((m) => m.id)} strategy={verticalListSortingStrategy}>
|
||||
{items.map((mod) => (
|
||||
<SortableModule key={mod.id} mod={mod} courseId={courseId} />
|
||||
))}
|
||||
</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