Files
lms-sb/src/components/admin/sortable-modules.tsx
T
admins 5dfa79d357 Fix all Server Actions imported from dynamic route paths
All admin and student Client Components were importing Server Actions
from paths with dynamic segments ([courseId], [moduleId], [lessonId], [slug]).
This caused "Cannot access toStringTag on the server" RSC crash.

Consolidated all Server Actions into static files under src/lib/actions/:
- course-actions.ts  (modules + enrollment)
- module-actions.ts  (lessons + reorder + move)
- user-actions.ts    (bulk grant / revoke)
- student-actions.ts (progress + homework + comments)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 14:05:26 +05:00

208 lines
7.0 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 { createModule, deleteModule, updateModule, reorderModules } from "@/lib/actions/course-actions";
interface Module {
id: string;
title: string;
description: string | null;
order: number;
_count: { lessons: number };
}
function SortableModule({ mod, courseId }: { mod: Module; courseId: string }) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(mod.title);
const [editDesc, setEditDesc] = useState(mod.description ?? "");
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.4 : 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, border: "2px solid var(--border)", background: "var(--color-surface)" }}
className="flex items-center gap-3 px-4 py-3"
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing text-lg select-none"
style={{ color: "var(--muted-foreground)" }}
aria-label="Перетащить"
>
</button>
{editing ? (
<form onSubmit={handleUpdate} className="flex flex-col gap-2 flex-1">
<div className="flex items-center gap-2">
<input
name="title"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
required
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" }}
/>
</form>
) : (
<>
<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)" }}>
{mod._count.lessons} {mod._count.lessons === 1 ? "урок" : mod._count.lessons < 5 ? "урока" : "уроков"}
</span>
<Link
href={`/admin/courses/${courseId}/modules/${mod.id}`}
className="btn-aubade text-xs px-3 py-1"
>
Уроки
</Link>
<button
type="button"
onClick={() => setEditing(true)}
className="text-xs"
style={{ color: "var(--muted-foreground)" }}
>
Переименовать
</button>
<button
type="button"
onClick={handleDelete}
disabled={pending}
className="text-xs"
style={{ color: "oklch(0.577 0.245 27.325)" }}
>
Удалить
</button>
</>
)}
</div>
);
}
export function SortableModules({ courseId, modules }: { courseId: string; modules: Module[] }) {
const [items, setItems] = useState(modules);
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((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-2">
<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 py-3" style={{ color: "var(--muted-foreground)" }}>
Модулей пока нет. Добавьте первый.
</p>
)}
<form onSubmit={handleCreate} className="flex gap-2 pt-3">
<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)")}
/>
<button type="submit" disabled={creating} className="btn-aubade text-xs px-4 py-2">
{creating ? "Создание..." : "+ Модуль"}
</button>
</form>
</div>
);
}