test push

This commit is contained in:
jze9
2026-05-18 18:51:00 +05:00
parent a64d8469df
commit 5057a61d55
5 changed files with 271 additions and 177 deletions

View File

@@ -53,63 +53,27 @@ const DEFAULT_META = {
status: 'draft',
}
/* ── Загрузчик: сначала данные, потом редактор ── */
export default function App() {
const [initContent, setInitContent] = useState(IS_NEW ? '' : null) // null = ещё не загружено
const [initMeta, setInitMeta] = useState(DEFAULT_META)
useEffect(() => {
if (IS_NEW) return
api.getArticle(ARTICLE_ID, TOKEN)
.then(data => {
setInitMeta({
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 || '')
setInitContent(html || '')
})
.catch(() => setInitContent(''))
}, [])
// Пока данные не загружены — показываем лоадер
if (initContent === null) {
return (
<div style={{ display: 'flex', height: '100vh', alignItems: 'center', justifyContent: 'center',
background: '#f8fafc', color: '#94a3b8', fontSize: 14 }}>
Загрузка статьи
</div>
)
}
return <EditorApp initContent={initContent} initMeta={initMeta} />
}
/* ── Сам редактор — рендерится только когда данные готовы ── */
function EditorApp({ initContent, initMeta }) {
const [meta, setMeta] = useState(initMeta)
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
const metaRef = useRef(meta)
metaRef.current = meta
const currentIdRef = useRef(initMeta.id ?? (IS_NEW ? null : ARTICLE_ID))
const saveTimerRef = useRef(null)
const editorRef = useRef(null)
const doSaveRef = useRef(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 создаётся сразу с готовым контентом ── */
/* ── Editor ── */
const editor = useEditor({
extensions: [
StarterKit,
@@ -130,22 +94,62 @@ function EditorApp({ initContent, initMeta }) {
Placeholder.configure({ placeholder: 'Начните писать статью…' }),
CharacterCount,
],
content: initContent,
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
@@ -153,15 +157,16 @@ function EditorApp({ initContent, initMeta }) {
setSaveStatus('saving')
const payload = {
title: m.title || 'Без названия',
slug: m.slug || undefined,
content: editor.getHTML(),
excerpt: m.excerpt,
cover_url: m.cover_url || null,
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,
tag_names: m.tag_names,
status: overrideStatus ?? m.status,
}
try {
@@ -175,12 +180,15 @@ function EditorApp({ initContent, initMeta }) {
saved = await api.updateArticle(currentIdRef.current, payload, TOKEN)
}
setSaveStatus('saved')
if (overrideStatus) setMeta(m => ({ ...m, status: overrideStatus }))
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 => {
@@ -190,6 +198,7 @@ function EditorApp({ initContent, initMeta }) {
} catch {}
}
/* ── Render ── */
return (
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>
@@ -214,6 +223,26 @@ function EditorApp({ initContent, initMeta }) {
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}
/>
@@ -229,12 +258,45 @@ function EditorApp({ initContent, initMeta }) {
onChange={e => setMeta(m => ({ ...m, title: e.target.value }))}
/>
{/* TipTap content */}
<div style={{ fontFamily: meta.font_family }}>
<EditorContent editor={editor} />
</div>
{/* 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>