@@ -0,0 +1,139 @@
'use client' ;
import { useState , useEffect } from 'react' ;
import { Pin , PinOff , Trash2 , Plus , Save , Eye , EyeOff , Loader2 , Check } from 'lucide-react' ;
const EMPTY = { title : '' , content : '' , author : 'Редактор' , is _pinned : false } ;
export default function AdminNotesPage ( ) {
const [ notes , setNotes ] = useState ( [ ] ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ editing , setEditing ] = useState ( null ) ;
const [ form , setForm ] = useState ( EMPTY ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ saved , setSaved ] = useState ( false ) ;
const [ err , setErr ] = useState ( '' ) ;
async function load ( ) {
setLoading ( true ) ;
try {
const r = await fetch ( '/admin/api/notes' ) ;
setNotes ( await r . json ( ) ) ;
} catch ( e ) { setErr ( e . message ) ; }
finally { setLoading ( false ) ; }
}
useEffect ( ( ) => { load ( ) ; } , [ ] ) ;
function startNew ( ) { setForm ( EMPTY ) ; setEditing ( 'new' ) ; setErr ( '' ) ; setSaved ( false ) ; }
function startEdit ( n ) { setForm ( { title : n . title || '' , content : n . content , author : n . author , is _pinned : n . is _pinned } ) ; setEditing ( n ) ; setErr ( '' ) ; setSaved ( false ) ; }
async function save ( ) {
if ( ! form . content . trim ( ) ) { setErr ( 'Текст обязателен' ) ; return ; }
setSaving ( true ) ; setErr ( '' ) ;
try {
const body = { ... form , title : form . title . trim ( ) || null } ;
const method = editing === 'new' ? 'POST' : 'PATCH' ;
const url = editing === 'new' ? '/admin/api/notes' : ` /admin/api/notes/ ${ editing . id } ` ;
const r = await fetch ( url , { method , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( body ) } ) ;
if ( ! r . ok ) throw new Error ( await r . text ( ) ) ;
setSaved ( true ) ;
setTimeout ( ( ) => { setEditing ( null ) ; setSaved ( false ) ; } , 800 ) ;
await load ( ) ;
} catch ( e ) { setErr ( e . message ) ; }
finally { setSaving ( false ) ; }
}
async function toggle ( note , field ) {
await fetch ( ` /admin/api/notes/ ${ note . id } ` , { method : 'PATCH' , headers : { 'Content-Type' : 'application/json' } , body : JSON . stringify ( { [ field ] : ! note [ field ] } ) } ) ;
await load ( ) ;
}
async function del ( note ) {
if ( ! confirm ( ` Удалить: « ${ ( note . title || note . content ) . slice ( 0 , 50 ) } »? ` ) ) return ;
await fetch ( ` /admin/api/notes/ ${ note . id } ` , { method : 'DELETE' } ) ;
await load ( ) ;
}
const fmt = d => new Date ( d ) . toLocaleDateString ( 'ru-RU' , { day : 'numeric' , month : 'short' , year : 'numeric' } ) ;
return (
< div className = "max-w-3xl" >
< div className = "flex items-center justify-between mb-6" >
< div >
< h1 className = "text-2xl font-bold text-neutral-900 dark:text-neutral-100" > Заметки редактора < / h 1 >
< p className = "text-sm text-neutral-500 mt-1" > Отображаются на главной странице и на < a href = "/notes" target = "_blank" className = "text-emerald-600 hover:underline" > / n o t e s < / a > < / p >
< / d i v >
< button onClick = { startNew } className = "flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium transition-colors" >
< Plus className = "w-4 h-4" / > Новая заметка
< / b u t t o n >
< / d i v >
{ editing && (
< div className = "rounded-xl border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-950/30 p-5 mb-5" >
< div className = "text-sm font-semibold mb-3 text-neutral-700 dark:text-neutral-300" >
{ editing === 'new' ? 'Новая заметка' : 'Редактировать' }
< / d i v >
< input 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 mb-3 outline-none focus:ring-2 focus:ring-emerald-500"
placeholder = "Заголовок (необязательно)" value = { form . title } onChange = { e => setForm ( f => ( { ... f , title : e . target . value } ) ) } / >
< textarea 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 min-h-[110px] mb-3 outline-none focus:ring-2 focus:ring-emerald-500 resize-none"
placeholder = "Текст заметки..." value = { form . content } onChange = { e => setForm ( f => ( { ... f , content : e . target . value } ) ) } maxLength = { 1000 } / >
< div className = "flex items-center gap-3 mb-3" >
< input className = "flex-1 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-sm outline-none focus:ring-2 focus:ring-emerald-500"
placeholder = "Автор" value = { form . author } onChange = { e => setForm ( f => ( { ... f , author : e . target . value } ) ) } / >
< label className = "flex items-center gap-2 text-sm cursor-pointer select-none text-neutral-600 dark:text-neutral-400" >
< input type = "checkbox" checked = { form . is _pinned } onChange = { e => setForm ( f => ( { ... f , is _pinned : e . target . checked } ) ) } className = "accent-emerald-500 w-4 h-4" / >
Закрепить
< / l a b e l >
< / d i v >
< div className = "text-xs text-neutral-400 mb-3" > { form . content . length } / 1000 < / d i v >
{ err && < div className = "text-xs text-red-500 mb-3" > { err } < / d i v > }
< div className = "flex gap-2" >
< button onClick = { save } disabled = { saving }
className = "flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 disabled:opacity-50 text-white text-sm font-medium transition-colors" >
{ saving ? < Loader2 className = "w-4 h-4 animate-spin" / > : saved ? < Check className = "w-4 h-4" / > : < Save className = "w-4 h-4" / > }
{ saved ? 'Сохранено' : 'Сохранить' }
< / b u t t o n >
< button onClick = { ( ) => setEditing ( null ) } 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 >
) }
{ loading && < div className = "py-12 text-center" > < Loader2 className = "w-5 h-5 animate-spin mx-auto text-emerald-500" / > < / d i v > }
{ ! loading && notes . length === 0 && ! editing && (
< div className = "rounded-xl border border-neutral-200 dark:border-neutral-800 p-10 text-center text-sm text-neutral-400" >
Заметок пока нет — нажми « Новая заметка »
< / d i v >
) }
< div className = "space-y-3" >
{ notes . map ( note => (
< div key = { note . id } className = { ` rounded-xl border p-4 transition-opacity ${ note . is _published ? 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900' : 'border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-950 opacity-60' } ` } >
< div className = "flex items-start gap-3" >
< div className = "flex-1 min-w-0" >
{ note . is _pinned && < div className = "text-xs text-emerald-600 dark:text-emerald-400 mb-1 flex items-center gap-1" > < Pin className = "w-3 h-3" / > закреплено < / d i v > }
{ note . title && < div className = "font-semibold text-sm text-neutral-900 dark:text-neutral-100 mb-1" > { note . title } < / d i v > }
< p className = "text-sm text-neutral-600 dark:text-neutral-400 whitespace-pre-line line-clamp-3" > { note . content } < / p >
< div className = "text-xs text-neutral-400 mt-2" > { note . author } · { fmt ( note . created _at ) } { ! note . is _published ? ' · скрыта' : '' } < / d i v >
< / d i v >
< div className = "flex items-center gap-1 shrink-0" >
< button onClick = { ( ) => startEdit ( note ) } className = "p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-400 hover:text-neutral-600 transition-colors" title = "Редактировать" > ✏ ️ < / b u t t o n >
< button onClick = { ( ) => toggle ( note , 'is_pinned' ) } className = "p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-400 hover:text-neutral-600 transition-colors" title = { note . is _pinned ? 'Открепить' : 'Закрепить' } >
{ note . is _pinned ? < PinOff className = "w-4 h-4" / > : < Pin className = "w-4 h-4" / > }
< / b u t t o n >
< button onClick = { ( ) => toggle ( note , 'is_published' ) } className = "p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-400 hover:text-neutral-600 transition-colors" title = { note . is _published ? 'Скрыть' : 'Опубликовать' } >
{ note . is _published ? < Eye className = "w-4 h-4" / > : < EyeOff className = "w-4 h-4" / > }
< / b u t t o n >
< button onClick = { ( ) => del ( note ) } className = "p-1.5 rounded hover:bg-red-50 dark:hover:bg-red-950 text-neutral-400 hover:text-red-500 transition-colors" title = "Удалить" >
< Trash2 className = "w-4 h-4" / >
< / b u t t o n >
< / d i v >
< / d i v >
< / d i v >
) ) }
< / d i v >
< / d i v >
) ;
}