forked from admin/zeropost-tool
feat: Notes manager — Заметки редактора в app.zeropost.ru
- app/notes/page.js: страница управления заметками (создать/редактировать/ удалить/закрепить/скрыть). Список с превью, inline-форма. - app/api/notes/route.js: GET+POST прокси к engine /api/notes - app/api/notes/[id]/route.js: PATCH+DELETE прокси - lib/engine.js: listNotes, createNote, updateNote, deleteNote - Header.js: ссылка «Заметки» в навигации (MessageCircle иконка)
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
import { engine } from '@/lib/engine';
|
||||
|
||||
export async function PATCH(req, { params }) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const note = await engine.updateNote(id, body);
|
||||
return NextResponse.json(note);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req, { params }) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
try {
|
||||
const { id } = await params;
|
||||
await engine.deleteNote(id);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { requireAdmin } from '@/lib/session';
|
||||
import { engine } from '@/lib/engine';
|
||||
|
||||
export async function GET(req) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
try {
|
||||
const notes = await engine.listNotes();
|
||||
return NextResponse.json(notes);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req) {
|
||||
const admin = await requireAdmin();
|
||||
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
try {
|
||||
const body = await req.json();
|
||||
const note = await engine.createNote(body);
|
||||
return NextResponse.json(note);
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Pin, PinOff, Trash2, Plus, Save, Eye, EyeOff, Loader2, MessageCircle, Check, ExternalLink } from 'lucide-react';
|
||||
|
||||
const EMPTY = { title: '', content: '', author: 'Редактор', is_pinned: false };
|
||||
|
||||
export default function NotesPage() {
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(null); // null | 'new' | note object
|
||||
const [form, setForm] = useState(EMPTY);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const r = await fetch('/api/notes');
|
||||
setNotes(await r.json());
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function startNew() {
|
||||
setForm(EMPTY);
|
||||
setEditing('new');
|
||||
setErr('');
|
||||
}
|
||||
|
||||
function startEdit(note) {
|
||||
setForm({ title: note.title || '', content: note.content, author: note.author, is_pinned: note.is_pinned });
|
||||
setEditing(note);
|
||||
setErr('');
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!form.content.trim()) { setErr('Текст заметки обязателен'); return; }
|
||||
setSaving(true); setErr('');
|
||||
try {
|
||||
const body = { ...form, title: form.title.trim() || null };
|
||||
if (editing === 'new') {
|
||||
await fetch('/api/notes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
} else {
|
||||
await fetch(`/api/notes/${editing.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
}
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) { setErr(e.message); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
async function togglePublish(note) {
|
||||
await fetch(`/api/notes/${note.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_published: !note.is_published }) });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function togglePin(note) {
|
||||
await fetch(`/api/notes/${note.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_pinned: !note.is_pinned }) });
|
||||
await load();
|
||||
}
|
||||
|
||||
async function del(note) {
|
||||
if (!confirm(`Удалить заметку «${note.title || note.content.slice(0, 40)}»?`)) return;
|
||||
await fetch(`/api/notes/${note.id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
}
|
||||
|
||||
const fmt = (d) => new Date(d).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageCircle className="w-5 h-5 text-accent" />
|
||||
<h1 className="text-xl font-bold">Заметки редактора</h1>
|
||||
<a href="https://zeropost.ru/notes" target="_blank" rel="noreferrer" className="text-gray-500 hover:text-accent">
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
<button onClick={startNew} className="btn-primary text-sm flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Новая заметка
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Форма создания/редактирования */}
|
||||
{editing && (
|
||||
<div className="card p-5 mb-5 border-accent/30">
|
||||
<div className="text-sm font-semibold mb-3">{editing === 'new' ? 'Новая заметка' : 'Редактировать'}</div>
|
||||
<input
|
||||
className="input text-sm mb-3"
|
||||
placeholder="Заголовок (опц.)"
|
||||
value={form.title}
|
||||
onChange={e => setForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
className="input text-sm min-h-[120px] mb-3"
|
||||
placeholder="Текст заметки. Коротко и по делу — виден блок на главной странице."
|
||||
value={form.content}
|
||||
onChange={e => setForm(f => ({ ...f, content: e.target.value }))}
|
||||
maxLength={1000}
|
||||
/>
|
||||
<div className="flex items-center gap-4 mb-3">
|
||||
<input
|
||||
className="input text-sm flex-1"
|
||||
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">
|
||||
<input type="checkbox" checked={form.is_pinned} onChange={e => setForm(f => ({ ...f, is_pinned: e.target.checked }))} className="accent-accent w-4 h-4" />
|
||||
Закрепить
|
||||
</label>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mb-3">{form.content.length}/1000 символов</div>
|
||||
{err && <div className="text-xs text-red-400 mb-3">{err}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={save} disabled={saving} className="btn-primary text-sm">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
{saving ? 'Сохраняю...' : 'Сохранить'}
|
||||
</button>
|
||||
<button onClick={() => setEditing(null)} className="btn-ghost text-sm">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="text-center py-12"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
||||
|
||||
{!loading && notes.length === 0 && (
|
||||
<div className="card p-8 text-center text-gray-500 text-sm">
|
||||
Заметок пока нет. Нажми «Новая заметка» чтобы добавить первую.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{notes.map(note => (
|
||||
<div key={note.id} className={`card p-4 ${!note.is_published ? 'opacity-60' : ''}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{note.is_pinned && (
|
||||
<div className="flex items-center gap-1 text-xs text-accent mb-1">
|
||||
<Pin className="w-3 h-3" /> закреплено
|
||||
</div>
|
||||
)}
|
||||
{note.title && <div className="font-semibold text-sm mb-1">{note.title}</div>}
|
||||
<p className="text-sm text-gray-300 whitespace-pre-line line-clamp-4">{note.content}</p>
|
||||
<div className="text-xs text-gray-500 mt-2">{note.author} · {fmt(note.created_at)}{!note.is_published ? ' · скрыта' : ''}</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 shrink-0">
|
||||
<button onClick={() => startEdit(note)} className="btn-ghost p-1.5 text-xs" title="Редактировать">✏️</button>
|
||||
<button onClick={() => togglePin(note)} className="btn-ghost p-1.5" title={note.is_pinned ? 'Открепить' : 'Закрепить'}>
|
||||
{note.is_pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
||||
</button>
|
||||
<button onClick={() => togglePublish(note)} className="btn-ghost p-1.5" 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="btn-ghost p-1.5 hover:text-red-400" title="Удалить">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user