@@ -49,7 +71,8 @@ export default function NewsDetail() {
{article.tags?.length > 0 && (
diff --git a/web/editor-ui/src/App.jsx b/web/editor-ui/src/App.jsx
index f74d39b..8a2d58b 100644
--- a/web/editor-ui/src/App.jsx
+++ b/web/editor-ui/src/App.jsx
@@ -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 (
-
- Загрузка статьи…
-
- )
- }
-
- return
-}
-
-/* ── Сам редактор — рендерится только когда данные готовы ── */
-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* {})
+ }, [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 (
@@ -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*
@@ -229,12 +258,45 @@ function EditorApp({ initContent, initMeta }) {
onChange={e => setMeta(m => ({ ...m, title: e.target.value }))}
/>
- {/* TipTap content */}
-
-
-
+ {/* HTML source textarea */}
+ {htmlMode && (
+
+
+ {/* Full HTML iframe — вне узкого контейнера, на всю ширину колонки */}
+ {!htmlMode && rawHtml && (
+
diff --git a/web/editor-ui/src/components/MediaModal.jsx b/web/editor-ui/src/components/MediaModal.jsx
index fa5b1db..ecab988 100644
--- a/web/editor-ui/src/components/MediaModal.jsx
+++ b/web/editor-ui/src/components/MediaModal.jsx
@@ -12,7 +12,10 @@ export function MediaModal({ type, token, onSelect, onClose }) {
useEffect(() => {
api.listMedia(token, type)
- .then(data => { setAllItems(data); setItems(data); setLoading(false) })
+ .then(data => {
+ const list = Array.isArray(data) ? data : (data.items ?? [])
+ setAllItems(list); setItems(list); setLoading(false)
+ })
.catch(() => setLoading(false))
}, [])
diff --git a/web/editor-ui/src/components/Toolbar.jsx b/web/editor-ui/src/components/Toolbar.jsx
index 4967c31..7a9d2bf 100644
--- a/web/editor-ui/src/components/Toolbar.jsx
+++ b/web/editor-ui/src/components/Toolbar.jsx
@@ -35,60 +35,38 @@ function Sep() {
return