Files
news_all_spo/web/editor-ui/src/App.jsx
2026-05-18 18:51:00 +05:00

329 lines
12 KiB
JavaScript

import { useState, useEffect, useCallback, useRef } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import TextStyle from '@tiptap/extension-text-style'
import FontFamily from '@tiptap/extension-font-family'
import { Color } from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'
import TextAlign from '@tiptap/extension-text-align'
import Image from '@tiptap/extension-image'
import Link from '@tiptap/extension-link'
import Table from '@tiptap/extension-table'
import TableRow from '@tiptap/extension-table-row'
import TableCell from '@tiptap/extension-table-cell'
import TableHeader from '@tiptap/extension-table-header'
import CharacterCount from '@tiptap/extension-character-count'
import Placeholder from '@tiptap/extension-placeholder'
import { marked } from 'marked'
import { FontSize } from './extensions/FontSize'
import { Video } from './extensions/Video'
import { Header } from './components/Header'
import { Toolbar } from './components/Toolbar'
import { Sidebar } from './components/Sidebar'
import { MediaModal } from './components/MediaModal'
import { api } from './api'
/* ── Route params ── */
const _parts = window.location.pathname.split('/')
const ARTICLE_ID = _parts[_parts.length - 1]
const TOKEN = new URLSearchParams(window.location.search).get('t') || ''
const IS_NEW = ARTICLE_ID === 'new'
const FONTS = [
'Arimo', 'Caladea', 'Cormorant Garamond', 'Cousine', 'Crimson Text',
'EB Garamond', 'Fira Code', 'Inter', 'JetBrains Mono', 'Josefin Sans',
'Lato', 'Libre Baskerville', 'Libre Franklin', 'Lora', 'Merriweather',
'Nunito', 'Open Sans', 'Oswald', 'Outfit', 'Patrick Hand',
'Playfair Display', 'Poppins', 'Roboto Mono', 'Roboto', 'Source Code Pro',
'Tinos',
]
const DEFAULT_META = {
id: null,
title: '',
slug: '',
excerpt: '',
cover_url: null,
source_url: null,
font_family: 'Merriweather',
category_id: null,
tag_names: [],
status: 'draft',
}
export default function App() {
const [meta, setMeta] = useState(DEFAULT_META)
const [categories, setCategories] = useState([])
const [saveStatus, setSaveStatus] = useState('saved')
const [mediaState, setMediaState] = useState(null)
const [htmlMode, setHtmlMode] = useState(false)
const [htmlDraft, setHtmlDraft] = useState('')
const [rawHtml, setRawHtml] = useState(null) // полный HTML-документ, обходит TipTap
const rawHtmlRef = useRef(null)
rawHtmlRef.current = rawHtml
/* Refs so callbacks always see latest values without re-creating them */
const metaRef = useRef(meta)
metaRef.current = meta
const currentIdRef = useRef(IS_NEW ? null : ARTICLE_ID)
const saveTimerRef = useRef(null)
const editorRef = useRef(null) // always points to live editor instance
const articleLoadedRef = useRef(false)
const doSaveRef = useRef(null) // always points to latest doSave
/* ── Editor ── */
const editor = useEditor({
extensions: [
StarterKit,
Underline,
TextStyle,
FontFamily.configure({ types: ['textStyle'] }),
FontSize,
Color,
Highlight.configure({ multicolor: true }),
TextAlign.configure({ types: ['heading', 'paragraph', 'image'] }),
Image.configure({ allowBase64: false }),
Video,
Link.configure({ openOnClick: false, HTMLAttributes: { target: '_blank', rel: 'noopener noreferrer' } }),
Table.configure({ resizable: true }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({ placeholder: 'Начните писать статью…' }),
CharacterCount,
],
onUpdate: () => {
setSaveStatus('unsaved')
clearTimeout(saveTimerRef.current)
// Always call the latest doSave via ref — avoids stale closure
saveTimerRef.current = setTimeout(() => doSaveRef.current?.(), 3000)
},
})
// Keep editorRef current every render so doSave never uses a stale instance
editorRef.current = editor
/* ── Load categories once on mount ── */
useEffect(() => {
api.getCategories(TOKEN).then(setCategories).catch(() => {})
}, [])
/* ── Load article once the editor is ready ── */
useEffect(() => {
if (!editor || IS_NEW || articleLoadedRef.current) return
articleLoadedRef.current = true
api.getArticle(ARTICLE_ID, TOKEN)
.then(data => {
setMeta({
id: data.id,
title: data.title,
slug: data.slug,
excerpt: data.excerpt || '',
cover_url: data.cover_url || null,
source_url: data.source_url || null,
font_family: data.font_family || 'Merriweather',
category_id: data.category?.id || null,
tag_names: (data.tags || []).map(t => t.name),
status: data.status,
})
const html = data.content?.startsWith('<')
? data.content
: marked.parse(data.content || '')
const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(html)
if (isFullDoc) {
setRawHtml(html)
editor.commands.setContent('', false)
} else {
setRawHtml(null)
editor.commands.setContent(html, false)
}
setSaveStatus('saved')
})
.catch(() => {})
}, [editor])
/* ── Save ─────────────────────────────────────────────────────────────────
Uses editorRef + metaRef so this callback is stable (no deps that change).
doSaveRef is updated every render so the auto-save timer always calls the
freshest version.
── */
const doSave = useCallback(async overrideStatus => {
const editor = editorRef.current
if (!editor) return
const m = metaRef.current
setSaveStatus('saving')
const payload = {
title: m.title || 'Без названия',
slug: m.slug || undefined,
content: rawHtmlRef.current ?? editor.getHTML(),
excerpt: m.excerpt,
cover_url: m.cover_url || null,
source_url: m.source_url || null,
font_family: m.font_family,
category_id: m.category_id || null,
tag_names: m.tag_names,
status: overrideStatus ?? m.status,
}
try {
let saved
if (!currentIdRef.current) {
saved = await api.createArticle(payload, TOKEN)
currentIdRef.current = saved.id
setMeta(m => ({ ...m, id: saved.id, slug: saved.slug }))
window.history.replaceState(null, '', `/editor/${saved.id}?t=${TOKEN}`)
} else {
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
}
setSaveStatus('saved')
if (overrideStatus) {
setMeta(m => ({ ...m, status: overrideStatus }))
}
} catch {
setSaveStatus('error')
}
}, []) // stable: reads from editorRef + metaRef
// Keep ref current so the auto-save timer always calls the latest version
doSaveRef.current = doSave
const handleUploadCover = async file => {
try {
const res = await api.uploadMedia(file, TOKEN)
setMeta(m => ({ ...m, cover_url: res.url }))
} catch {}
}
/* ── Render ── */
return (
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>
<Header
saveStatus={saveStatus}
articleStatus={meta.status}
onSave={() => doSave()}
onPublish={() => doSave('published')}
/>
<div className="flex flex-1 overflow-hidden">
{/* Editor column */}
<div className="flex flex-col flex-1 min-w-0 bg-white">
<Toolbar
editor={editor}
onInsertImage={() => setMediaState({
type: 'image',
cb: url => editor.chain().focus().setImage({ src: url }).run(),
})}
onInsertVideo={() => setMediaState({
type: 'video',
cb: url => editor.chain().focus().insertVideo({ src: url }).run(),
})}
onEditHtml={() => {
if (htmlMode) {
const isFullDoc = /^\s*<!DOCTYPE|^\s*<html/i.test(htmlDraft)
if (isFullDoc) {
// Полный HTML-документ — обходим TipTap, сохраняем как есть
setRawHtml(htmlDraft)
editor.commands.setContent('', false)
} else {
// Фрагмент — отдаём в TipTap
setRawHtml(null)
editor.commands.setContent(htmlDraft, true)
}
setHtmlMode(false)
} else {
// Открываем редактор: показываем rawHtml или HTML из TipTap
setHtmlDraft(rawHtml ?? editor.getHTML())
setHtmlMode(true)
}
}}
htmlMode={htmlMode}
fonts={FONTS}
/>
<div className="flex-1 overflow-y-auto">
<div className="max-w-[720px] mx-auto px-10 py-10">
{/* Title */}
<input
className="w-full text-[2.2rem] font-bold text-slate-900 border-none outline-none bg-transparent placeholder-slate-300 mb-8 leading-tight"
style={{ fontFamily: meta.font_family }}
placeholder="Заголовок статьи"
value={meta.title}
onChange={e => setMeta(m => ({ ...m, title: e.target.value }))}
/>
{/* HTML source textarea */}
{htmlMode && (
<textarea
spellCheck={false}
value={htmlDraft}
onChange={e => setHtmlDraft(e.target.value)}
style={{
width: '100%', minHeight: '60vh',
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
fontSize: '0.8rem', lineHeight: 1.7,
color: '#1e293b', background: '#f8fafc',
border: '1px solid #e2e8f0', borderRadius: 8,
padding: '1rem', resize: 'vertical', outline: 'none',
}}
/>
)}
{/* TipTap visual editor */}
{!htmlMode && !rawHtml && (
<div style={{ fontFamily: meta.font_family }}>
<EditorContent editor={editor} />
</div>
)}
</div>
{/* Full HTML iframe — вне узкого контейнера, на всю ширину колонки */}
{!htmlMode && rawHtml && (
<iframe
srcDoc={rawHtml}
style={{ width: '100%', border: 'none', display: 'block' }}
title="HTML preview"
sandbox="allow-scripts allow-same-origin"
onLoad={e => {
const doc = e.target.contentDocument
if (doc) e.target.style.height = doc.documentElement.scrollHeight + 'px'
}}
/>
)}
</div>
</div>
{/* Sidebar */}
<Sidebar
meta={meta}
categories={categories}
onChange={updates => setMeta(m => ({ ...m, ...updates }))}
onUploadCover={handleUploadCover}
wordCount={editor?.storage.characterCount?.words() ?? 0}
charCount={editor?.storage.characterCount?.characters() ?? 0}
fonts={FONTS}
/>
</div>
{/* Media picker modal */}
{mediaState && (
<MediaModal
type={mediaState.type}
token={TOKEN}
onSelect={url => { mediaState.cb(url); setMediaState(null) }}
onClose={() => setMediaState(null)}
/>
)}
</div>
)
}