feat: post saving, instant publish, scheduling, post history per channel

This commit is contained in:
Alexey Pavlov
2026-05-31 17:36:02 +03:00
parent e2b64baf2e
commit 1a1eac16ee
5 changed files with 262 additions and 2 deletions
+13
View File
@@ -0,0 +1,13 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function POST(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({error:'Unauthorized'},{status:401});
const { id } = await params;
try {
const data = await engine.publishPost(user.id, id);
return NextResponse.json(data);
} catch (e) { return NextResponse.json({error:e.message},{status:500}); }
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function PATCH(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({error:'Unauthorized'},{status:401});
const { id } = await params;
const body = await req.json();
try {
const data = await engine.updatePost(user.id, id, body);
return NextResponse.json(data);
} catch (e) { return NextResponse.json({error:e.message},{status:500}); }
}
export async function DELETE(req, { params }) {
const user = await requireUser();
if (!user) return NextResponse.json({error:'Unauthorized'},{status:401});
const { id } = await params;
try {
await engine.deletePost(user.id, id);
return NextResponse.json({ok:true});
} catch (e) { return NextResponse.json({error:e.message},{status:500}); }
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from 'next/server';
import { requireUser } from '@/lib/session';
import { engine } from '@/lib/engine';
export async function GET(req) {
const user = await requireUser();
if (!user) return NextResponse.json({error:'Unauthorized'},{status:401});
const { searchParams } = new URL(req.url);
const params = {};
for (const [k,v] of searchParams) params[k] = v;
try {
const data = await engine.listUserPosts(user.id, params);
return NextResponse.json(data);
} catch (e) { return NextResponse.json({error:e.message},{status:500}); }
}
export async function POST(req) {
const user = await requireUser();
if (!user) return NextResponse.json({error:'Unauthorized'},{status:401});
const body = await req.json();
try {
const data = await engine.savePost(user.id, body);
return NextResponse.json(data);
} catch (e) { return NextResponse.json({error:e.message},{status:500}); }
}
+189 -2
View File
@@ -1,10 +1,10 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { import {
ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings, ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings,
Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart, Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart,
MessageSquare, Pencil, X, ChevronDown MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2
} from 'lucide-react'; } from 'lucide-react';
const GOAL_LABELS = { const GOAL_LABELS = {
@@ -66,6 +66,94 @@ export default function ChannelView({ channel }) {
} }
} }
// Сохранение и публикация
const [savedPostId, setSavedPostId] = useState(null);
const [publishing, setPublishing] = useState(false);
const [showScheduler, setShowScheduler] = useState(false);
const [scheduleAt, setScheduleAt] = useState('');
const [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false);
// Подгрузка истории при монтировании
useEffect(() => { loadHistory(); }, []);
async function loadHistory() {
setLoadingHistory(true);
try {
const res = await fetch(`/api/user-posts?channel_id=${channel.id}&limit=20`);
const data = await res.json();
if (Array.isArray(data)) setHistory(data);
} catch {} finally { setLoadingHistory(false); }
}
async function savePost(status = 'draft', scheduledAt = null) {
if (!post) return;
setPublishing(true);
setError('');
try {
let id = savedPostId;
if (!id) {
// Создаём
const res = await fetch('/api/user-posts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel_id: channel.id, content: post, image_url: image,
topic: topic.trim(), status, scheduled_at: scheduledAt,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
id = data.id;
setSavedPostId(id);
} else {
// Обновляем
const res = await fetch(`/api/user-posts/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: post, image_url: image, status, scheduled_at: scheduledAt }),
});
if (!res.ok) throw new Error((await res.json()).error || 'Ошибка');
}
await loadHistory();
return id;
} catch (err) {
setError(err.message);
return null;
} finally {
setPublishing(false);
}
}
async function publishNow() {
const id = await savePost('draft');
if (!id) return;
setPublishing(true);
try {
const res = await fetch(`/api/user-posts/${id}/publish`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка');
await loadHistory();
setPost(null);
setSavedPostId(null);
setImage(null);
setTopic('');
} catch (err) { setError(err.message); }
finally { setPublishing(false); }
}
async function schedule() {
if (!scheduleAt) return setError('Укажите время');
const id = await savePost('scheduled', new Date(scheduleAt).toISOString());
if (!id) return;
setShowScheduler(false);
setScheduleAt('');
setPost(null);
setSavedPostId(null);
setImage(null);
setTopic('');
}
async function generate(asVariant = false) { async function generate(asVariant = false) {
if (!topic.trim() && !asVariant) return; if (!topic.trim() && !asVariant) return;
if (asVariant && !post) return; if (asVariant && !post) return;
@@ -340,6 +428,55 @@ export default function ChannelView({ channel }) {
</div> </div>
)} )}
{/* Публикация */}
<div className="mt-5 pt-4 border-t border-border">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Публикация</div>
<div className="flex flex-wrap gap-2">
<button
onClick={publishNow}
disabled={publishing}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-accent hover:bg-accent/90 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{publishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
{publishing ? 'Публикую...' : 'Опубликовать сейчас'}
</button>
<button
onClick={() => setShowScheduler(v => !v)}
disabled={publishing}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg border border-border bg-surface2 hover:border-accent text-sm transition-colors disabled:opacity-50"
>
<Clock className="w-4 h-4" />
Запланировать
</button>
<button
onClick={() => savePost('draft').then(() => loadHistory())}
disabled={publishing}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg border border-border bg-surface2 hover:border-accent text-sm transition-colors disabled:opacity-50"
>
Сохранить черновик
</button>
</div>
{/* Планировщик */}
{showScheduler && (
<div className="mt-3 p-3 rounded-lg bg-surface2 border border-border">
<label className="label text-xs">Время публикации (МСК)</label>
<div className="flex gap-2">
<input
type="datetime-local"
value={scheduleAt}
onChange={e => setScheduleAt(e.target.value)}
className="input text-sm flex-1"
min={new Date(Date.now() + 60000).toISOString().slice(0, 16)}
/>
<button onClick={schedule} disabled={!scheduleAt || publishing} className="btn-primary text-sm">
{publishing ? '...' : 'Запланировать'}
</button>
</div>
</div>
)}
</div>
{/* Трансформации */} {/* Трансформации */}
<div className="mt-5 pt-4 border-t border-border"> <div className="mt-5 pt-4 border-t border-border">
<div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Переработать</div> <div className="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Переработать</div>
@@ -388,6 +525,56 @@ export default function ChannelView({ channel }) {
</div> </div>
</div> </div>
)} )}
{/* История постов */}
{history.length > 0 && (
<div className="card p-5 mt-4">
<h3 className="font-semibold text-sm mb-3">История постов канала</h3>
<div className="space-y-2">
{history.map(p => {
const statusColors = {
draft: 'bg-gray-500/15 text-gray-400',
scheduled: 'bg-blue-500/15 text-blue-400',
published: 'bg-accent/15 text-accent',
failed: 'bg-red-500/15 text-red-400',
};
const statusLabels = { draft: 'Черновик', scheduled: 'Запланирован', published: 'Опубликован', failed: 'Ошибка' };
return (
<div key={p.id} className="flex items-start gap-3 p-3 rounded-lg bg-surface2 border border-border">
{p.image_url && (
<img src={p.image_url} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="text-xs text-gray-400 line-clamp-2 whitespace-pre-wrap mb-1">{p.content.slice(0, 200)}</div>
<div className="flex items-center gap-2 text-xs">
<span className={`px-1.5 py-0.5 rounded font-medium ${statusColors[p.status] || statusColors.draft}`}>
{statusLabels[p.status] || p.status}
</span>
{p.scheduled_at && p.status === 'scheduled' && (
<span className="text-blue-400"> {new Date(p.scheduled_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{p.published_at && (
<span className="text-gray-500">{new Date(p.published_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{!p.scheduled_at && !p.published_at && (
<span className="text-gray-500">{new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
)}
{p.error && (
<span className="text-red-400 truncate">{p.error}</span>
)}
</div>
</div>
<button
onClick={() => { setPost(p.content); setImage(p.image_url); setSavedPostId(p.id); setTopic(p.topic || ''); window.scrollTo({top: 0, behavior: 'smooth'}); }}
className="text-xs text-accent hover:underline shrink-0"
>
открыть
</button>
</div>
);
})}
</div>
</div>
)}
</main> </main>
); );
} }
+11
View File
@@ -41,4 +41,15 @@ export const engine = {
generatePostImage: (userId, data) => call('/api/generate/post-image', { userId, method: 'POST', body: data }), generatePostImage: (userId, data) => call('/api/generate/post-image', { userId, method: 'POST', body: data }),
topicsIdeas: (userId, data) => call('/api/generate/topics-ideas', { userId, method: 'POST', body: data }), topicsIdeas: (userId, data) => call('/api/generate/topics-ideas', { userId, method: 'POST', body: data }),
getImageStyles: () => call('/api/generate/image-styles'), getImageStyles: () => call('/api/generate/image-styles'),
// User posts (черновики / запланированные / опубликованные)
listUserPosts: (userId, params = {}) => {
const qs = new URLSearchParams(params).toString();
return call(`/api/user-posts${qs ? '?' + qs : ''}`, { userId });
},
savePost: (userId, data) => call('/api/user-posts', { userId, method: 'POST', body: data }),
getPost: (userId, id) => call(`/api/user-posts/${id}`, { userId }),
updatePost: (userId, id, data) => call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: data }),
deletePost: (userId, id) => call(`/api/user-posts/${id}`, { userId, method: 'DELETE' }),
publishPost: (userId, id) => call(`/api/user-posts/${id}/publish`, { userId, method: 'POST' }),
}; };