3e04df32c5
- 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 иконка)
169 lines
7.6 KiB
JavaScript
169 lines
7.6 KiB
JavaScript
'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>
|
||
);
|
||
}
|