new web ract

This commit is contained in:
jze9
2026-05-15 03:31:28 +05:00
parent de78624495
commit 2335497226
58 changed files with 7397 additions and 406 deletions

View File

@@ -52,22 +52,62 @@ const DEFAULT_META = {
status: 'draft',
}
/* ── Загрузчик: сначала данные, потом редактор ── */
export default function App() {
const [meta, setMeta] = useState(DEFAULT_META)
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,
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 [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
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)
/* ── Editor ── */
/* ── Editor создаётся сразу с готовым контентом ── */
const editor = useEditor({
extensions: [
StarterKit,
@@ -88,54 +128,22 @@ export default function App() {
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,
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
@@ -143,15 +151,15 @@ export default function App() {
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: 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,
tag_names: m.tag_names,
status: overrideStatus ?? m.status,
}
try {
@@ -165,15 +173,12 @@ export default function App() {
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 => {
@@ -183,7 +188,6 @@ export default function App() {
} catch {}
}
/* ── Render ── */
return (
<div className="flex flex-col h-screen overflow-hidden bg-slate-50" style={{ fontFamily: 'Inter, sans-serif' }}>

View File

@@ -15,13 +15,20 @@ export function Header({ saveStatus, articleStatus, onSave, onPublish }) {
return (
<header className="flex items-center justify-between px-4 h-14 bg-slate-900 border-b border-slate-800 flex-shrink-0 z-20">
<div className="flex items-center gap-3">
<a
href="javascript:history.back()"
<button
onClick={() => {
// Если в iframe — говорим родителю закрыть редактор
if (window.parent !== window) {
window.parent.postMessage({ type: 'editor-close' }, '*')
} else {
window.close()
}
}}
className="flex items-center gap-1.5 text-slate-400 hover:text-white transition-colors text-sm"
>
<ArrowLeft size={16} />
<span>Назад</span>
</a>
<span>Закрыть</span>
</button>
<div className="w-px h-5 bg-slate-700" />
<span className="text-slate-200 font-semibold text-sm tracking-tight">News CMS</span>
</div>

View File

@@ -35,38 +35,60 @@ function Sep() {
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
}
/* ── Hook: позиция дропдауна через fixed (вырывается из overflow-контейнеров) ── */
function useFixedDropdown() {
const [open, setOpen] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const btnRef = useRef(null)
const toggle = e => {
e.preventDefault()
if (!open && btnRef.current) {
const r = btnRef.current.getBoundingClientRect()
setPos({ top: r.bottom + 4, left: r.left })
}
setOpen(o => !o)
}
useEffect(() => {
if (!open) return
const close = e => {
if (!btnRef.current?.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', close)
return () => document.removeEventListener('mousedown', close)
}, [open])
return { open, setOpen, pos, btnRef, toggle }
}
/* ── Font family dropdown ── */
function FontDropdown({ editor, fonts }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const current = editor.getAttributes('textStyle').fontFamily || 'Шрифт'
useEffect(() => {
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
return (
<div className="relative flex-shrink-0" ref={ref}>
<div className="flex-shrink-0">
<button
ref={btnRef}
type="button"
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
onMouseDown={toggle}
className="flex items-center gap-1 px-2 h-[30px] rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors max-w-[130px] cursor-pointer"
title="Шрифт"
>
<span
className="truncate leading-none"
style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}
>
<span className="truncate leading-none" style={{ fontFamily: current !== 'Шрифт' ? current : undefined }}>
{current}
</span>
<ChevronDown size={11} className="flex-shrink-0 opacity-60" />
</button>
{open && (
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 max-h-72 overflow-y-auto py-1.5">
<div
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-72 overflow-y-auto py-1.5"
>
<button
type="button"
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
@@ -95,21 +117,15 @@ function FontDropdown({ editor, fonts }) {
/* ── Font size dropdown ── */
function SizeDropdown({ editor }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const current = editor.getAttributes('textStyle').fontSize?.replace('px', '') || '—'
useEffect(() => {
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
return (
<div className="relative flex-shrink-0" ref={ref}>
<div className="flex-shrink-0">
<button
ref={btnRef}
type="button"
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
onMouseDown={toggle}
className="flex items-center gap-1 px-1.5 h-[30px] w-14 rounded-md text-xs text-slate-600 hover:bg-slate-100 border border-transparent hover:border-slate-200 transition-colors cursor-pointer"
title="Размер шрифта"
>
@@ -118,7 +134,10 @@ function SizeDropdown({ editor }) {
</button>
{open && (
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-20 max-h-56 overflow-y-auto py-1.5">
<div
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 80 }}
className="bg-white border border-slate-200 rounded-xl shadow-2xl max-h-56 overflow-y-auto py-1.5"
>
{FONT_SIZES.map(s => (
<button
key={s}
@@ -138,14 +157,7 @@ function SizeDropdown({ editor }) {
/* ── Table menu ── */
function TableMenu({ editor }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false) }
document.addEventListener('mousedown', h)
return () => document.removeEventListener('mousedown', h)
}, [])
const { open, setOpen, pos, btnRef, toggle } = useFixedDropdown()
const items = [
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
@@ -162,17 +174,22 @@ function TableMenu({ editor }) {
]
return (
<div className="relative flex-shrink-0" ref={ref}>
<div className="flex-shrink-0">
<Btn
onClick={() => setOpen(o => !o)}
onClick={e => toggle({ preventDefault: () => {}, ...e })}
active={editor.isActive('table')}
title="Таблица"
>
<Table2 size={14} />
</Btn>
{/* Используем ref на обёртке для Btn */}
<span ref={btnRef} style={{ display: 'none' }} />
{open && (
<div className="absolute top-[34px] left-0 z-50 bg-white border border-slate-200 rounded-xl shadow-2xl w-52 py-1.5">
<div
style={{ position: 'fixed', top: pos.top, left: pos.left, zIndex: 9999, width: 208 }}
className="bg-white border border-slate-200 rounded-xl shadow-2xl py-1.5"
>
{items.map((item, i) =>
item === null ? (
<div key={i} className="my-1 border-t border-slate-100" />
@@ -181,9 +198,7 @@ function TableMenu({ editor }) {
key={item.label}
type="button"
className={`w-full px-3 py-1.5 text-left text-xs transition-colors
${item.danger
? 'text-red-500 hover:bg-red-50'
: 'text-slate-700 hover:bg-slate-50'}`}
${item.danger ? 'text-red-500 hover:bg-red-50' : 'text-slate-700 hover:bg-slate-50'}`}
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
>
{item.label}
@@ -210,7 +225,7 @@ export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
}
return (
<div className="sticky top-0 z-10 flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-wrap min-h-[46px]">
<div className="flex items-center gap-0.5 px-3 py-1.5 bg-white border-b border-slate-200 shadow-sm overflow-x-auto flex-shrink-0 min-h-[46px]" style={{ scrollbarWidth: 'none' }}>
{/* History */}
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
<Undo2 size={14} />