feat: Заметки редактора в admin zeropost.ru

- app/admin/(protected)/notes/page.js: управление заметками
- app/admin/api/notes/route.js: GET+POST прокси к engine
- app/admin/api/notes/[id]/route.js: PATCH+DELETE
- lib/engine.js: createNote, updateNote, deleteNote
- components/admin/AdminNav.js: пункт «Заметки» с иконкой
This commit is contained in:
Ник (Claude)
2026-06-09 11:55:55 +03:00
parent 334b2f51df
commit 290fc160cc
5 changed files with 197 additions and 1 deletions
+139
View File
@@ -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">Заметки редактора</h1>
<p className="text-sm text-neutral-500 mt-1">Отображаются на главной странице и на <a href="/notes" target="_blank" className="text-emerald-600 hover:underline">/notes</a></p>
</div>
<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" /> Новая заметка
</button>
</div>
{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' ? 'Новая заметка' : 'Редактировать'}
</div>
<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" />
Закрепить
</label>
</div>
<div className="text-xs text-neutral-400 mb-3">{form.content.length}/1000</div>
{err && <div className="text-xs text-red-500 mb-3">{err}</div>}
<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 ? 'Сохранено' : 'Сохранить'}
</button>
<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">
Отмена
</button>
</div>
</div>
)}
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-emerald-500" /></div>}
{!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">
Заметок пока нет нажми «Новая заметка»
</div>
)}
<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" /> закреплено</div>}
{note.title && <div className="font-semibold text-sm text-neutral-900 dark:text-neutral-100 mb-1">{note.title}</div>}
<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 ? ' · скрыта' : ''}</div>
</div>
<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="Редактировать"></button>
<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" />}
</button>
<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" />}
</button>
<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" />
</button>
</div>
</div>
</div>
))}
</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/adminAuth';
import { updateNote, deleteNote } from '@/lib/engine';
export async function PATCH(req, { params }) {
await requireAdminAuth();
try {
const { id } = await params;
const body = await req.json();
const note = await updateNote(id, body);
return NextResponse.json(note);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
export async function DELETE(req, { params }) {
await requireAdminAuth();
try {
const { id } = await params;
await deleteNote(id);
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+20
View File
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { requireAdminAuth } from '@/lib/adminAuth';
import { listNotes, createNote } from '@/lib/engine';
export async function GET() {
await requireAdminAuth();
const notes = await listNotes({ limit: 100 });
return NextResponse.json(notes);
}
export async function POST(req) {
await requireAdminAuth();
try {
const body = await req.json();
const note = await createNote(body);
return NextResponse.json(note);
} catch (err) {
return NextResponse.json({ error: err.message }, { status: 500 });
}
}
+2 -1
View File
@@ -1,13 +1,14 @@
'use client';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink } from 'lucide-react';
import { LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink, MessageCircle } from 'lucide-react';
const NAV = [
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
{ href: '/admin/notes', label: 'Заметки', icon: MessageCircle },
{ href: '/admin/settings', label: 'Настройки', icon: Settings },
];
+10
View File
@@ -68,6 +68,16 @@ export async function listNotes({ limit = 20 } = {}) {
catch { return []; }
}
export async function createNote(data) {
return call('/api/notes', { method: 'POST', body: JSON.stringify(data) });
}
export async function updateNote(id, data) {
return call(`/api/notes/${id}`, { method: 'PATCH', body: JSON.stringify(data) });
}
export async function deleteNote(id) {
return call(`/api/notes/${id}`, { method: 'DELETE' });
}
export async function getStats() {
try {
return await call('/api/stats', { cache: 'no-store' });