forked from admin/zeropost-tool
feat: channel history page — published posts with search
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import { engine } from '@/lib/engine';
|
||||
import Header from '@/components/Header';
|
||||
import ChannelHistory from '@/components/ChannelHistory';
|
||||
|
||||
export default async function ChannelHistoryPage({ params }) {
|
||||
const user = await requireUser();
|
||||
if (!user) redirect('/login');
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
let channel, posts;
|
||||
try {
|
||||
channel = await engine.getChannel(user.id, id);
|
||||
posts = await engine.listUserPosts(user.id, { channel_id: id, status: 'published', limit: 100 });
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
if (!channel) notFound();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header user={user} />
|
||||
<ChannelHistory channel={channel} posts={posts || []} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { ArrowLeft, Clock, Image as ImageIcon, Copy, Check, Search } from 'lucide-react';
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
if (!dateStr) return '';
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const m = Math.floor(diff / 60000);
|
||||
if (m < 60) return `${m} мин назад`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h} ч назад`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d} дн назад`;
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
function PostCard({ p }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const preview = p.content?.slice(0, 200) || '';
|
||||
const isLong = (p.content?.length || 0) > 200;
|
||||
|
||||
function copy() {
|
||||
navigator.clipboard.writeText(p.content || '');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-4 flex flex-col gap-3">
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400">
|
||||
<Clock size={12} />
|
||||
<span>{timeAgo(p.published_at || p.updated_at || p.created_at)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={copy}
|
||||
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
{copied ? <Check size={13} className="text-green-500" /> : <Copy size={13} />}
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Картинка */}
|
||||
{p.image_url && (
|
||||
<img
|
||||
src={p.image_url}
|
||||
alt=""
|
||||
className="w-full rounded-lg object-cover max-h-48"
|
||||
/>
|
||||
)}
|
||||
{!p.image_url && (
|
||||
<div className="w-full h-10 rounded-lg bg-gray-50 dark:bg-gray-800 flex items-center gap-2 px-3">
|
||||
<ImageIcon size={14} className="text-gray-300" />
|
||||
<span className="text-xs text-gray-300">Без изображения</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Текст */}
|
||||
<div className="text-sm text-gray-800 dark:text-gray-200 whitespace-pre-wrap leading-relaxed">
|
||||
{expanded ? p.content : preview}
|
||||
{isLong && !expanded && <span className="text-gray-400">…</span>}
|
||||
</div>
|
||||
{isLong && (
|
||||
<button
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
className="text-xs text-indigo-500 hover:text-indigo-700 text-left"
|
||||
>
|
||||
{expanded ? 'Свернуть' : 'Показать полностью'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChannelHistory({ channel, posts }) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const filtered = query.trim()
|
||||
? posts.filter(p => p.content?.toLowerCase().includes(query.toLowerCase()))
|
||||
: posts;
|
||||
|
||||
return (
|
||||
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||
{/* Навигация */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Link
|
||||
href={`/channels/${channel.id}`}
|
||||
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
{channel.name}
|
||||
</Link>
|
||||
<span className="text-gray-300 dark:text-gray-700">/</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">История публикаций</span>
|
||||
</div>
|
||||
|
||||
{/* Статистика + поиск */}
|
||||
<div className="flex items-center justify-between gap-4 mb-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
{posts.length === 0
|
||||
? 'Публикаций пока нет'
|
||||
: `${posts.length} ${posts.length === 1 ? 'публикация' : posts.length < 5 ? 'публикации' : 'публикаций'}`}
|
||||
</p>
|
||||
{posts.length > 0 && (
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск по тексту"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="pl-8 pr-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-300 w-48"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Список постов */}
|
||||
{filtered.length === 0 && query ? (
|
||||
<p className="text-center text-sm text-gray-400 py-12">Ничего не найдено</p>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Clock size={32} className="mx-auto text-gray-200 dark:text-gray-700 mb-3" />
|
||||
<p className="text-sm text-gray-400">Опубликованных постов пока нет</p>
|
||||
<Link
|
||||
href={`/channels/${channel.id}`}
|
||||
className="mt-4 inline-block text-sm text-indigo-500 hover:text-indigo-700"
|
||||
>
|
||||
Создать первый пост →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{filtered.map(p => <PostCard key={p.id} p={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ 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, Send, Clock, Trash2
|
||||
MessageSquare, Pencil, X, ChevronDown, Send, Clock, Trash2, History
|
||||
} from 'lucide-react';
|
||||
|
||||
const GOAL_LABELS = {
|
||||
@@ -276,6 +276,10 @@ export default function ChannelView({ channel }) {
|
||||
</div>
|
||||
{channel.niche && <p className="text-sm text-gray-500 max-w-2xl">{channel.niche}</p>}
|
||||
</div>
|
||||
<Link href={`/channels/${channel.id}/history`} className="btn-ghost text-sm">
|
||||
<History className="w-4 h-4" />
|
||||
История
|
||||
</Link>
|
||||
<Link href={`/channels/${channel.id}/edit`} className="btn-ghost text-sm">
|
||||
<Settings className="w-4 h-4" />
|
||||
Настройки
|
||||
|
||||
Reference in New Issue
Block a user