Add Markdown import from Obsidian (Stage 8)

- md-to-tiptap.ts: remark-based converter (headings, lists, blockquotes,
  code blocks, bold/italic/strike, links, images, hr)
- Obsidian ![[wikilink]] stripped, [[link|alias]] → plain text
- POST /api/admin/import-md: parses frontmatter (gray-matter) + converts content
- LessonEditor: "Импорт .md" button populates editor without auto-save
- ROADMAP: marked Stages 2, 3, 5, 6, 7, 8 as complete, fixed numbering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 15:44:42 +05:00
parent 6d93a7b406
commit c647b29712
7 changed files with 1085 additions and 92 deletions
+48 -1
View File
@@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Placeholder from "@tiptap/extension-placeholder";
import { Save, Eye } from "lucide-react";
import { Save, Eye, FileUp } from "lucide-react";
import { saveLesson } from "@/app/admin/courses/[courseId]/modules/[moduleId]/lessons/[lessonId]/actions";
interface LessonData {
@@ -32,6 +32,8 @@ export function LessonEditor({
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 [pending, startTransition] = useTransition();
@@ -78,6 +80,34 @@ export function LessonEditor({
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]);
function handleSave() {
if (!editor) return;
startTransition(async () => {
@@ -125,6 +155,17 @@ export function LessonEditor({
</button>
<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-4 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"
@@ -149,6 +190,12 @@ export function LessonEditor({
</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>
)}
{/* Title */}
<div className="space-y-1">
<label className="text-xs font-bold uppercase tracking-widest" style={{ color: "var(--muted-foreground)" }}>