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:
2026-04-08 10:29:39 +05:00
parent 66b311f17e
commit 093e403f5f
4 changed files with 140 additions and 46 deletions
+1
View File
@@ -21,6 +21,7 @@
"@tiptap/extension-image": "^3.22.2", "@tiptap/extension-image": "^3.22.2",
"@tiptap/extension-link": "^3.22.2", "@tiptap/extension-link": "^3.22.2",
"@tiptap/extension-placeholder": "^3.22.2", "@tiptap/extension-placeholder": "^3.22.2",
"@tiptap/extension-underline": "^3.22.2",
"@tiptap/pm": "^3.22.2", "@tiptap/pm": "^3.22.2",
"@tiptap/react": "^3.22.2", "@tiptap/react": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2", "@tiptap/starter-kit": "^3.22.2",
+1
View File
@@ -26,6 +26,7 @@
"@tiptap/extension-image": "^3.22.2", "@tiptap/extension-image": "^3.22.2",
"@tiptap/extension-link": "^3.22.2", "@tiptap/extension-link": "^3.22.2",
"@tiptap/extension-placeholder": "^3.22.2", "@tiptap/extension-placeholder": "^3.22.2",
"@tiptap/extension-underline": "^3.22.2",
"@tiptap/pm": "^3.22.2", "@tiptap/pm": "^3.22.2",
"@tiptap/react": "^3.22.2", "@tiptap/react": "^3.22.2",
"@tiptap/starter-kit": "^3.22.2", "@tiptap/starter-kit": "^3.22.2",
@@ -12,7 +12,8 @@ interface Props {
export default async function LessonEditorPage({ params }: Props) { export default async function LessonEditorPage({ params }: Props) {
const { courseId, moduleId, lessonId } = await params; const { courseId, moduleId, lessonId } = await params;
const lesson = await prisma.lesson.findUnique({ const [lesson, siblings] = await Promise.all([
prisma.lesson.findUnique({
where: { id: lessonId }, where: { id: lessonId },
include: { include: {
files: { orderBy: { createdAt: "asc" } }, files: { orderBy: { createdAt: "asc" } },
@@ -21,10 +22,20 @@ export default async function LessonEditorPage({ params }: Props) {
include: { course: { select: { title: true, slug: true } } }, 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(); 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 ( return (
<div className="p-8 max-w-4xl"> <div className="p-8 max-w-4xl">
<nav className="text-xs mb-6 uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}> <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} courseId={courseId}
moduleId={moduleId} moduleId={moduleId}
courseSlug={lesson.module.course.slug} courseSlug={lesson.module.course.slug}
prevLesson={prevLesson}
nextLesson={nextLesson}
/> />
</div> </div>
+93 -14
View File
@@ -5,8 +5,10 @@ import { useEditor, EditorContent } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image"; import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder"; 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"; import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
interface LessonData { interface LessonData {
@@ -17,17 +19,27 @@ interface LessonData {
published: boolean; published: boolean;
} }
interface SiblingLesson {
id: string;
title: string;
}
export function LessonEditor({ export function LessonEditor({
lesson, lesson,
courseId, courseId,
moduleId, moduleId,
courseSlug, courseSlug,
prevLesson,
nextLesson,
}: { }: {
lesson: LessonData; lesson: LessonData;
courseId: string; courseId: string;
moduleId: string; moduleId: string;
courseSlug: string; courseSlug: string;
prevLesson?: SiblingLesson | null;
nextLesson?: SiblingLesson | null;
}) { }) {
const router = useRouter();
const [title, setTitle] = useState(lesson.title); const [title, setTitle] = useState(lesson.title);
const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId); const [kinescopeId, setKinescopeId] = useState(lesson.kinescopeId);
const [published, setPublished] = useState(lesson.published); const [published, setPublished] = useState(lesson.published);
@@ -50,6 +62,7 @@ export function LessonEditor({
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
Underline,
Image.configure({ inline: false }), Image.configure({ inline: false }),
Link.configure({ openOnClick: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: "Начните писать текст урока..." }), Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
@@ -108,6 +121,18 @@ export function LessonEditor({
input.click(); input.click();
}, [editor]); }, [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() { function handleSave() {
if (!editor) return; if (!editor) return;
startTransition(async () => { startTransition(async () => {
@@ -122,10 +147,16 @@ export function LessonEditor({
}); });
} }
function navigateTo(lessonId: string) {
router.push(`/admin/courses/${courseId}/modules/${moduleId}/lessons/${lessonId}`);
}
return ( return (
<div className="space-y-5"> <div className="space-y-5">
{/* Header controls */} {/* Header controls */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between gap-2 flex-wrap">
{/* Left: published toggle + prev/next */}
<div className="flex items-center gap-3">
<button <button
type="button" type="button"
role="switch" role="switch"
@@ -154,12 +185,36 @@ export function LessonEditor({
</span> </span>
</button> </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"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={importMd} onClick={importMd}
disabled={importing || pending} 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" title="Импортировать из .md файла Obsidian"
style={{ opacity: importing || pending ? 0.6 : 1 }} style={{ opacity: importing || pending ? 0.6 : 1 }}
> >
@@ -170,7 +225,7 @@ export function LessonEditor({
href={`/courses/${courseSlug}/lessons/${lesson.id}`} href={`/courses/${courseSlug}/lessons/${lesson.id}`}
target="_blank" target="_blank"
rel="noopener noreferrer" 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="Просмотр как студент" title="Просмотр как студент"
> >
<Eye size={14} /> <Eye size={14} />
@@ -180,7 +235,7 @@ export function LessonEditor({
type="button" type="button"
onClick={handleSave} onClick={handleSave}
disabled={pending || uploading} 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 }} style={{ opacity: pending || uploading ? 0.6 : 1 }}
title="Сохранить урок" title="Сохранить урок"
> >
@@ -233,23 +288,43 @@ export function LessonEditor({
{/* Toolbar */} {/* Toolbar */}
<div <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)" }} 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().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: 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> <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().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().toggleOrderedList().run()} active={editor?.isActive("orderedList")}>1. Список</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>&ldquo;&rdquo;</ToolBtn> <ToolBtn onClick={() => editor?.chain().focus().toggleBlockquote().run()} active={editor?.isActive("blockquote")}>&ldquo;&rdquo; Цитата</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().toggleCodeBlock().run()} active={editor?.isActive("codeBlock")}>{'</>'}</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().setHorizontalRule().run()}> Разделитель</ToolBtn>
<Sep />
{/* Link & image */}
<ToolBtn onClick={addLink} active={editor?.isActive("link")}>🔗 Ссылка</ToolBtn>
<ToolBtn onClick={uploadImage} disabled={uploading}>{uploading ? "Загрузка..." : "🖼 Фото"}</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> <Sep />
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}></ToolBtn>
{/* History */}
<ToolBtn onClick={() => editor?.chain().focus().undo().run()}> Отменить</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}> Повторить</ToolBtn>
</div> </div>
{/* Editor content */} {/* 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({ function ToolBtn({
onClick, onClick,
active, active,