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';
|
'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' }),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user