Add in-content video embed and image replace/delete controls

- KinescopeVideo TipTap node: insert video anywhere in lesson content via toolbar button; editor shows ID preview, student view renders KinescopePlayer
- ImageWithControls NodeView: hover over any image in editor to reveal Replace and Delete buttons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 11:27:52 +05:00
parent 287347168d
commit 7e6ad8067c
2 changed files with 200 additions and 3 deletions
+159 -2
View File
@@ -1,7 +1,8 @@
"use client"; "use client";
import { useState, useCallback } from "react"; 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 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";
@@ -10,6 +11,153 @@ import Placeholder from "@tiptap/extension-placeholder";
import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react"; import { Save, Eye, FileUp, ChevronLeft, ChevronRight } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
// ── Image NodeView: hover controls (replace / delete) ──────────────────────
function ImageWithControls({ node, updateAttributes, deleteNode }: {
node: { attrs: Record<string, unknown> };
updateAttributes: (attrs: Record<string, unknown>) => 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 (
<NodeViewWrapper
style={{ display: "block", position: "relative" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<img
src={node.attrs.src as string}
alt={(node.attrs.alt as string) ?? ""}
style={{ maxWidth: "100%", display: "block" }}
/>
{hovered && (
<div
style={{
position: "absolute",
top: "8px",
right: "8px",
display: "flex",
gap: "4px",
}}
>
<button type="button" onClick={handleReplace} disabled={uploading} style={btnStyle}>
{uploading ? "Загрузка..." : "Заменить"}
</button>
<button type="button" onClick={() => deleteNode()} style={{ ...btnStyle, borderColor: "oklch(0.577 0.245 27.325)", color: "oklch(0.577 0.245 27.325)" }}>
Удалить
</button>
</div>
)}
</NodeViewWrapper>
);
}
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<string, unknown> };
updateAttributes: (attrs: Record<string, unknown>) => 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 (
<NodeViewWrapper data-drag-handle>
<div
style={{
border: "2px solid var(--border)",
padding: "0.6rem 1rem",
background: "var(--color-surface)",
display: "flex",
alignItems: "center",
gap: "0.75rem",
fontSize: "13px",
margin: "0.5rem 0",
}}
>
<span style={{ color: "var(--muted-foreground)" }}> Kinescope:</span>
<code style={{ flex: 1, fontFamily: "var(--font-mono)", fontSize: "12px" }}>
{videoId || <span style={{ opacity: 0.4 }}>не задан</span>}
</code>
<button type="button" onClick={changeVideoId} className="btn-aubade px-2 py-1 text-xs">
Изменить
</button>
<button type="button" onClick={() => deleteNode()} className="btn-aubade px-2 py-1 text-xs" style={{ color: "oklch(0.577 0.245 27.325)" }}>
Удалить
</button>
</div>
</NodeViewWrapper>
);
}
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 { interface LessonData {
id: string; id: string;
title: string; title: string;
@@ -62,9 +210,10 @@ export function LessonEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
Underline, Underline,
Image.configure({ inline: false }), ImageWithControlsExtension.configure({ inline: false }),
Link.configure({ openOnClick: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: "Начните писать текст урока..." }), Placeholder.configure({ placeholder: "Начните писать текст урока..." }),
KinescopeVideoExtension,
], ],
content: Object.keys(lesson.content).length ? lesson.content : undefined, content: Object.keys(lesson.content).length ? lesson.content : undefined,
editorProps: { editorProps: {
@@ -120,6 +269,13 @@ export function LessonEditor({
input.click(); input.click();
}, [editor]); }, [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(() => { const addLink = useCallback(() => {
if (!editor) return; if (!editor) return;
const prev = editor.getAttributes("link").href as string | undefined; const prev = editor.getAttributes("link").href as string | undefined;
@@ -355,6 +511,7 @@ export function LessonEditor({
{/* Link & image */} {/* Link & image */}
<ToolBtn onClick={addLink} active={editor?.isActive("link")}>🔗 Ссылка</ToolBtn> <ToolBtn onClick={addLink} active={editor?.isActive("link")}>🔗 Ссылка</ToolBtn>
<ToolBtn onClick={uploadImage} disabled={uploading}>{uploading ? "Загрузка..." : "🖼 Фото"}</ToolBtn> <ToolBtn onClick={uploadImage} disabled={uploading}>{uploading ? "Загрузка..." : "🖼 Фото"}</ToolBtn>
<ToolBtn onClick={insertKinescopeVideo}> Видео</ToolBtn>
<Sep /> <Sep />
+41 -1
View File
@@ -1,16 +1,56 @@
"use client"; "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 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 { KinescopePlayer } from "@/components/player/kinescope-player";
function KinescopeVideoStudentView({ node }: { node: { attrs: Record<string, unknown> } }) {
const videoId = node.attrs.videoId as string;
if (!videoId) return null;
return (
<NodeViewWrapper>
<div className="my-6">
<KinescopePlayer videoId={videoId} />
</div>
</NodeViewWrapper>
);
}
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 }) { export function LessonContent({ content }: { content: object }) {
const editor = useEditor({ const editor = useEditor({
extensions: [ extensions: [
StarterKit, StarterKit,
Underline,
Image.configure({ inline: false }), Image.configure({ inline: false }),
Link.configure({ openOnClick: true }), Link.configure({ openOnClick: true }),
KinescopeVideoExtension,
], ],
content, content,
editable: false, editable: false,