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:
@@ -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 />
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user