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:
Ник (Claude)
2026-06-09 11:44:33 +03:00
parent d413f5f018
commit 3e04df32c5
5 changed files with 233 additions and 1 deletions
+28
View File
@@ -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 });
}
}
+26
View File
@@ -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 });
}
}
+168
View File
@@ -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>
);
}
+5 -1
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Sparkles, LogOut, Settings2, CalendarDays } from 'lucide-react'; import { Sparkles, LogOut, Settings2, CalendarDays, MessageCircle } from 'lucide-react';
import ThemeToggle from './ThemeToggle'; import ThemeToggle from './ThemeToggle';
export default function Header({ user }) { export default function Header({ user }) {
@@ -22,6 +22,10 @@ export default function Header({ user }) {
<CalendarDays className="w-4 h-4" /> <CalendarDays className="w-4 h-4" />
<span>Календарь</span> <span>Календарь</span>
</Link> </Link>
<Link href="/notes" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
<MessageCircle className="w-4 h-4" />
<span>Заметки</span>
</Link>
</nav> </nav>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{user?.isAdmin && ( {user?.isAdmin && (
+6
View File
@@ -76,6 +76,12 @@ export const engine = {
}, },
usageRecent: (limit = 20) => call(`/api/usage/recent?limit=${limit}`), usageRecent: (limit = 20) => call(`/api/usage/recent?limit=${limit}`),
// Editor notes
listNotes: () => call('/api/notes?limit=100'),
createNote: (data) => call('/api/notes', { method: 'POST', body: data }),
updateNote: (id, data) => call(`/api/notes/${id}`, { method: 'PATCH', body: data }),
deleteNote: (id) => call(`/api/notes/${id}`, { method: 'DELETE' }),
// Calendar // Calendar
getCalendar: (userId, params = {}) => { getCalendar: (userId, params = {}) => {
const qs = new URLSearchParams(params).toString(); const qs = new URLSearchParams(params).toString();