@@ -0,0 +1,514 @@
'use client' ;
import { useState , useEffect , useCallback } from 'react' ;
import {
Loader2 , RefreshCw , Plus , Check , X , Edit3 , Save , Zap , Send ,
Coffee , Bug , Wrench , MessageCircle , Sparkles , Trash2 , Settings ,
} from 'lucide-react' ;
// ─── Метаданные UI ────────────────────────────────────────────────────────
const STATUS _META = {
draft : { label : 'Черновик' , dot : 'bg-amber-500' , text : 'text-amber-700 dark:text-amber-400' , bg : 'bg-amber-50 dark:bg-amber-950/30' , border : 'border-amber-200 dark:border-amber-900' } ,
approved : { label : 'Одобрено' , dot : 'bg-blue-500' , text : 'text-blue-700 dark:text-blue-400' , bg : 'bg-blue-50 dark:bg-blue-950/30' , border : 'border-blue-200 dark:border-blue-900' } ,
sending : { label : 'Отправка…' , dot : 'bg-cyan-500' , text : 'text-cyan-700 dark:text-cyan-400' , bg : 'bg-cyan-50 dark:bg-cyan-950/30' , border : 'border-cyan-200 dark:border-cyan-900' } ,
published : { label : 'Опубликовано' , dot : 'bg-emerald-500' , text : 'text-emerald-700 dark:text-emerald-400' , bg : 'bg-emerald-50 dark:bg-emerald-950/30' , border : 'border-emerald-200 dark:border-emerald-900' } ,
failed : { label : 'Ошибка' , dot : 'bg-red-500' , text : 'text-red-700 dark:text-red-400' , bg : 'bg-red-50 dark:bg-red-950/30' , border : 'border-red-200 dark:border-red-900' } ,
skipped : { label : 'Пропущено' , dot : 'bg-neutral-400' , text : 'text-neutral-500' , bg : 'bg-neutral-50 dark:bg-neutral-900' , border : 'border-neutral-200 dark:border-neutral-800' } ,
} ;
const BUCKET _ICON = {
bug _story : Bug , tools : Wrench , coffee _thoughts : Coffee , ai _industry : Sparkles ,
} ;
const bucketIcon = ( k ) => BUCKET _ICON [ k ] || MessageCircle ;
const mskTime = ( iso ) => {
if ( ! iso ) return '—' ;
return new Date ( iso ) . toLocaleString ( 'ru-RU' , { timeZone : 'Europe/Moscow' , day : '2-digit' , month : '2-digit' , hour : '2-digit' , minute : '2-digit' } ) ;
} ;
const wordCount = ( s ) => s ? s . trim ( ) . split ( /\s+/ ) . length : 0 ;
// ─── Main ────────────────────────────────────────────────────────────────
export default function AdminZero ( ) {
const [ notes , setNotes ] = useState ( [ ] ) ;
const [ buckets , setBuckets ] = useState ( [ ] ) ;
const [ config , setConfig ] = useState ( null ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ filter , setFilter ] = useState ( 'all' ) ;
const [ toast , setToast ] = useState ( null ) ;
// generate form
const [ genOpen , setGenOpen ] = useState ( false ) ;
const [ genForm , setGenForm ] = useState ( { channel _id : 1 , force _bucket : '' , allow _today _dup : false } ) ;
const [ generating , setGen ] = useState ( false ) ;
// edit / regen
const [ editId , setEditId ] = useState ( null ) ;
const [ editText , setEditText ] = useState ( '' ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ regenId , setRegenId ] = useState ( null ) ;
const [ regenBucket , setRegenBucket ] = useState ( '' ) ;
// config (settings panel)
const [ cfgOpen , setCfgOpen ] = useState ( false ) ;
const flash = ( msg , type = 'success' ) => { setToast ( { msg , type } ) ; setTimeout ( ( ) => setToast ( null ) , 3500 ) ; } ;
const load = useCallback ( async ( ) => {
setLoading ( true ) ;
try {
const [ n , b , c ] = await Promise . all ( [
fetch ( '/admin/api/zero/notes?limit=100' ) . then ( r => r . json ( ) ) ,
fetch ( '/admin/api/zero/buckets' ) . then ( r => r . json ( ) ) ,
fetch ( '/admin/api/zero/config' ) . then ( r => r . json ( ) ) ,
] ) ;
setNotes ( n . items || [ ] ) ;
setBuckets ( b . buckets || [ ] ) ;
setConfig ( c . config || null ) ;
// Подставим первый канал из config в форму генерации (если задан)
if ( c . config ? . ZERO _NOTES _CHANNEL _IDS ) {
const firstId = parseInt ( String ( c . config . ZERO _NOTES _CHANNEL _IDS ) . split ( ',' ) [ 0 ] , 10 ) ;
if ( firstId ) setGenForm ( f => ( { ... f , channel _id : firstId } ) ) ;
}
} catch ( e ) { flash ( 'Ошибка загрузки: ' + e . message , 'error' ) ; }
setLoading ( false ) ;
} , [ ] ) ;
useEffect ( ( ) => { load ( ) ; } , [ load ] ) ;
// ─── Actions ─────────────────────────────────────────────────
async function generate ( ) {
setGen ( true ) ;
try {
const r = await fetch ( '/admin/api/zero/generate' , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
channel _id : Number ( genForm . channel _id ) ,
force _bucket : genForm . force _bucket || undefined ,
allow _today _dup : genForm . allow _today _dup ,
} ) ,
} ) ;
const data = await r . json ( ) ;
if ( ! r . ok ) throw new Error ( data . error || 'Ошибка генерации' ) ;
flash ( ` Черновик # ${ data . note . id } создан · ${ data . note . theme _bucket } ` ) ;
setGenOpen ( false ) ;
await load ( ) ;
} catch ( e ) { flash ( e . message , 'error' ) ; }
setGen ( false ) ;
}
async function approve ( id ) {
const r = await fetch ( ` /admin/api/zero/notes/ ${ id } /approve ` , { method : 'POST' } ) ;
if ( r . ok ) { flash ( ` Заметка # ${ id } одобрена ` ) ; load ( ) ; }
else { const d = await r . json ( ) ; flash ( d . error || 'fail' , 'error' ) ; }
}
async function skip ( id ) {
if ( ! confirm ( 'Пропустить эту заметку? Она не будет опубликована.' ) ) return ;
const r = await fetch ( ` /admin/api/zero/notes/ ${ id } /skip ` , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { reason : 'skipped by admin via UI' } ) ,
} ) ;
if ( r . ok ) { flash ( ` Заметка # ${ id } пропущена ` ) ; load ( ) ; }
}
async function regenerate ( id ) {
const r = await fetch ( ` /admin/api/zero/notes/ ${ id } /regenerate ` , {
method : 'POST' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { force _bucket : regenBucket || undefined } ) ,
} ) ;
const data = await r . json ( ) ;
if ( r . ok ) { flash ( ` Перегенерирована: # ${ id } → # ${ data . note ? . id } ` ) ; setRegenId ( null ) ; setRegenBucket ( '' ) ; load ( ) ; }
else flash ( data . error || 'fail' , 'error' ) ;
}
async function saveEdit ( id ) {
setSaving ( true ) ;
try {
const r = await fetch ( ` /admin/api/zero/notes/ ${ id } ` , {
method : 'PATCH' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { content : editText } ) ,
} ) ;
if ( r . ok ) { flash ( 'Сохранено' ) ; setEditId ( null ) ; load ( ) ; }
else { const d = await r . json ( ) ; flash ( d . error || 'fail' , 'error' ) ; }
} catch ( e ) { flash ( e . message , 'error' ) ; }
setSaving ( false ) ;
}
async function publishNow ( id ) {
// Одобряем (если ещё draft) и ставим scheduled_at = NOW() — runner подхватит в ближайшую минуту
if ( ! confirm ( 'Опубликовать сейчас (одобрить и поставить scheduled_at = сейчас)?' ) ) return ;
const r1 = await fetch ( ` /admin/api/zero/notes/ ${ id } /approve ` , { method : 'POST' } ) ;
if ( ! r1 . ok && r1 . status !== 404 ) {
const d = await r1 . json ( ) ; flash ( 'approve: ' + ( d . error || 'fail' ) , 'error' ) ; return ;
}
const r2 = await fetch ( ` /admin/api/zero/notes/ ${ id } ` , {
method : 'PATCH' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { scheduled _at : new Date ( ) . toISOString ( ) } ) ,
} ) ;
if ( r2 . ok ) { flash ( ` Заметка # ${ id } уйдёт в TG в ближайшую минуту ` ) ; load ( ) ; }
else { const d = await r2 . json ( ) ; flash ( d . error || 'fail' , 'error' ) ; }
}
async function saveConfig ( patch ) {
const r = await fetch ( '/admin/api/zero/config' , {
method : 'PATCH' , headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( patch ) ,
} ) ;
const data = await r . json ( ) ;
if ( r . ok ) { flash ( 'Настройки сохранены' ) ; load ( ) ; }
else flash ( data . error || 'fail' , 'error' ) ;
}
const counts = notes . reduce ( ( acc , n ) => { acc [ n . status ] = ( acc [ n . status ] || 0 ) + 1 ; return acc ; } , { } ) ;
const filtered = filter === 'all' ? notes : notes . filter ( n => n . status === filter ) ;
return (
< div className = "space-y-6" >
{ toast && (
< div className = { ` fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg max-w-sm ${
toast . type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
} ` } > { toast . msg } < / d i v >
) }
{ /* HEADER */ }
< div className = "flex items-center justify-between" >
< div >
< h1 className = "text-2xl font-bold text-neutral-900 dark:text-neutral-100 flex items-center gap-2" >
< Coffee className = "w-6 h-6 text-emerald-500" / > Заметки от Зеро
< / h 1 >
< p className = "text-sm text-neutral-500 mt-1" >
AI - персонаж в < a href = "https://t.me/zeropostru" target = "_blank" rel = "noreferrer" className = "text-emerald-600 hover:underline" > @ zeropostru < / a > · к о р о т к и е п о с т ы о т п е р в о г о л и ц а
{ config && ( config . _enabled
? < span className = "ml-2 text-xs text-emerald-600" > ● автогенерация активна < / s p a n >
: < span className = "ml-2 text-xs text-neutral-400" > ○ автогенерация выключена < / s p a n > ) }
< / p >
< / d i v >
< div className = "flex items-center gap-2" >
< button onClick = { ( ) => setCfgOpen ( v => ! v ) }
className = "flex items-center gap-1.5 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors" >
< Settings className = "w-4 h-4" / > Настройки
< / b u t t o n >
< button onClick = { ( ) => setGenOpen ( v => ! v ) }
className = "flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium transition-colors" >
< Plus className = "w-4 h-4" / > Сгенерировать
< / b u t t o n >
< button onClick = { load }
className = "p-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors" >
< RefreshCw className = { ` w-4 h-4 ${ loading ? 'animate-spin' : '' } ` } / >
< / b u t t o n >
< / d i v >
< / d i v >
{ /* CONFIG PANEL */ }
{ cfgOpen && config && (
< ConfigPanel config = { config } onSave = { saveConfig } onClose = { ( ) => setCfgOpen ( false ) } / >
) }
{ /* GENERATE FORM */ }
{ genOpen && (
< div className = "rounded-xl border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-950/30 p-5 space-y-3" >
< h3 className = "font-semibold text-sm flex items-center gap-2 text-neutral-900 dark:text-neutral-100" >
< Zap className = "w-4 h-4 text-emerald-500" / > Новый черновик
< / h 3 >
< div className = "grid grid-cols-1 sm:grid-cols-3 gap-3" >
< div >
< label className = "block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1" > channel _id < / l a b e l >
< input type = "number" value = { genForm . channel _id }
onChange = { e => setGenForm ( f => ( { ... f , channel _id : e . target . value } ) ) }
className = "w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" / >
< / d i v >
< div >
< label className = "block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1" > Ведро темы < / l a b e l >
< select value = { genForm . force _bucket }
onChange = { e => setGenForm ( f => ( { ... f , force _bucket : e . target . value } ) ) }
className = "w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" >
< option value = "" > Случайное ( anti - repeat ) < / o p t i o n >
{ buckets . map ( b => < option key = { b . key } value = { b . key } > { b . label } < / o p t i o n > ) }
< / s e l e c t >
< / d i v >
< div className = "flex items-end" >
< label className = "flex items-center gap-2 text-sm cursor-pointer text-neutral-600 dark:text-neutral-400" >
< input type = "checkbox" checked = { genForm . allow _today _dup }
onChange = { e => setGenForm ( f => ( { ... f , allow _today _dup : e . target . checked } ) ) }
className = "accent-emerald-500 w-4 h-4" / >
Разрешить второй черновик за день
< / l a b e l >
< / d i v >
< / d i v >
< div className = "flex gap-2" >
< button onClick = { generate } disabled = { generating }
className = "flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors" >
{ generating ? < Loader2 className = "w-4 h-4 animate-spin" / > : < Zap className = "w-4 h-4" / > }
{ generating ? 'Генерация ~20-30 сек…' : 'Запустить' }
< / b u t t o n >
< button onClick = { ( ) => setGenOpen ( false ) }
className = "px-4 py-2 rounded-lg text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors" >
Отмена
< / b u t t o n >
< / d i v >
< / d i v >
) }
{ /* FILTERS */ }
< div className = "flex flex-wrap gap-1.5 text-sm" >
< FilterTab active = { filter === 'all' } onClick = { ( ) => setFilter ( 'all' ) } label = "Все" count = { notes . length } / >
{ Object . entries ( STATUS _META ) . map ( ( [ key , meta ] ) => {
const cnt = counts [ key ] || 0 ;
if ( cnt === 0 && ! [ 'draft' , 'approved' ] . includes ( key ) ) return null ;
return < FilterTab key = { key } active = { filter === key } onClick = { ( ) => setFilter ( key ) } label = { meta . label } count = { cnt } dotClass = { meta . dot } / > ;
} ) }
< / d i v >
{ /* LIST */ }
{ loading && notes . length === 0 && (
< div className = "py-12 text-center" > < Loader2 className = "w-5 h-5 animate-spin mx-auto text-emerald-500" / > < / d i v >
) }
{ ! loading && filtered . length === 0 && (
< div className = "rounded-xl border border-neutral-200 dark:border-neutral-800 p-10 text-center text-sm text-neutral-400" >
{ notes . length === 0 ? 'Заметок ещё нет — жми «Сгенерировать»' : 'В этом разделе пусто' }
< / d i v >
) }
< div className = "space-y-3" >
{ filtered . map ( n => (
< NoteCard key = { n . id } note = { n } buckets = { buckets }
isEditing = { editId === n . id } editText = { editText } setEditText = { setEditText } saving = { saving }
onStartEdit = { ( ) => { setEditId ( n . id ) ; setEditText ( n . content ) ; } }
onCancelEdit = { ( ) => setEditId ( null ) }
onSaveEdit = { ( ) => saveEdit ( n . id ) }
onApprove = { ( ) => approve ( n . id ) }
onSkip = { ( ) => skip ( n . id ) }
onPublishNow = { ( ) => publishNow ( n . id ) }
isRegen = { regenId === n . id } regenBucket = { regenBucket } setRegenBucket = { setRegenBucket }
onRegenStart = { ( ) => { setRegenId ( n . id ) ; setRegenBucket ( '' ) ; } }
onRegenCancel = { ( ) => setRegenId ( null ) }
onRegenConfirm = { ( ) => regenerate ( n . id ) } / >
) ) }
< / d i v >
< / d i v >
) ;
}
// ─── Sub-components ───────────────────────────────────────────────────────
function FilterTab ( { active , onClick , label , count , dotClass } ) {
return (
< button onClick = { onClick }
className = { ` flex items-center gap-1.5 px-3 py-1.5 rounded-lg border text-sm font-medium transition-colors ${
active
? 'border-emerald-500 text-emerald-700 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-950/40'
: 'border-neutral-200 dark:border-neutral-700 text-neutral-500 hover:bg-neutral-50 dark:hover:bg-neutral-800'
} ` } >
{ dotClass && < span className = { ` w-1.5 h-1.5 rounded-full ${ dotClass } ` } / > }
{ label }
< span className = "text-xs opacity-60" > { count } < / s p a n >
< / b u t t o n >
) ;
}
function ConfigPanel ( { config , onSave , onClose } ) {
const [ form , setForm ] = useState ( {
ZERO _NOTES _CHANNEL _IDS : config . ZERO _NOTES _CHANNEL _IDS || '' ,
ZERO _NOTES _GENERATE _HOUR : config . ZERO _NOTES _GENERATE _HOUR || '13' ,
ZERO _NOTES _APPROVE _HOUR : config . ZERO _NOTES _APPROVE _HOUR || '7' ,
ZERO _NOTES _PUBLISH _HOUR : config . ZERO _NOTES _PUBLISH _HOUR || '13' ,
ZERO _SITE _URL _BASE : config . ZERO _SITE _URL _BASE || '' ,
} ) ;
const enabled = ! ! ( form . ZERO _NOTES _CHANNEL _IDS && form . ZERO _NOTES _CHANNEL _IDS . trim ( ) ) ;
return (
< div className = "rounded-xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-5 space-y-4" >
< div className = "flex items-center justify-between" >
< h3 className = "font-semibold text-sm text-neutral-900 dark:text-neutral-100" > Настройки автогенерации Зеро < / h 3 >
< button onClick = { onClose } className = "p-1 rounded text-neutral-400 hover:text-neutral-600" > < X className = "w-4 h-4" / > < / b u t t o n >
< / d i v >
{ /* Toggle Вкл/Выкл */ }
< div className = "flex items-center justify-between p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800/50" >
< div >
< div className = "text-sm font-medium text-neutral-900 dark:text-neutral-100" > Автогенерация < / d i v >
< div className = "text-xs text-neutral-500 mt-0.5" >
{ enabled ? ` Канал id: ${ form . ZERO _NOTES _CHANNEL _IDS } ` : 'Выключено — scheduler ничего не делает' }
< / d i v >
< / d i v >
< button onClick = { ( ) => {
const next = enabled ? '' : '1' ;
setForm ( f => ( { ... f , ZERO _NOTES _CHANNEL _IDS : next } ) ) ;
onSave ( { ZERO _NOTES _CHANNEL _IDS : next } ) ;
} }
className = { ` px-3 py-1.5 rounded-full text-xs font-medium border transition-colors ${
enabled ? 'bg-emerald-500 text-white border-emerald-500' : 'border-neutral-300 dark:border-neutral-600 text-neutral-500'
} ` } >
{ enabled ? '● Вкл' : '○ Выкл' }
< / b u t t o n >
< / d i v >
< div className = "grid grid-cols-2 sm:grid-cols-4 gap-3" >
< ConfigField label = "Каналы (csv id)" value = { form . ZERO _NOTES _CHANNEL _IDS }
onChange = { v => setForm ( f => ( { ... f , ZERO _NOTES _CHANNEL _IDS : v } ) ) }
placeholder = "1" / >
< ConfigField label = "Час генерации МСК" value = { form . ZERO _NOTES _GENERATE _HOUR }
onChange = { v => setForm ( f => ( { ... f , ZERO _NOTES _GENERATE _HOUR : v } ) ) } type = "number" min = "0" max = "23" / >
< ConfigField label = "Час auto-approve" value = { form . ZERO _NOTES _APPROVE _HOUR }
onChange = { v => setForm ( f => ( { ... f , ZERO _NOTES _APPROVE _HOUR : v } ) ) } type = "number" min = "0" max = "23" / >
< ConfigField label = "Час публикации МСК" value = { form . ZERO _NOTES _PUBLISH _HOUR }
onChange = { v => setForm ( f => ( { ... f , ZERO _NOTES _PUBLISH _HOUR : v } ) ) } type = "number" min = "0" max = "23" / >
< / d i v >
< ConfigField label = "URL базы для inline-кнопки «Открыть на сайте»" value = { form . ZERO _SITE _URL _BASE }
onChange = { v => setForm ( f => ( { ... f , ZERO _SITE _URL _BASE : v } ) ) } placeholder = "https://zeropost.ru/zero" full / >
< div className = "flex gap-2" >
< button onClick = { ( ) => onSave ( form ) }
className = "flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium" >
< Save className = "w-4 h-4" / > Сохранить
< / b u t t o n >
< button onClick = { onClose }
className = "px-4 py-2 rounded-lg text-sm text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" >
Закрыть
< / b u t t o n >
< / d i v >
< p className = "text-xs text-neutral-400" >
Время в МСК . Генерация ≠ публикация : в час генерации создаётся черновик со scheduled _at на ближайший час публикации ( по умолчанию на сутки вперёд ) .
< / p >
< / d i v >
) ;
}
function ConfigField ( { label , value , onChange , type = 'text' , placeholder , min , max , full } ) {
return (
< div className = { full ? 'col-span-full' : '' } >
< label className = "block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1" > { label } < / l a b e l >
< input type = { type } value = { value } onChange = { e => onChange ( e . target . value ) } placeholder = { placeholder } min = { min } max = { max }
className = "w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" / >
< / d i v >
) ;
}
function NoteCard ( {
note , buckets ,
isEditing , editText , setEditText , saving , onStartEdit , onCancelEdit , onSaveEdit ,
onApprove , onSkip , onPublishNow ,
isRegen , regenBucket , setRegenBucket , onRegenStart , onRegenCancel , onRegenConfirm ,
} ) {
const meta = STATUS _META [ note . status ] || STATUS _META . draft ;
const Icon = bucketIcon ( note . theme _bucket ) ;
const bucketLabel = buckets . find ( b => b . key === note . theme _bucket ) ? . label || note . theme _bucket ;
const canApprove = note . status === 'draft' ;
const canEdit = [ 'draft' , 'approved' ] . includes ( note . status ) ;
const canRegen = [ 'draft' , 'failed' ] . includes ( note . status ) ;
const canSkip = [ 'draft' , 'approved' ] . includes ( note . status ) ;
const canPubNow = [ 'draft' , 'approved' ] . includes ( note . status ) ;
return (
< div className = { ` rounded-xl border ${ meta . border } ${ meta . bg } p-5 transition-colors ` } >
{ /* HEADER */ }
< div className = "flex items-start gap-3 mb-3" >
< Icon className = "w-5 h-5 text-emerald-500 shrink-0 mt-0.5" / >
< div className = "flex-1 min-w-0" >
< div className = "flex flex-wrap items-center gap-2 text-xs" >
< span className = "font-mono text-neutral-400" > # { note . id } < / s p a n >
< span className = { ` inline-flex items-center gap-1.5 px-2 py-0.5 rounded font-medium ${ meta . text } ` } >
< span className = { ` w-1.5 h-1.5 rounded-full ${ meta . dot } ` } / > { meta . label }
< / s p a n >
< span className = "text-neutral-500" > · { bucketLabel } < / s p a n >
{ note . pose && < span className = "text-neutral-400" > · поза : { note . pose } < / s p a n > }
< span className = "text-neutral-500" >
· { note . status === 'published'
? ` опубликовано ${ mskTime ( note . published _at ) } `
: ` на ${ mskTime ( note . scheduled _at ) } МСК ` }
< / s p a n >
< / d i v >
{ note . theme && < div className = "text-xs text-neutral-500 mt-1 truncate" > тема : { note . theme } < / d i v > }
< / d i v >
< / d i v >
{ /* CONTENT or EDIT */ }
{ isEditing ? (
< div className = "space-y-2" >
< textarea value = { editText } onChange = { e => setEditText ( e . target . value ) }
rows = { Math . max ( 6 , editText . split ( '\n' ) . length + 1 ) }
className = "w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-emerald-500 font-mono" / >
< div className = "flex items-center gap-2 text-xs text-neutral-500" >
< span > { wordCount ( editText ) } слов · { editText . length } символов < / s p a n >
< div className = "flex-1" / >
< button onClick = { onCancelEdit } className = "flex items-center gap-1 px-3 py-1 rounded text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" >
< X className = "w-3 h-3" / > Отмена
< / b u t t o n >
< button onClick = { onSaveEdit } disabled = { saving }
className = "flex items-center gap-1 px-3 py-1 rounded bg-emerald-500 hover:bg-emerald-600 text-white" >
{ saving ? < Loader2 className = "w-3 h-3 animate-spin" / > : < Save className = "w-3 h-3" / > } Сохранить
< / b u t t o n >
< / d i v >
< / d i v >
) : (
< div className = "text-sm whitespace-pre-wrap leading-relaxed text-neutral-800 dark:text-neutral-200" > { note . content } < / d i v >
) }
{ /* ERROR */ }
{ note . error && (
< div className = "mt-3 text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900 rounded p-2" >
Ошибка : { note . error }
{ note . attempts > 0 && < span className = "text-neutral-500" > · попыток : { note . attempts } < / s p a n > }
< / d i v >
) }
{ /* REGEN PICKER */ }
{ isRegen && (
< div className = "mt-3 p-3 border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-950/30 rounded-lg space-y-2" >
< div className = "text-xs text-neutral-600 dark:text-neutral-400" > Выбери ведро ( или оставь пусто для anti - repeat ) : < / d i v >
< select value = { regenBucket } onChange = { e => setRegenBucket ( e . target . value ) }
className = "w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" >
< option value = "" > Случайное ( anti - repeat ) < / o p t i o n >
{ buckets . map ( b => < option key = { b . key } value = { b . key } > { b . label } < / o p t i o n > ) }
< / s e l e c t >
< div className = "flex gap-2" >
< button onClick = { onRegenConfirm } className = "flex items-center gap-1 px-3 py-1 rounded bg-emerald-500 hover:bg-emerald-600 text-white text-xs font-medium" >
< Zap className = "w-3 h-3" / > Перегенерировать
< / b u t t o n >
< button onClick = { onRegenCancel } className = "px-3 py-1 rounded text-xs text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800" > Отмена < / b u t t o n >
< / d i v >
< / d i v >
) }
{ /* ACTIONS */ }
{ ! isEditing && ! isRegen && (
< div className = "mt-3 flex flex-wrap items-center gap-2 text-xs" >
{ canApprove && (
< button onClick = { onApprove } className = "flex items-center gap-1 px-3 py-1.5 rounded bg-emerald-500 hover:bg-emerald-600 text-white font-medium" >
< Check className = "w-3 h-3" / > Одобрить
< / b u t t o n >
) }
{ canPubNow && (
< button onClick = { onPublishNow } className = "flex items-center gap-1 px-3 py-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-white dark:hover:bg-neutral-800 text-emerald-700 dark:text-emerald-400" >
< Send className = "w-3 h-3" / > Опубликовать сейчас
< / b u t t o n >
) }
{ canEdit && (
< button onClick = { onStartEdit } className = "flex items-center gap-1 px-3 py-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-white dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-300" >
< Edit3 className = "w-3 h-3" / > Редактировать
< / b u t t o n >
) }
{ canRegen && (
< button onClick = { onRegenStart } className = "flex items-center gap-1 px-3 py-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-white dark:hover:bg-neutral-800 text-neutral-700 dark:text-neutral-300" >
< RefreshCw className = "w-3 h-3" / > Перегенерировать
< / b u t t o n >
) }
{ canSkip && (
< button onClick = { onSkip } className = "flex items-center gap-1 px-3 py-1.5 rounded text-neutral-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30" >
< Trash2 className = "w-3 h-3" / > Пропустить
< / b u t t o n >
) }
{ note . status === 'published' && note . channel _message _id && (
< span className = "text-neutral-500 flex items-center gap-1" >
< Send className = "w-3 h-3" / > TG msg # { note . channel _message _id }
< / s p a n >
) }
< div className = "flex-1" / >
< span className = "text-neutral-400 font-mono" >
{ note . model || '' }
{ note . tokens _in ? ` · ${ note . tokens _in } → ${ note . tokens _out } tok ` : '' }
< / s p a n >
< / d i v >
) }
< / d i v >
) ;
}