Enhance lesson editor: prev/next nav + richer toolbar
- page.tsx: fetch sibling lessons, pass prevLesson/nextLesson props - LessonEditor: ChevronLeft/Right nav buttons with lesson title tooltip - Toolbar: added Underline, Strikethrough, inline Code, H1, Horizontal rule, Link dialog (prompt), labeled buttons for better discoverability - Install @tiptap/extension-underline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,19 +12,30 @@ interface Props {
|
||||
export default async function LessonEditorPage({ params }: Props) {
|
||||
const { courseId, moduleId, lessonId } = await params;
|
||||
|
||||
const lesson = await prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
files: { orderBy: { createdAt: "asc" } },
|
||||
homework: true,
|
||||
module: {
|
||||
include: { course: { select: { title: true, slug: true } } },
|
||||
const [lesson, siblings] = await Promise.all([
|
||||
prisma.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
include: {
|
||||
files: { orderBy: { createdAt: "asc" } },
|
||||
homework: true,
|
||||
module: {
|
||||
include: { course: { select: { title: true, slug: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
prisma.lesson.findMany({
|
||||
where: { moduleId },
|
||||
orderBy: { order: "asc" },
|
||||
select: { id: true, title: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!lesson || lesson.moduleId !== moduleId) notFound();
|
||||
|
||||
const idx = siblings.findIndex((l) => l.id === lessonId);
|
||||
const prevLesson = idx > 0 ? siblings[idx - 1] : null;
|
||||
const nextLesson = idx < siblings.length - 1 ? siblings[idx + 1] : null;
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-4xl">
|
||||
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
|
||||
@@ -50,6 +61,8 @@ export default async function LessonEditorPage({ params }: Props) {
|
||||
courseId={courseId}
|
||||
moduleId={moduleId}
|
||||
courseSlug={lesson.module.course.slug}
|
||||
prevLesson={prevLesson}
|
||||
nextLesson={nextLesson}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,8 +5,10 @@ import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Save, Eye, FileUp } from "lucide-react";
|
||||
import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
|
||||
|
||||
interface LessonData {
|
||||
@@ -17,17 +19,27 @@ interface LessonData {
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
interface SiblingLesson {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function LessonEditor({
|
||||
lesson,
|
||||
courseId,
|
||||
moduleId,
|
||||
courseSlug,
|
||||
prevLesson,
|
||||
nextLesson,
|
||||
}: {
|
||||
lesson: LessonData;
|
||||
courseId: string;
|
||||
moduleId: string;
|
||||
courseSlug: string;
|
||||
prevLesson?: SiblingLesson | null;
|
||||
nextLesson?: SiblingLesson | null;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [title, setTitle] = useState(lesson.title);
|
||||
const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId);
|
||||
const [published, setPublished] = useState(lesson.published);
|
||||
@@ -50,6 +62,7 @@ export function LessonEditor({
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Underline,
|
||||
Image.configure({ inline: false }),
|
||||
Link.configure({ openOnClick: false }),
|
||||
Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
|
||||
@@ -108,6 +121,18 @@ export function LessonEditor({
|
||||
input.click();
|
||||
}, [editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const prev = editor.getAttributes("link").href as string | undefined;
|
||||
const url = window.prompt("Ссылка:", prev ?? "https://");
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
editor.chain().focus().unsetLink().run();
|
||||
} else {
|
||||
editor.chain().focus().setLink({ href: url, target: "_blank" }).run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
function handleSave() {
|
||||
if (!editor) return;
|
||||
startTransition(async () => {
|
||||
@@ -122,44 +147,74 @@ export function LessonEditor({
|
||||
});
|
||||
}
|
||||
|
||||
function navigateTo(lessonId: string) {
|
||||
router.push(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={published}
|
||||
onClick={() => setPublished(!published)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span
|
||||
className="relative inline-block w-10 h-6 transition-colors"
|
||||
style={{
|
||||
background: published ? "var(--accent)" : "var(--border)",
|
||||
border: "2px solid var(--foreground)",
|
||||
}}
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
{/* Left: published toggle + prev/next */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={published}
|
||||
onClick={() => setPublished(!published)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-4 h-4 transition-transform"
|
||||
className="relative inline-block w-10 h-6 transition-colors"
|
||||
style={{
|
||||
background: "var(--foreground)",
|
||||
left: "2px",
|
||||
transform: published ? "translateX(16px)" : "translateX(0)",
|
||||
background: published ? "var(--accent)" : "var(--border)",
|
||||
border: "2px solid var(--foreground)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span style={{ color: published ? "var(--foreground)" : "var(--muted-foreground)" }}>
|
||||
{published ? "Опубликован" : "Черновик"}
|
||||
</span>
|
||||
</button>
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-4 h-4 transition-transform"
|
||||
style={{
|
||||
background: "var(--foreground)",
|
||||
left: "2px",
|
||||
transform: published ? "translateX(16px)" : "translateX(0)",
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<span style={{ color: published ? "var(--foreground)" : "var(--muted-foreground)" }}>
|
||||
{published ? "Опубликован" : "Черновик"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Prev / Next navigation */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => prevLesson && navigateTo(prevLesson.id)}
|
||||
disabled={!prevLesson}
|
||||
title={prevLesson ? `← ${prevLesson.title}` : "Первый урок в модуле"}
|
||||
className="btn-aubade flex items-center gap-1 px-2 py-2 text-sm disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nextLesson && navigateTo(nextLesson.id)}
|
||||
disabled={!nextLesson}
|
||||
title={nextLesson ? `${nextLesson.title} →` : "Последний урок в модуле"}
|
||||
className="btn-aubade flex items-center gap-1 px-2 py-2 text-sm disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Import / Preview / Save */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={importMd}
|
||||
disabled={importing || pending}
|
||||
className="btn-aubade flex items-center gap-1.5 px-4 py-2 text-sm"
|
||||
className="btn-aubade flex items-center gap-1.5 px-3 py-2 text-sm"
|
||||
title="Импортировать из .md файла Obsidian"
|
||||
style={{ opacity: importing || pending ? 0.6 : 1 }}
|
||||
>
|
||||
@@ -170,7 +225,7 @@ export function LessonEditor({
|
||||
href={`/courses/${courseSlug}/lessons/${lesson.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-aubade flex items-center gap-1.5 px-4 py-2 text-sm"
|
||||
className="btn-aubade flex items-center gap-1.5 px-3 py-2 text-sm"
|
||||
title="Просмотр как студент"
|
||||
>
|
||||
<Eye size={14} />
|
||||
@@ -180,7 +235,7 @@ export function LessonEditor({
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={pending || uploading}
|
||||
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-4 py-2 text-sm"
|
||||
className="btn-aubade btn-aubade-accent flex items-center gap-1.5 px-3 py-2 text-sm"
|
||||
style={{ opacity: pending || uploading ? 0.6 : 1 }}
|
||||
title="Сохранить урок"
|
||||
>
|
||||
@@ -233,23 +288,43 @@ export function LessonEditor({
|
||||
|
||||
{/* Toolbar */}
|
||||
<div
|
||||
className="flex flex-wrap gap-1 p-2"
|
||||
className="flex flex-wrap gap-0.5 p-2"
|
||||
style={{ border: "2px solid var(--border)", borderBottom: "1px solid var(--border)", background: "var(--color-surface)" }}
|
||||
>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}>Ж</ToolBtn>
|
||||
{/* Text style */}
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBold().run()} active={editor?.isActive("bold")}><strong>Ж</strong></ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleItalic().run()} active={editor?.isActive("italic")}><em>К</em></ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleUnderline().run()} active={editor?.isActive("underline")}><span style={{ textDecoration: "underline" }}>Ч</span></ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleStrike().run()} active={editor?.isActive("strike")}><span style={{ textDecoration: "line-through" }}>З</span></ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleCode().run()} active={editor?.isActive("code")}>`code`</ToolBtn>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Headings */}
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleHeading({ level: 1 }).run()} active={editor?.isActive("heading", { level: 1 })}>H1</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleHeading({ level: 2 }).run()} active={editor?.isActive("heading", { level: 2 })}>H2</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleHeading({ level: 3 }).run()} active={editor?.isActive("heading", { level: 3 })}>H3</ToolBtn>
|
||||
<div className="w-px mx-1" style={{ background: "var(--border)" }} />
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Lists & blocks */}
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBulletList().run()} active={editor?.isActive("bulletList")}>• Список</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleOrderedList().run()} active={editor?.isActive("orderedList")}>1. Список</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>“”</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleCodeBlock().run()} active={editor?.isActive("codeBlock")}>{'</>'}</ToolBtn>
|
||||
<div className="w-px mx-1" style={{ background: "var(--border)" }} />
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>“” Цитата</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().toggleCodeBlock().run()} active={editor?.isActive("codeBlock")}>{'</>'} Код</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().setHorizontalRule().run()}>── Разделитель</ToolBtn>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* Link & image */}
|
||||
<ToolBtn onClick={addLink} active={editor?.isActive("link")}>🔗 Ссылка</ToolBtn>
|
||||
<ToolBtn onClick={uploadImage} disabled={uploading}>{uploading ? "Загрузка..." : "🖼 Фото"}</ToolBtn>
|
||||
<div className="w-px mx-1" style={{ background: "var(--border)" }} />
|
||||
<ToolBtn onClick={() => editor?.chain().focus().undo().run()}>↩</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}>↪</ToolBtn>
|
||||
|
||||
<Sep />
|
||||
|
||||
{/* History */}
|
||||
<ToolBtn onClick={() => editor?.chain().focus().undo().run()}>↩ Отменить</ToolBtn>
|
||||
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}>↪ Повторить</ToolBtn>
|
||||
</div>
|
||||
|
||||
{/* Editor content */}
|
||||
@@ -261,6 +336,10 @@ export function LessonEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <div className="w-px mx-1 self-stretch" style={{ background: "var(--border)" }} />;
|
||||
}
|
||||
|
||||
function ToolBtn({
|
||||
onClick,
|
||||
active,
|
||||
|
||||
Reference in New Issue
Block a user