287347168d
- 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>
408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
"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")}>“” Цитата</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>
|
||
);
|
||
}
|