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:
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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,19 +12,30 @@ 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([
|
||||||
where: { id: lessonId },
|
prisma.lesson.findUnique({
|
||||||
include: {
|
where: { id: lessonId },
|
||||||
files: { orderBy: { createdAt: "asc" } },
|
include: {
|
||||||
homework: true,
|
files: { orderBy: { createdAt: "asc" } },
|
||||||
module: {
|
homework: true,
|
||||||
include: { course: { select: { title: true, slug: 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();
|
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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,44 +147,74 @@ 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">
|
||||||
<button
|
{/* Left: published toggle + prev/next */}
|
||||||
type="button"
|
<div className="flex items-center gap-3">
|
||||||
role="switch"
|
<button
|
||||||
aria-checked={published}
|
type="button"
|
||||||
onClick={() => setPublished(!published)}
|
role="switch"
|
||||||
className="flex items-center gap-2 text-sm"
|
aria-checked={published}
|
||||||
>
|
onClick={() => setPublished(!published)}
|
||||||
<span
|
className="flex items-center gap-2 text-sm"
|
||||||
className="relative inline-block w-10 h-6 transition-colors"
|
|
||||||
style={{
|
|
||||||
background: published ? "var(--accent)" : "var(--border)",
|
|
||||||
border: "2px solid var(--foreground)",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="absolute top-0.5 w-4 h-4 transition-transform"
|
className="relative inline-block w-10 h-6 transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: "var(--foreground)",
|
background: published ? "var(--accent)" : "var(--border)",
|
||||||
left: "2px",
|
border: "2px solid var(--foreground)",
|
||||||
transform: published ? "translateX(16px)" : "translateX(0)",
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</span>
|
<span
|
||||||
<span style={{ color: published ? "var(--foreground)" : "var(--muted-foreground)" }}>
|
className="absolute top-0.5 w-4 h-4 transition-transform"
|
||||||
{published ? "Опубликован" : "Черновик"}
|
style={{
|
||||||
</span>
|
background: "var(--foreground)",
|
||||||
</button>
|
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">
|
<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")}>“”</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>
|
<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,
|
||||||
|
|||||||
Reference in New Issue
Block a user