test ract
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user