test ract

This commit is contained in:
jze9
2026-05-14 16:47:58 +05:00
parent 0ac1c8a862
commit de78624495
36 changed files with 5877 additions and 254 deletions

15
web/editor-ui/index.html Normal file
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

260
web/editor-ui/src/App.jsx Normal file
View 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
View 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()
},
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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(),
}
},
})

View 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
View 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;
}

View 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>
)

View 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: [],
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/editor-assets/',
})