Files
postcast-tool/app/notes/page.js
T
Ник (Claude) 3e04df32c5 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 иконка)
2026-06-09 11:44:33 +03:00

169 lines
7.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}