test ract
This commit is contained in:
260
web/editor-ui/src/App.jsx
Normal file
260
web/editor-ui/src/App.jsx
Normal file
@@ -0,0 +1,260 @@
|
||||
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,
|
||||
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)
|
||||
|
||||
/* 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,
|
||||
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 || '')
|
||||
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: editor.getHTML(),
|
||||
excerpt: m.excerpt,
|
||||
cover_url: m.cover_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(),
|
||||
})}
|
||||
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 }))}
|
||||
/>
|
||||
|
||||
{/* TipTap content */}
|
||||
<div style={{ fontFamily: meta.font_family }}>
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user