From 3e04df32c5b51cf04d553a690380e3ac4eaed93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Tue, 9 Jun 2026 11:44:33 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20Notes=20manager=20=E2=80=94=20=D0=97?= =?UTF-8?q?=D0=B0=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B4=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B0=20=D0=B2=20app.zeropost.ru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 иконка) --- app/api/notes/[id]/route.js | 28 ++++++ app/api/notes/route.js | 26 ++++++ app/notes/page.js | 168 ++++++++++++++++++++++++++++++++++++ components/Header.js | 6 +- lib/engine.js | 6 ++ 5 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 app/api/notes/[id]/route.js create mode 100644 app/api/notes/route.js create mode 100644 app/notes/page.js diff --git a/app/api/notes/[id]/route.js b/app/api/notes/[id]/route.js new file mode 100644 index 0000000..cfe0109 --- /dev/null +++ b/app/api/notes/[id]/route.js @@ -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 }); + } +} diff --git a/app/api/notes/route.js b/app/api/notes/route.js new file mode 100644 index 0000000..2252211 --- /dev/null +++ b/app/api/notes/route.js @@ -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 }); + } +} diff --git a/app/notes/page.js b/app/notes/page.js new file mode 100644 index 0000000..721fd51 --- /dev/null +++ b/app/notes/page.js @@ -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 ( +
+
+
+ +

Заметки редактора

+ + + +
+ +
+ + {/* Форма создания/редактирования */} + {editing && ( +
+
{editing === 'new' ? 'Новая заметка' : 'Редактировать'}
+ setForm(f => ({ ...f, title: e.target.value }))} + /> +