From 1a1eac16eeb73f53120789a78c6ca632d880eefb Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 17:36:02 +0300 Subject: [PATCH] feat: post saving, instant publish, scheduling, post history per channel --- app/api/user-posts/[id]/publish/route.js | 13 ++ app/api/user-posts/[id]/route.js | 24 +++ app/api/user-posts/route.js | 25 +++ components/ChannelView.js | 191 ++++++++++++++++++++++- lib/engine.js | 11 ++ 5 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 app/api/user-posts/[id]/publish/route.js create mode 100644 app/api/user-posts/[id]/route.js create mode 100644 app/api/user-posts/route.js diff --git a/app/api/user-posts/[id]/publish/route.js b/app/api/user-posts/[id]/publish/route.js new file mode 100644 index 0000000..06080f6 --- /dev/null +++ b/app/api/user-posts/[id]/publish/route.js @@ -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}); } +} diff --git a/app/api/user-posts/[id]/route.js b/app/api/user-posts/[id]/route.js new file mode 100644 index 0000000..8c95915 --- /dev/null +++ b/app/api/user-posts/[id]/route.js @@ -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}); } +} diff --git a/app/api/user-posts/route.js b/app/api/user-posts/route.js new file mode 100644 index 0000000..a92eba1 --- /dev/null +++ b/app/api/user-posts/route.js @@ -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}); } +} diff --git a/components/ChannelView.js b/components/ChannelView.js index f259ea9..e4a0586 100644 --- a/components/ChannelView.js +++ b/components/ChannelView.js @@ -1,10 +1,10 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import Link from 'next/link'; import { ArrowLeft, Sparkles, Wand2, Copy, Check, Loader2, Settings, Image as ImageIcon, RefreshCw, Scissors, Maximize2, Zap, Heart, - MessageSquare, Pencil, X, ChevronDown + MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2 } from 'lucide-react'; 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) { if (!topic.trim() && !asVariant) return; if (asVariant && !post) return; @@ -340,6 +428,55 @@ export default function ChannelView({ channel }) { )} + {/* Публикация */} +
+
Публикация
+
+ + + +
+ + {/* Планировщик */} + {showScheduler && ( +
+ +
+ setScheduleAt(e.target.value)} + className="input text-sm flex-1" + min={new Date(Date.now() + 60000).toISOString().slice(0, 16)} + /> + +
+
+ )} +
+ {/* Трансформации */}
Переработать
@@ -388,6 +525,56 @@ export default function ChannelView({ channel }) {
)} + {/* История постов */} + {history.length > 0 && ( +
+

История постов канала

+
+ {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 ( +
+ {p.image_url && ( + + )} +
+
{p.content.slice(0, 200)}
+
+ + {statusLabels[p.status] || p.status} + + {p.scheduled_at && p.status === 'scheduled' && ( + → {new Date(p.scheduled_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })} + )} + {p.published_at && ( + {new Date(p.published_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })} + )} + {!p.scheduled_at && !p.published_at && ( + {new Date(p.created_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })} + )} + {p.error && ( + {p.error} + )} +
+
+ +
+ ); + })} +
+
+ )} ); } diff --git a/lib/engine.js b/lib/engine.js index d7706d8..a465086 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -41,4 +41,15 @@ export const engine = { 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 }), 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' }), };