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

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