test ract
This commit is contained in:
15
web/editor-ui/index.html
Normal file
15
web/editor-ui/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Редактор статьи</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Arimo:ital,wght@0,400;0,700;1,400;1,700&family=Caladea:ital,wght@0,400;0,700;1,400;1,700&family=Cormorant+Garamond:ital,wght@0,400;0,700;1,400;1,700&family=Cousine:ital,wght@0,400;0,700;1,400;1,700&family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&family=EB+Garamond:ital,wght@0,400;0,700;1,400;1,700&family=Fira+Code:wght@400;700&family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Josefin+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Lato:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Libre+Franklin:ital,wght@0,400;0,700;1,400;1,700&family=Lora:ital,wght@0,400;0,700;1,400;1,700&family=Merriweather:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&family=Nunito:ital,wght@0,400;0,700;1,400;1,700&family=Open+Sans:ital,wght@0,400;0,700;1,400;1,700&family=Oswald:wght@400;500;700&family=Outfit:wght@400;700&family=Patrick+Hand&family=Playfair+Display:ital,wght@0,400;0,700;1,400;1,700&family=Poppins:ital,wght@0,400;0,700;1,400;1,700&family=Roboto+Mono:ital,wght@0,400;0,700;1,400;1,700&family=Roboto:ital,wght@0,400;0,700;1,400;1,700&family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&family=Tinos:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
3698
web/editor-ui/package-lock.json
generated
Normal file
3698
web/editor-ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
web/editor-ui/package.json
Normal file
41
web/editor-ui/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "news-editor-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-character-count": "^2.10.4",
|
||||
"@tiptap/extension-color": "^2.10.4",
|
||||
"@tiptap/extension-font-family": "^2.10.4",
|
||||
"@tiptap/extension-highlight": "^2.10.4",
|
||||
"@tiptap/extension-image": "^2.10.4",
|
||||
"@tiptap/extension-link": "^2.10.4",
|
||||
"@tiptap/extension-placeholder": "^2.10.4",
|
||||
"@tiptap/extension-table": "^2.10.4",
|
||||
"@tiptap/extension-table-cell": "^2.10.4",
|
||||
"@tiptap/extension-table-header": "^2.10.4",
|
||||
"@tiptap/extension-table-row": "^2.10.4",
|
||||
"@tiptap/extension-text-align": "^2.10.4",
|
||||
"@tiptap/extension-text-style": "^2.10.4",
|
||||
"@tiptap/extension-underline": "^2.10.4",
|
||||
"@tiptap/pm": "^2.10.4",
|
||||
"@tiptap/react": "^2.10.4",
|
||||
"@tiptap/starter-kit": "^2.10.4",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^15.0.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.2",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
6
web/editor-ui/postcss.config.js
Normal file
6
web/editor-ui/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
46
web/editor-ui/src/api.js
Normal file
46
web/editor-ui/src/api.js
Normal file
@@ -0,0 +1,46 @@
|
||||
const BASE = '/api'
|
||||
|
||||
async function req(method, path, token, body) {
|
||||
const res = await fetch(`${BASE}/${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const text = await res.text()
|
||||
throw new Error(text || res.statusText)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getArticle: (id, token) =>
|
||||
req('GET', `admin/articles/${id}`, token),
|
||||
|
||||
createArticle: (data, token) =>
|
||||
req('POST', 'admin/articles', token, data),
|
||||
|
||||
updateArticle: (id, data, token) =>
|
||||
req('PUT', `admin/articles/${id}`, token, data),
|
||||
|
||||
getCategories: token =>
|
||||
req('GET', 'admin/categories', token),
|
||||
|
||||
listMedia: (token, type) =>
|
||||
req('GET', `admin/media${type ? `?media_type=${type}` : ''}`, token),
|
||||
|
||||
uploadMedia: async (file, token) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
const res = await fetch(`${BASE}/admin/media/upload`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
})
|
||||
if (!res.ok) throw new Error(await res.text())
|
||||
return res.json()
|
||||
},
|
||||
}
|
||||
53
web/editor-ui/src/components/Header.jsx
Normal file
53
web/editor-ui/src/components/Header.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { ArrowLeft, Check, Loader2, AlertCircle, Clock } from 'lucide-react'
|
||||
|
||||
const SAVE_STATES = {
|
||||
saving: { icon: Loader2, text: 'Сохранение…', color: 'text-slate-400', spin: true },
|
||||
saved: { icon: Check, text: 'Сохранено', color: 'text-emerald-400', spin: false },
|
||||
unsaved: { icon: Clock, text: 'Есть изменения', color: 'text-amber-400', spin: false },
|
||||
error: { icon: AlertCircle, text: 'Ошибка сохр.', color: 'text-red-400', spin: false },
|
||||
}
|
||||
|
||||
export function Header({ saveStatus, articleStatus, onSave, onPublish }) {
|
||||
const s = SAVE_STATES[saveStatus] ?? SAVE_STATES.saved
|
||||
const Icon = s.icon
|
||||
const published = articleStatus === 'published'
|
||||
|
||||
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()"
|
||||
className="flex items-center gap-1.5 text-slate-400 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
<span>Назад</span>
|
||||
</a>
|
||||
<div className="w-px h-5 bg-slate-700" />
|
||||
<span className="text-slate-200 font-semibold text-sm tracking-tight">News CMS</span>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center gap-1.5 text-xs font-medium ${s.color}`}>
|
||||
<Icon size={13} className={s.spin ? 'animate-spin' : ''} />
|
||||
<span>{s.text}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-3 py-1.5 text-sm text-slate-300 hover:text-white hover:bg-slate-700 rounded-lg transition-colors"
|
||||
>
|
||||
Сохранить
|
||||
</button>
|
||||
<button
|
||||
onClick={onPublish}
|
||||
className={`px-4 py-1.5 text-sm font-semibold rounded-lg transition-all shadow-sm
|
||||
${published
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 text-white'
|
||||
: 'bg-indigo-600 hover:bg-indigo-500 text-white'}`}
|
||||
>
|
||||
{published ? 'Обновить' : 'Опубликовать'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
131
web/editor-ui/src/components/MediaModal.jsx
Normal file
131
web/editor-ui/src/components/MediaModal.jsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Upload, X, ImageIcon, Video, Loader2, Check } from 'lucide-react'
|
||||
import { api } from '../api'
|
||||
|
||||
export function MediaModal({ type, token, onSelect, onClose }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const fileRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
api.listMedia(token, type)
|
||||
.then(data => { setItems(data); setLoading(false) })
|
||||
.catch(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleUpload = async file => {
|
||||
setUploading(true)
|
||||
try {
|
||||
const res = await api.uploadMedia(file, token)
|
||||
setItems(prev => [res, ...prev])
|
||||
} catch {}
|
||||
setUploading(false)
|
||||
}
|
||||
|
||||
const accept = type === 'image' ? 'image/*' : 'video/*'
|
||||
const isEmpty = !loading && items.length === 0
|
||||
const isImage = type === 'image'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose() }}
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[80vh] flex flex-col overflow-hidden">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-100">
|
||||
<div>
|
||||
<h2 className="font-semibold text-slate-900 text-base">Медиатека</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
{isImage ? 'Изображения' : 'Видео'} · кликните для вставки
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{uploading
|
||||
? <Loader2 size={14} className="animate-spin" />
|
||||
: <Upload size={14} />}
|
||||
Загрузить
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X size={18} className="text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<Loader2 size={28} className="animate-spin text-slate-300" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-slate-300 gap-3">
|
||||
{isImage ? <ImageIcon size={36} /> : <Video size={36} />}
|
||||
<p className="text-sm">Файлы не найдены</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-600 underline"
|
||||
>
|
||||
Загрузить первый файл
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`grid gap-2.5 ${isImage ? 'grid-cols-4 sm:grid-cols-5' : 'grid-cols-2 sm:grid-cols-3'}`}>
|
||||
{items.map(item => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(item.url)}
|
||||
className="group relative rounded-xl overflow-hidden border-2 border-transparent hover:border-indigo-500 transition-all bg-slate-100 aspect-square"
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.filename}
|
||||
className="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<video src={item.url} className="w-full h-full object-cover" />
|
||||
)}
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-indigo-600/0 group-hover:bg-indigo-600/25 transition-colors flex items-center justify-center">
|
||||
<Check
|
||||
size={22}
|
||||
className="text-white opacity-0 group-hover:opacity-100 drop-shadow-lg transition-opacity"
|
||||
/>
|
||||
</div>
|
||||
{/* Filename tooltip */}
|
||||
<div className="absolute bottom-0 inset-x-0 px-2 py-1.5 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<p className="text-white text-[10px] truncate leading-none">{item.filename}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
className="hidden"
|
||||
onChange={e => { if (e.target.files[0]) handleUpload(e.target.files[0]) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
web/editor-ui/src/components/Sidebar.jsx
Normal file
156
web/editor-ui/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useRef } from 'react'
|
||||
import { X, ImageIcon, ChevronDown, BarChart2 } from 'lucide-react'
|
||||
import { TagInput } from './TagInput'
|
||||
|
||||
export function Sidebar({ meta, categories, onChange, onUploadCover, wordCount, charCount, fonts }) {
|
||||
const coverRef = useRef(null)
|
||||
|
||||
return (
|
||||
<aside className="w-[280px] flex-shrink-0 bg-slate-900 border-l border-slate-800 flex flex-col overflow-y-auto">
|
||||
|
||||
{/* Stats bar */}
|
||||
<div className="flex items-center gap-4 px-4 py-3 border-b border-slate-800">
|
||||
<BarChart2 size={14} className="text-slate-500 flex-shrink-0" />
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span>
|
||||
<span className="font-semibold text-white">{wordCount}</span>
|
||||
<span className="text-slate-400 ml-1">слов</span>
|
||||
</span>
|
||||
<span>
|
||||
<span className="font-semibold text-white">{charCount}</span>
|
||||
<span className="text-slate-400 ml-1">симв.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="px-4 pt-4 pb-1">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium
|
||||
${meta.status === 'published'
|
||||
? 'bg-emerald-900/60 text-emerald-300 border border-emerald-800'
|
||||
: 'bg-amber-900/40 text-amber-300 border border-amber-800/60'}`}
|
||||
>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${meta.status === 'published' ? 'bg-emerald-400' : 'bg-amber-400'}`} />
|
||||
{meta.status === 'published' ? 'Опубликовано' : 'Черновик'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-5">
|
||||
|
||||
{/* Slug */}
|
||||
<div>
|
||||
<Label>URL (slug)</Label>
|
||||
<input
|
||||
className="input-dark font-mono text-xs"
|
||||
placeholder="url-stati"
|
||||
value={meta.slug}
|
||||
onChange={e => onChange({ slug: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<div>
|
||||
<Label>Анонс</Label>
|
||||
<textarea
|
||||
className="input-dark resize-none text-sm"
|
||||
placeholder="Краткое описание статьи…"
|
||||
rows={3}
|
||||
value={meta.excerpt}
|
||||
onChange={e => onChange({ excerpt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div>
|
||||
<Label>Категория</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="input-dark appearance-none cursor-pointer pr-8 text-sm"
|
||||
value={meta.category_id || ''}
|
||||
onChange={e => onChange({ category_id: e.target.value || null })}
|
||||
>
|
||||
<option value="">Без категории</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={13} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<Label>Теги</Label>
|
||||
<TagInput tags={meta.tag_names} onChange={tags => onChange({ tag_names: tags })} />
|
||||
<p className="text-[11px] text-slate-500 mt-1.5">Enter или запятая для добавления</p>
|
||||
</div>
|
||||
|
||||
{/* Font */}
|
||||
<div>
|
||||
<Label>Шрифт статьи</Label>
|
||||
<div className="relative">
|
||||
<select
|
||||
className="input-dark appearance-none cursor-pointer pr-8 text-sm"
|
||||
style={{ fontFamily: meta.font_family }}
|
||||
value={meta.font_family}
|
||||
onChange={e => onChange({ font_family: e.target.value })}
|
||||
>
|
||||
{fonts.map(f => (
|
||||
<option key={f} value={f} style={{ fontFamily: f }}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown size={13} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
<div>
|
||||
<Label>Обложка</Label>
|
||||
{meta.cover_url ? (
|
||||
<div className="relative rounded-xl overflow-hidden ring-1 ring-slate-700">
|
||||
<img
|
||||
src={meta.cover_url}
|
||||
alt="cover"
|
||||
className="w-full h-36 object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange({ cover_url: null })}
|
||||
className="absolute top-2 right-2 p-1.5 bg-slate-900/80 backdrop-blur-sm rounded-full hover:bg-red-600 transition-colors"
|
||||
>
|
||||
<X size={12} className="text-white" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => coverRef.current?.click()}
|
||||
className="w-full h-28 border-2 border-dashed border-slate-700 rounded-xl flex flex-col items-center justify-center gap-2 hover:border-indigo-500 hover:bg-slate-800/50 transition-all group"
|
||||
>
|
||||
<ImageIcon size={20} className="text-slate-500 group-hover:text-indigo-400 transition-colors" />
|
||||
<span className="text-xs text-slate-500 group-hover:text-slate-300 transition-colors">
|
||||
Загрузить обложку
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<input
|
||||
ref={coverRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => e.target.files[0] && onUploadCover(e.target.files[0])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Label({ children }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 mb-1.5">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
56
web/editor-ui/src/components/TagInput.jsx
Normal file
56
web/editor-ui/src/components/TagInput.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
export function TagInput({ tags, onChange }) {
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef(null)
|
||||
|
||||
const add = val => {
|
||||
const tag = val.trim()
|
||||
if (tag && !tags.includes(tag)) onChange([...tags, tag])
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const remove = tag => onChange(tags.filter(t => t !== tag))
|
||||
|
||||
const onKey = e => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
add(input)
|
||||
} else if (e.key === 'Backspace' && !input && tags.length) {
|
||||
remove(tags[tags.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5 min-h-[40px] bg-slate-800 border border-slate-700 rounded-lg px-2.5 py-2 cursor-text focus-within:border-indigo-500 transition-colors"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-1 px-2 py-0.5 bg-indigo-900/70 text-indigo-200 text-xs rounded-full border border-indigo-700/50"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); remove(tag) }}
|
||||
className="hover:text-white transition-colors leading-none"
|
||||
>
|
||||
<X size={9} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 min-w-[80px] bg-transparent text-sm text-slate-200 outline-none placeholder-slate-500"
|
||||
placeholder={tags.length === 0 ? 'Добавить тег…' : ''}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKey}
|
||||
onBlur={() => { if (input) add(input) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
331
web/editor-ui/src/components/Toolbar.jsx
Normal file
331
web/editor-ui/src/components/Toolbar.jsx
Normal file
@@ -0,0 +1,331 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Bold, Italic, Underline as UnderlineIcon, Strikethrough,
|
||||
Heading1, Heading2, Heading3, Type,
|
||||
AlignLeft, AlignCenter, AlignRight, AlignJustify,
|
||||
List, ListOrdered, Quote, Code2,
|
||||
Link2, ImageIcon, Video, Table2,
|
||||
Undo2, Redo2, Palette, Highlighter, ChevronDown,
|
||||
Minus,
|
||||
} from 'lucide-react'
|
||||
|
||||
const FONT_SIZES = ['10', '11', '12', '14', '16', '18', '20', '24', '28', '32', '36', '48', '64', '72']
|
||||
|
||||
/* ── Primitive components ── */
|
||||
|
||||
function Btn({ onClick, active, disabled, title, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); onClick?.() }}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
className={`
|
||||
relative flex items-center justify-center w-[30px] h-[30px] rounded-md text-[13px] transition-colors flex-shrink-0
|
||||
${active ? 'bg-indigo-100 text-indigo-700' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-800'}
|
||||
${disabled ? 'opacity-30 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Sep() {
|
||||
return <div className="w-px h-5 bg-slate-200 mx-0.5 flex-shrink-0" />
|
||||
}
|
||||
|
||||
/* ── Font family dropdown ── */
|
||||
|
||||
function FontDropdown({ editor, fonts }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||
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 }}
|
||||
>
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-50 transition-colors"
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().unsetFontFamily().run(); setOpen(false) }}
|
||||
>
|
||||
По умолчанию
|
||||
</button>
|
||||
<div className="my-1 border-t border-slate-100" />
|
||||
{fonts.map(f => (
|
||||
<button
|
||||
key={f}
|
||||
type="button"
|
||||
className="w-full px-3 py-1.5 text-left text-sm hover:bg-slate-50 transition-colors"
|
||||
style={{ fontFamily: f }}
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().setFontFamily(f).run(); setOpen(false) }}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Font size dropdown ── */
|
||||
|
||||
function SizeDropdown({ editor }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={e => { e.preventDefault(); setOpen(o => !o) }}
|
||||
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="Размер шрифта"
|
||||
>
|
||||
<span className="flex-1 text-center">{current}</span>
|
||||
<ChevronDown size={11} className="opacity-60" />
|
||||
</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">
|
||||
{FONT_SIZES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
className="w-full px-3 py-1 text-left text-xs hover:bg-slate-50 transition-colors"
|
||||
onMouseDown={e => { e.preventDefault(); editor.chain().focus().setFontSize(`${s}px`).run(); setOpen(false) }}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── 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 items = [
|
||||
{ label: 'Вставить таблицу 3×3', fn: () => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run() },
|
||||
null,
|
||||
{ label: 'Добавить строку ниже', fn: () => editor.chain().focus().addRowAfter().run() },
|
||||
{ label: 'Добавить строку выше', fn: () => editor.chain().focus().addRowBefore().run() },
|
||||
{ label: 'Удалить строку', fn: () => editor.chain().focus().deleteRow().run(), danger: true },
|
||||
null,
|
||||
{ label: 'Добавить столбец справа', fn: () => editor.chain().focus().addColumnAfter().run() },
|
||||
{ label: 'Добавить столбец слева', fn: () => editor.chain().focus().addColumnBefore().run() },
|
||||
{ label: 'Удалить столбец', fn: () => editor.chain().focus().deleteColumn().run(), danger: true },
|
||||
null,
|
||||
{ label: 'Удалить таблицу', fn: () => editor.chain().focus().deleteTable().run(), danger: true },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative flex-shrink-0" ref={ref}>
|
||||
<Btn
|
||||
onClick={() => setOpen(o => !o)}
|
||||
active={editor.isActive('table')}
|
||||
title="Таблица"
|
||||
>
|
||||
<Table2 size={14} />
|
||||
</Btn>
|
||||
|
||||
{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">
|
||||
{items.map((item, i) =>
|
||||
item === null ? (
|
||||
<div key={i} className="my-1 border-t border-slate-100" />
|
||||
) : (
|
||||
<button
|
||||
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'}`}
|
||||
onMouseDown={e => { e.preventDefault(); item.fn(); setOpen(false) }}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main Toolbar ── */
|
||||
|
||||
export function Toolbar({ editor, onInsertImage, onInsertVideo, fonts }) {
|
||||
if (!editor) return null
|
||||
|
||||
const setLink = () => {
|
||||
const prev = editor.getAttributes('link').href || ''
|
||||
const url = window.prompt('URL ссылки:', prev)
|
||||
if (url === null) return
|
||||
if (!url) editor.chain().focus().unsetLink().run()
|
||||
else editor.chain().focus().setLink({ href: url }).run()
|
||||
}
|
||||
|
||||
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]">
|
||||
{/* History */}
|
||||
<Btn onClick={() => editor.chain().focus().undo().run()} disabled={!editor.can().undo()} title="Отменить (Ctrl+Z)">
|
||||
<Undo2 size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().redo().run()} disabled={!editor.can().redo()} title="Повторить (Ctrl+Y)">
|
||||
<Redo2 size={14} />
|
||||
</Btn>
|
||||
<Sep />
|
||||
|
||||
{/* Text format */}
|
||||
<Btn onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive('bold')} title="Жирный (Ctrl+B)">
|
||||
<Bold size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive('italic')} title="Курсив (Ctrl+I)">
|
||||
<Italic size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleUnderline().run()} active={editor.isActive('underline')} title="Подчёркивание (Ctrl+U)">
|
||||
<UnderlineIcon size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive('strike')} title="Зачёркивание">
|
||||
<Strikethrough size={14} />
|
||||
</Btn>
|
||||
<Sep />
|
||||
|
||||
{/* Headings */}
|
||||
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive('heading', { level: 1 })} title="Заголовок 1">
|
||||
<Heading1 size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive('heading', { level: 2 })} title="Заголовок 2">
|
||||
<Heading2 size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive('heading', { level: 3 })} title="Заголовок 3">
|
||||
<Heading3 size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().setParagraph().run()} active={editor.isActive('paragraph') && !editor.isActive('heading')} title="Обычный текст">
|
||||
<Type size={14} />
|
||||
</Btn>
|
||||
<Sep />
|
||||
|
||||
{/* Alignment */}
|
||||
<Btn onClick={() => editor.chain().focus().setTextAlign('left').run()} active={editor.isActive({ textAlign: 'left' })} title="По левому краю">
|
||||
<AlignLeft size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().setTextAlign('center').run()} active={editor.isActive({ textAlign: 'center' })} title="По центру">
|
||||
<AlignCenter size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().setTextAlign('right').run()} active={editor.isActive({ textAlign: 'right' })} title="По правому краю">
|
||||
<AlignRight size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().setTextAlign('justify').run()} active={editor.isActive({ textAlign: 'justify' })} title="По ширине">
|
||||
<AlignJustify size={14} />
|
||||
</Btn>
|
||||
<Sep />
|
||||
|
||||
{/* Lists + blocks */}
|
||||
<Btn onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive('bulletList')} title="Маркированный список">
|
||||
<List size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive('orderedList')} title="Нумерованный список">
|
||||
<ListOrdered size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive('blockquote')} title="Цитата">
|
||||
<Quote size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive('codeBlock')} title="Блок кода">
|
||||
<Code2 size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={() => editor.chain().focus().setHorizontalRule().run()} title="Разделитель">
|
||||
<Minus size={14} />
|
||||
</Btn>
|
||||
<Sep />
|
||||
|
||||
{/* Insert media */}
|
||||
<Btn onClick={setLink} active={editor.isActive('link')} title="Ссылка">
|
||||
<Link2 size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={onInsertImage} title="Вставить изображение">
|
||||
<ImageIcon size={14} />
|
||||
</Btn>
|
||||
<Btn onClick={onInsertVideo} title="Вставить видео">
|
||||
<Video size={14} />
|
||||
</Btn>
|
||||
<TableMenu editor={editor} />
|
||||
<Sep />
|
||||
|
||||
{/* Typography */}
|
||||
<FontDropdown editor={editor} fonts={fonts} />
|
||||
<SizeDropdown editor={editor} />
|
||||
|
||||
{/* Text color */}
|
||||
<label
|
||||
className="relative flex items-center justify-center w-[30px] h-[30px] rounded-md cursor-pointer hover:bg-slate-100 transition-colors flex-shrink-0"
|
||||
title="Цвет текста"
|
||||
>
|
||||
<Palette size={14} className="text-slate-500" />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute opacity-0 w-0 h-0"
|
||||
onInput={e => editor.chain().focus().setColor(e.target.value).run()}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Highlight color */}
|
||||
<label
|
||||
className="relative flex items-center justify-center w-[30px] h-[30px] rounded-md cursor-pointer hover:bg-slate-100 transition-colors flex-shrink-0"
|
||||
title="Выделение цветом"
|
||||
>
|
||||
<Highlighter size={14} className="text-slate-500" />
|
||||
<input
|
||||
type="color"
|
||||
className="absolute opacity-0 w-0 h-0"
|
||||
defaultValue="#fef08a"
|
||||
onInput={e => editor.chain().focus().setHighlight({ color: e.target.value }).run()}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
web/editor-ui/src/extensions/FontSize.js
Normal file
34
web/editor-ui/src/extensions/FontSize.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
export const FontSize = Extension.create({
|
||||
name: 'fontSize',
|
||||
|
||||
addOptions() {
|
||||
return { types: ['textStyle'] }
|
||||
},
|
||||
|
||||
addGlobalAttributes() {
|
||||
return [{
|
||||
types: this.options.types,
|
||||
attributes: {
|
||||
fontSize: {
|
||||
default: null,
|
||||
parseHTML: el => el.style.fontSize || null,
|
||||
renderHTML: ({ fontSize }) => {
|
||||
if (!fontSize) return {}
|
||||
return { style: `font-size: ${fontSize}` }
|
||||
},
|
||||
},
|
||||
},
|
||||
}]
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setFontSize: fontSize => ({ chain }) =>
|
||||
chain().setMark('textStyle', { fontSize }).run(),
|
||||
unsetFontSize: () => ({ chain }) =>
|
||||
chain().setMark('textStyle', { fontSize: null }).removeEmptyTextStyle().run(),
|
||||
}
|
||||
},
|
||||
})
|
||||
47
web/editor-ui/src/extensions/Video.jsx
Normal file
47
web/editor-ui/src/extensions/Video.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Node } from '@tiptap/core'
|
||||
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react'
|
||||
|
||||
function VideoView({ node }) {
|
||||
return (
|
||||
<NodeViewWrapper className="my-4">
|
||||
<video
|
||||
src={node.attrs.src}
|
||||
controls
|
||||
className="max-w-full rounded-lg mx-auto block shadow-md"
|
||||
style={{ maxHeight: '480px' }}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const Video = Node.create({
|
||||
name: 'video',
|
||||
group: 'block',
|
||||
atom: true,
|
||||
draggable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: { default: null },
|
||||
}
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'video[src]' }]
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ['video', { controls: '', ...HTMLAttributes, style: 'max-width:100%;border-radius:8px;' }]
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(VideoView)
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertVideo: attrs => ({ commands }) =>
|
||||
commands.insertContent({ type: this.name, attrs }),
|
||||
}
|
||||
},
|
||||
})
|
||||
198
web/editor-ui/src/index.css
Normal file
198
web/editor-ui/src/index.css
Normal file
@@ -0,0 +1,198 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ─── TipTap editor content ─── */
|
||||
.ProseMirror {
|
||||
outline: none;
|
||||
min-height: 480px;
|
||||
color: #1e293b;
|
||||
line-height: 1.8;
|
||||
font-size: 1.0625rem;
|
||||
}
|
||||
|
||||
.ProseMirror > * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
.ProseMirror p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ProseMirror h1 {
|
||||
font-size: 2em;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin-top: 1.5em;
|
||||
margin-bottom: 0.5em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ProseMirror h2 {
|
||||
font-size: 1.5em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 0.4em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ProseMirror h3 {
|
||||
font-size: 1.25em;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-top: 1em;
|
||||
margin-bottom: 0.3em;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.ProseMirror ul,
|
||||
.ProseMirror ol {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
.ProseMirror ul {
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.ProseMirror ol {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.ProseMirror li {
|
||||
margin: 0.2em 0;
|
||||
}
|
||||
|
||||
.ProseMirror blockquote {
|
||||
border-left: 3px solid #6366f1;
|
||||
padding: 0.5em 1em;
|
||||
margin: 1em 0;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
background: #f8fafc;
|
||||
border-radius: 0 6px 6px 0;
|
||||
}
|
||||
|
||||
.ProseMirror code {
|
||||
background: #f1f5f9;
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 4px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: 0.875em;
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.ProseMirror pre {
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
padding: 1.25em 1.5em;
|
||||
border-radius: 10px;
|
||||
overflow-x: auto;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.ProseMirror pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: 0.875em;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.ProseMirror table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 1.25em 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.ProseMirror td,
|
||||
.ProseMirror th {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 0.6em 0.9em;
|
||||
min-width: 80px;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ProseMirror th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
font-size: 0.875em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.ProseMirror .selectedCell::after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: "";
|
||||
inset: 0;
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ProseMirror img {
|
||||
max-width: 100%;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.ProseMirror a {
|
||||
color: #6366f1;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.ProseMirror a:hover {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
/* Placeholder */
|
||||
.ProseMirror p.is-editor-empty:first-child::before {
|
||||
color: #94a3b8;
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Highlight */
|
||||
.ProseMirror mark {
|
||||
border-radius: 3px;
|
||||
padding: 0.1em 0.2em;
|
||||
}
|
||||
|
||||
/* Resizable table handle */
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
/* Sidebar inputs */
|
||||
.input-dark {
|
||||
@apply w-full bg-slate-800 text-slate-200 px-3 py-2 rounded-lg border border-slate-700
|
||||
focus:border-indigo-500 focus:outline-none transition-colors placeholder-slate-500;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
10
web/editor-ui/src/main.jsx
Normal file
10
web/editor-ui/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
12
web/editor-ui/tailwind.config.js
Normal file
12
web/editor-ui/tailwind.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,jsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
7
web/editor-ui/vite.config.js
Normal file
7
web/editor-ui/vite.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/editor-assets/',
|
||||
})
|
||||
Reference in New Issue
Block a user