diff --git a/app/api/generate/from-url/route.js b/app/api/generate/from-url/route.js new file mode 100644 index 0000000..25dc834 --- /dev/null +++ b/app/api/generate/from-url/route.js @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function POST(request) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const body = await request.json(); + if (!body.channelId || !body.url) { + return NextResponse.json({ error: 'channelId and url required' }, { status: 400 }); + } + + try { + const data = await engine.generateFromUrl(user.id, body); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/app/api/metrics/best-time/[channelId]/route.js b/app/api/metrics/best-time/[channelId]/route.js new file mode 100644 index 0000000..fcbe71b --- /dev/null +++ b/app/api/metrics/best-time/[channelId]/route.js @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function GET(request, { params }) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { searchParams } = new URL(request.url); + try { + const data = await engine.getBestTime(params.channelId, Object.fromEntries(searchParams)); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/app/api/metrics/channel/[channelId]/route.js b/app/api/metrics/channel/[channelId]/route.js new file mode 100644 index 0000000..e28322c --- /dev/null +++ b/app/api/metrics/channel/[channelId]/route.js @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +export async function GET(request, { params }) { + const user = await requireUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const { searchParams } = new URL(request.url); + try { + const data = await engine.getChannelMetrics(params.channelId, Object.fromEntries(searchParams)); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: err.status || 500 }); + } +} diff --git a/components/ChannelAnalytics.js b/components/ChannelAnalytics.js new file mode 100644 index 0000000..5ef19e2 --- /dev/null +++ b/components/ChannelAnalytics.js @@ -0,0 +1,255 @@ +'use client'; + +/** + * ChannelAnalytics — вкладка аналитики в ChannelView. + * Props: channelId, channelName + */ + +import { useState, useEffect, useCallback } from 'react'; +import { BarChart2, TrendingUp, Clock, RefreshCw, + Heart, Share2, Eye, AlertCircle, Calendar } from 'lucide-react'; + +const DOW_SHORT = ['Пн','Вт','Ср','Чт','Пт','Сб','Вс']; +const BAR_HEIGHT = 80; + +// ── Мини-бар для гистограммы ────────────────────────────────────────────────── +function Bar({ value, max, label, highlight }) { + const pct = max > 0 ? (value / max) : 0; + return ( +
+ {value > 0 ? value : ''} +
+
0 ? 4 : 0)}px` }} + /> +
+ {label} +
+ ); +} + +// ── Карточка метрики ────────────────────────────────────────────────────────── +function MetricCard({ Icon, label, value, sub, color = 'text-accent' }) { + return ( +
+
+ +
+
+
{value ?? '—'}
+
{label}
+ {sub &&
{sub}
} +
+
+ ); +} + +// ── Строка реакций ──────────────────────────────────────────────────────────── +function ReactionBar({ reactions }) { + if (!reactions || !Object.keys(reactions).length) return null; + const total = Object.values(reactions).reduce((s, v) => s + v, 0); + return ( +
+ {Object.entries(reactions) + .sort((a, b) => b[1] - a[1]) + .map(([emoji, count]) => ( + + {emoji} {count} + + ))} + всего {total} +
+ ); +} + +// ── Главный компонент ───────────────────────────────────────────────────────── +export default function ChannelAnalytics({ channelId, channelName }) { + const [metrics, setMetrics] = useState(null); + const [bestTime, setBestTime] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [days, setDays] = useState(30); + + const load = useCallback(async () => { + setLoading(true); + setError(''); + try { + const [m, b] = await Promise.all([ + fetch(`/api/metrics/channel/${channelId}?days=${days}`).then(r => r.json()), + fetch(`/api/metrics/best-time/${channelId}?days=90`).then(r => r.json()), + ]); + if (m.error) throw new Error(m.error); + setMetrics(m); + setBestTime(b); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + }, [channelId, days]); + + useEffect(() => { load(); }, [load]); + + const totals = metrics?.totals || {}; + const topPosts = metrics?.top_posts || []; + const reactionTotals = metrics?.reaction_totals || []; + const totalReactions = reactionTotals.reduce((s, r) => s + parseInt(r.total), 0); + + // Лучший день недели по кол-ву публикаций + const dowData = Array.from({ length: 7 }, (_, i) => { + const d = bestTime?.by_dow?.find(r => parseInt(r.dow) === i + 1); + return { label: DOW_SHORT[i], value: d?.count || 0 }; + }); + const maxDow = Math.max(...dowData.map(d => d.value), 1); + const bestDow = dowData.reduce((best, d, i) => d.value > best.value ? { ...d, i } : best, { value: 0, i: -1 }); + + // Лучший час + const hourData = Array.from({ length: 24 }, (_, h) => { + const d = bestTime?.by_hour?.find(r => parseInt(r.hour) === h); + return { label: `${h}`, value: d?.count || 0 }; + }); + const maxHour = Math.max(...hourData.map(d => d.value), 1); + const bestHour = hourData.reduce((best, d, i) => d.value > best.value ? { ...d, i } : best, { value: 0, i: -1 }); + + return ( +
+ + {/* Тулбар */} +
+

+ + Аналитика +

+
+ + +
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Карточки-цифры */} +
+ + + + +
+ + {/* Топ реакций */} + {reactionTotals.length > 0 && ( +
+
+ Реакции за {days} дней +
+
+ {reactionTotals.map(r => ( + + {r.emoji} + {r.total} + + ))} +
+
+ )} + + {/* Лучший день недели */} +
+
+ Активность по дням недели +
+ {bestDow.value > 0 && ( +

+ Чаще всего публикуешь в {DOW_SHORT[bestDow.i]} +

+ )} +
+ {dowData.map((d, i) => ( + + ))} +
+
+ + {/* Лучший час */} +
+
+ Активность по часам (МСК, 90 дн.) +
+ {bestHour.value > 0 && ( +

+ Пик публикаций в {bestHour.i}:00 +

+ )} +
+ {hourData.map((d, i) => ( + + ))} +
+
+ + {/* Топ постов по реакциям */} + {topPosts.length > 0 && ( +
+
+ Топ постов по реакциям +
+
+ {topPosts.map(p => ( +
+
{p.preview}
+
+ + {p.forwards > 0 && ( + + {p.forwards} + + )} + + {p.published_at ? new Date(p.published_at).toLocaleDateString('ru', { day:'numeric', month:'short' }) : ''} + + {p.tg_message_id && ( + + Открыть + + )} +
+
+ ))} +
+
+ )} + + {/* Нет данных */} + {!loading && metrics && topPosts.length === 0 && reactionTotals.length === 0 && ( +
+ +

Пока нет данных для отображения

+

Реакции появятся после первых публикаций через этот канал

+
+ )} +
+ ); +} diff --git a/components/ChannelView.js b/components/ChannelView.js index 2989177..5f9a1b8 100644 --- a/components/ChannelView.js +++ b/components/ChannelView.js @@ -9,6 +9,8 @@ import { import PhotoSearchModal from './PhotoSearchModal'; import PostPreview from './PostPreview'; import PostTemplates from './PostTemplates'; +import ChannelAnalytics from './ChannelAnalytics'; +import FromUrlModal from './FromUrlModal'; const GOAL_LABELS = { educational: 'Обучение', news: 'Новости', @@ -53,6 +55,7 @@ export default function ChannelView({ channel }) { // Photo search modal const [showPhotoSearch, setShowPhotoSearch] = useState(false); + const [showFromUrl, setShowFromUrl] = useState(false); // Трансформации const [transforming, setTransforming] = useState(false); @@ -85,6 +88,7 @@ export default function ChannelView({ channel }) { // Сохранение и публикация const [savedPostId, setSavedPostId] = useState(null); const [publishing, setPublishing] = useState(false); + const [activeTab, setActiveTab] = useState('generate'); // generate | analytics const [showScheduler, setShowScheduler] = useState(false); const [scheduleAt, setScheduleAt] = useState(''); const [history, setHistory] = useState([]); @@ -118,6 +122,13 @@ export default function ChannelView({ channel }) { setShowPhotoSearch(false); } + function applyFromUrl({ content, imageUrl, title }) { + setPost(content); + if (imageUrl) setImage(imageUrl); + if (title && !topic.trim()) setTopic(title.slice(0, 120)); + setSavedPostId(null); + } + async function savePost(status = 'draft', scheduledAt = null) { if (!post) return; setPublishing(true); @@ -330,6 +341,22 @@ export default function ChannelView({ channel }) {
+ {/* Вкладки */} +
+ {[['generate','Создать пост'],['analytics','Аналитика']].map(([id,label]) => ( + + ))} +
+ + {activeTab === 'analytics' && ( + + )} + + {activeTab === 'generate' && <> {/* Generator */}
@@ -339,6 +366,13 @@ export default function ChannelView({ channel }) {
+
)} + } ); } diff --git a/components/FromUrlModal.js b/components/FromUrlModal.js new file mode 100644 index 0000000..93a5095 --- /dev/null +++ b/components/FromUrlModal.js @@ -0,0 +1,191 @@ +'use client'; + +/** + * FromUrlModal — модальное окно «URL → черновик». + * Props: + * open — bool + * channelId — number + * onClose() + * onApply({ content, imageUrl, title }) — вызывается с результатом + */ + +import { useState } from 'react'; +import { X, Link2, Loader2, Youtube, Globe, Send, AlertCircle } from 'lucide-react'; + +function detectSource(url) { + try { + const u = new URL(url); + if (u.hostname.includes('youtube.com') || u.hostname.includes('youtu.be')) return 'youtube'; + if (u.hostname === 't.me') return 'telegram'; + return 'web'; + } catch { return 'web'; } +} + +const SOURCE_ICONS = { + youtube: { Icon: Youtube, label: 'YouTube', color: 'text-red-400' }, + telegram: { Icon: Send, label: 'Telegram', color: 'text-blue-400' }, + web: { Icon: Globe, label: 'Сайт', color: 'text-text-mute' }, +}; + +export default function FromUrlModal({ open, channelId, onClose, onApply }) { + const [url, setUrl] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [result, setResult] = useState(null); + const [edited, setEdited] = useState(''); + + if (!open) return null; + + const source = detectSource(url); + const { Icon: SrcIcon, label: srcLabel, color: srcColor } = SOURCE_ICONS[source] || SOURCE_ICONS.web; + + async function generate() { + if (!url.trim()) return; + setLoading(true); + setError(''); + setResult(null); + try { + const res = await fetch('/api/generate/from-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ channelId, url: url.trim() }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Ошибка генерации'); + setResult(data); + setEdited(data.content); + } catch (e) { + setError(e.message); + } finally { + setLoading(false); + } + } + + function apply() { + if (!edited.trim()) return; + onApply({ + content: edited, + imageUrl: result?.imageUrl || null, + title: result?.title || '', + }); + handleClose(); + } + + function handleClose() { + setUrl(''); + setResult(null); + setEdited(''); + setError(''); + onClose(); + } + + return ( +
+
+ + {/* Шапка */} +
+

+ + URL → черновик +

+ +
+ + {/* Контент */} +
+ + {/* Инпут URL */} +
+ +
+
+ +
+ { setUrl(e.target.value); setResult(null); setError(''); }} + disabled={loading} + onKeyDown={e => e.key === 'Enter' && !loading && generate()} + /> +
+ {url && ( +

Источник: {srcLabel}

+ )} +
+ + {/* Кнопка генерации */} + {!result && ( + + )} + + {/* Ошибка */} + {error && ( +
+ {error} +
+ )} + + {/* Результат */} + {result && ( + <> + {result.title && ( +
+ Источник: {result.title} +
+ )} + +
+ +