forked from admin/zeropost-tool
feat: post saving, instant publish, scheduling, post history per channel
This commit is contained in:
@@ -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}); }
|
||||
}
|
||||
@@ -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}); }
|
||||
}
|
||||
@@ -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
@@ -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 }) {
|
||||
</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="text-xs font-semibold text-gray-400 uppercase tracking-wide mb-3">Переработать</div>
|
||||
@@ -388,6 +525,56 @@ export default function ChannelView({ channel }) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' }),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user