329 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|