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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user