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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
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 = [
|
const NAV = [
|
||||||
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
|
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
|
||||||
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
|
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
|
||||||
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
|
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
|
||||||
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
|
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
|
||||||
|
{ href: '/admin/notes', label: 'Заметки', icon: MessageCircle },
|
||||||
{ href: '/admin/settings', label: 'Настройки', icon: Settings },
|
{ href: '/admin/settings', label: 'Настройки', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,16 @@ export async function listNotes({ limit = 20 } = {}) {
|
|||||||
catch { return []; }
|
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() {
|
export async function getStats() {
|
||||||
try {
|
try {
|
||||||
return await call('/api/stats', { cache: 'no-store' });
|
return await call('/api/stats', { cache: 'no-store' });
|
||||||
|
|||||||
Reference in New Issue
Block a user