diff --git a/src/components/admin/lesson-editor.tsx b/src/components/admin/lesson-editor.tsx index c0e1fd5..001629f 100644 --- a/src/components/admin/lesson-editor.tsx +++ b/src/components/admin/lesson-editor.tsx @@ -1,7 +1,8 @@ "use client"; import { useState, useCallback } from "react"; -import { useEditor, EditorContent } from "@tiptap/react"; +import { useEditor, EditorContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; +import { Node, mergeAttributes } from "@tiptap/core"; import StarterKit from "@tiptap/starter-kit"; import Image from "@tiptap/extension-image"; import Link from "@tiptap/extension-link"; @@ -10,6 +11,153 @@ import Placeholder from "@tiptap/extension-placeholder"; import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react"; import { useRouter } from "next/navigation"; +// ── Image NodeView: hover controls (replace / delete) ────────────────────── + +function ImageWithControls({ node, updateAttributes, deleteNode }: { + node: { attrs: Record }; + updateAttributes: (attrs: Record) => void; + deleteNode: () => void; +}) { + const [hovered, setHovered] = useState(false); + const [uploading, setUploading] = useState(false); + + async function handleReplace() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = "image/*"; + input.onchange = async () => { + const file = input.files?.[0]; + if (!file) 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) updateAttributes({ src: data.url }); + setUploading(false); + }; + input.click(); + } + + const btnStyle: React.CSSProperties = { + background: "var(--background)", + border: "2px solid var(--foreground)", + color: "var(--foreground)", + padding: "3px 10px", + fontSize: "12px", + cursor: "pointer", + fontFamily: "inherit", + }; + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {(node.attrs.alt + {hovered && ( +
+ + +
+ )} +
+ ); +} + +const ImageWithControlsExtension = Image.extend({ + addNodeView() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ReactNodeViewRenderer(ImageWithControls as any); + }, +}); + +// ── KinescopeVideo TipTap node (editor preview) ──────────────────────────── + +function KinescopeVideoEditorView({ node, updateAttributes, deleteNode }: { + node: { attrs: Record }; + updateAttributes: (attrs: Record) => void; + deleteNode: () => void; +}) { + const videoId = node.attrs.videoId as string; + + function changeVideoId() { + const newId = window.prompt("Kinescope Video ID:", videoId ?? ""); + if (newId === null) return; + if (!newId.trim()) { deleteNode(); return; } + updateAttributes({ videoId: newId.trim() }); + } + + return ( + +
+ ▶ Kinescope: + + {videoId || не задан} + + + +
+
+ ); +} + +const KinescopeVideoExtension = Node.create({ + name: "kinescopeVideo", + group: "block", + atom: true, + draggable: true, + + addAttributes() { + return { videoId: { default: "" } }; + }, + + parseHTML() { + return [{ tag: "div[data-kinescope-video]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes({ "data-kinescope-video": "" }, HTMLAttributes)]; + }, + + addNodeView() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ReactNodeViewRenderer(KinescopeVideoEditorView as any); + }, +}); + interface LessonData { id: string; title: string; @@ -62,9 +210,10 @@ export function LessonEditor({ extensions: [ StarterKit, Underline, - Image.configure({ inline: false }), + ImageWithControlsExtension.configure({ inline: false }), Link.configure({ openOnClick: false }), Placeholder.configure({ placeholder: "Начните писать текст урока..." }), + KinescopeVideoExtension, ], content: Object.keys(lesson.content).length ? lesson.content : undefined, editorProps: { @@ -120,6 +269,13 @@ export function LessonEditor({ input.click(); }, [editor]); + const insertKinescopeVideo = useCallback(() => { + if (!editor) return; + const videoId = window.prompt("Kinescope Video ID:"); + if (!videoId?.trim()) return; + editor.chain().focus().insertContent({ type: "kinescopeVideo", attrs: { videoId: videoId.trim() } }).run(); + }, [editor]); + const addLink = useCallback(() => { if (!editor) return; const prev = editor.getAttributes("link").href as string | undefined; @@ -355,6 +511,7 @@ export function LessonEditor({ {/* Link & image */} 🔗 Ссылка {uploading ? "Загрузка..." : "🖼 Фото"} + ▶ Видео diff --git a/src/components/student/lesson-content.tsx b/src/components/student/lesson-content.tsx index abc7c37..cc36091 100644 --- a/src/components/student/lesson-content.tsx +++ b/src/components/student/lesson-content.tsx @@ -1,16 +1,56 @@ "use client"; -import { useEditor, EditorContent } from "@tiptap/react"; +import { useEditor, EditorContent, NodeViewWrapper, ReactNodeViewRenderer } from "@tiptap/react"; +import { Node, mergeAttributes } from "@tiptap/core"; 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 { KinescopePlayer } from "@/components/player/kinescope-player"; + +function KinescopeVideoStudentView({ node }: { node: { attrs: Record } }) { + const videoId = node.attrs.videoId as string; + if (!videoId) return null; + return ( + +
+ +
+
+ ); +} + +const KinescopeVideoExtension = Node.create({ + name: "kinescopeVideo", + group: "block", + atom: true, + + addAttributes() { + return { videoId: { default: "" } }; + }, + + parseHTML() { + return [{ tag: "div[data-kinescope-video]" }]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes({ "data-kinescope-video": "" }, HTMLAttributes)]; + }, + + addNodeView() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return ReactNodeViewRenderer(KinescopeVideoStudentView as any); + }, +}); export function LessonContent({ content }: { content: object }) { const editor = useEditor({ extensions: [ StarterKit, + Underline, Image.configure({ inline: false }), Link.configure({ openOnClick: true }), + KinescopeVideoExtension, ], content, editable: false,