Files
lms-sb/src/components/admin/lesson-editor.tsx
T
admins 287347168d Add cross-module lesson navigation and admin edit button
- Admin lesson editor now navigates across module boundaries (full course flat list)
- Student lesson view shows "Edit" button for admins linking to lesson editor

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

408 lines
15 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, useCallback } from "react";
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, ChevronLeft, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation";
interface LessonData {
id: string;
title: string;
kinescopeId: string;
content: object;
published: boolean;
}
interface SiblingLesson {
id: string;
title: string;
moduleId: string;
}
export function LessonEditor({
lesson,
courseId,
courseSlug,
prevLesson,
nextLesson,
}: {
lesson: LessonData;
courseId: 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);
const [uploading, setUploading] = useState(false);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const inputStyle = {
border: "2px solid var(--border)",
background: "var(--background)",
outline: "none",
width: "100%",
padding: "0.5rem 0.75rem",
fontSize: "16px",
fontFamily: "inherit",
} as React.CSSProperties;
const editor = useEditor({
extensions: [
StarterKit,
Underline,
Image.configure({ inline: false }),
Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
],
content: Object.keys(lesson.content).length ? lesson.content : undefined,
editorProps: {
attributes: {
class: "prose prose-slate max-w-none min-h-[300px] focus:outline-none p-4",
},
},
}, [lesson.id]);
const uploadImage = useCallback(async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
const file = input.files?.[0];
if (!file || !editor) return;
setUploading(true);
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/upload", { method: "POST", body: fd });
const data = await res.json();
if (data.url) editor.chain().focus().setImage({ src: data.url }).run();
setUploading(false);
};
input.click();
}, [editor]);
const importMd = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = ".md";
input.onchange = async () => {
const file = input.files?.[0];
if (!file || !editor) return;
setImporting(true);
setImportError(null);
try {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/admin/import-md", { method: "POST", body: fd });
if (!res.ok) throw new Error("Ошибка импорта");
const data = await res.json();
if (data.title) setTitle(data.title);
if (data.kinescopeId) setKinescopeId(data.kinescopeId);
if (data.published !== null) setPublished(data.published);
if (data.content) editor.commands.setContent(data.content);
} catch {
setImportError("Не удалось импортировать файл");
} finally {
setImporting(false);
}
};
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]);
async function handleSave() {
if (!editor) return;
setPending(true);
setSaveError(null);
try {
const res = await fetch(`/api/admin/lessons/${lesson.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, kinescopeId, content: editor.getJSON(), published }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
}
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : "Ошибка сохранения");
} finally {
setPending(false);
}
}
function navigateTo(target: SiblingLesson) {
router.push(`/admin/courses/${courseId}/modules/${target.moduleId}/lessons/${target.id}`);
}
return (
<div className="space-y-5">
{/* Header controls */}
<div
className="flex items-center justify-between gap-2 flex-wrap"
style={{
position: "sticky",
top: 0,
zIndex: 30,
background: "var(--background)",
marginLeft: "-1.5rem",
marginRight: "-1.5rem",
paddingLeft: "1.5rem",
paddingRight: "1.5rem",
paddingTop: "0.75rem",
paddingBottom: "0.75rem",
borderBottom: "2px solid var(--border)",
}}
>
{/* 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="relative inline-block w-10 h-6 transition-colors"
style={{
background: published ? "var(--accent)" : "var(--border)",
border: "2px solid var(--foreground)",
}}
>
<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)}
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)}
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-3 py-2 text-sm"
title="Импортировать из .md файла Obsidian"
style={{ opacity: importing || pending ? 0.6 : 1 }}
>
<FileUp size={14} />
{importing ? "Импорт..." : "Импорт .md"}
</button>
<a
href={`/courses/${courseSlug}/lessons/${lesson.id}`}
target="_blank"
rel="noopener noreferrer"
className="btn-aubade flex items-center gap-1.5 px-3 py-2 text-sm"
title="Просмотр как студент"
>
<Eye size={14} />
Просмотр
</a>
<button
type="button"
onClick={handleSave}
disabled={pending || uploading}
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="Сохранить урок"
>
<Save size={14} />
{pending ? "Сохранение..." : saved ? "Сохранено" : "Сохранить"}
</button>
</div>
</div>
{importError && (
<p className="text-xs px-3 py-2" style={{ background: "oklch(0.577 0.245 27.325 / 0.1)", color: "oklch(0.577 0.245 27.325)", border: "1px solid oklch(0.577 0.245 27.325 / 0.3)" }}>
{importError}
</p>
)}
{saveError && (
<p className="text-xs px-3 py-2" style={{ background: "oklch(0.577 0.245 27.325 / 0.1)", color: "oklch(0.577 0.245 27.325)", border: "1px solid oklch(0.577 0.245 27.325 / 0.3)" }}>
Ошибка сохранения: {saveError}
</p>
)}
{/* Title */}
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Заголовок урока
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ ...inputStyle, fontSize: "1.1rem", fontWeight: "700" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
{/* Kinescope ID */}
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Kinescope Video ID
</label>
<input
value={kinescopeId}
onChange={(e) => setKinescopeId(e.target.value)}
placeholder="Оставьте пустым если видео нет"
style={{ ...inputStyle, fontFamily: "var(--font-mono)" }}
onFocus={(e) => (e.currentTarget.style.borderColor = "var(--foreground)")}
onBlur={(e) => (e.currentTarget.style.borderColor = "var(--border)")}
/>
</div>
{/* TipTap Editor */}
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>
Содержимое урока
</label>
{/* Toolbar */}
<div
className="flex flex-wrap gap-0.5 p-2"
style={{
position: "sticky",
top: "62px",
zIndex: 20,
border: "2px solid var(--border)",
borderBottom: "1px solid var(--border)",
background: "var(--color-surface)",
}}
>
{/* 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>
<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")}>&ldquo;&rdquo; Цитата</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>
<Sep />
{/* History */}
<ToolBtn onClick={() => editor?.chain().focus().undo().run()}> Отменить</ToolBtn>
<ToolBtn onClick={() => editor?.chain().focus().redo().run()}> Повторить</ToolBtn>
</div>
{/* Editor content */}
<div style={{ border: "2px solid var(--border)", borderTop: "none", background: "var(--background)" }}>
<EditorContent editor={editor} />
</div>
</div>
</div>
);
}
function Sep() {
return <div className="w-px mx-1 self-stretch" style={{ background: "var(--border)" }} />;
}
function ToolBtn({
onClick,
active,
disabled,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="px-2 py-1 text-xs transition-colors disabled:opacity-50"
style={{
background: active ? "var(--foreground)" : "transparent",
color: active ? "var(--background)" : "var(--foreground)",
border: "1px solid transparent",
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.background = "var(--border)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.background = "transparent"; }}
>
{children}
</button>
);
}