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 && (
+
+ )}
+
+ {/* Карточки-цифры */}
+
+
+
+
+
+
+
+ {/* Топ реакций */}
+ {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 && (
+
+ )}
+
+ {/* Результат */}
+ {result && (
+ <>
+ {result.title && (
+
+ Источник: {result.title}
+
+ )}
+
+
+
+
+
+ {result.imageUrl && (
+
+
+

{ e.target.style.display = 'none'; }}
+ />
+
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+
+ );
+}
diff --git a/lib/engine.js b/lib/engine.js
index 443b59b..199a438 100644
--- a/lib/engine.js
+++ b/lib/engine.js
@@ -74,6 +74,22 @@ export const engine = {
const qs = new URLSearchParams(params).toString();
return call(`/api/calendar${qs ? '?' + qs : ''}`, { userId });
},
+ // Metrics
+ getChannelMetrics: (channelId, params = {}) => {
+ const qs = new URLSearchParams(params).toString();
+ return call(`/api/metrics/channel/${channelId}${qs ? '?' + qs : ''}`);
+ },
+ getBestTime: (channelId, params = {}) => {
+ const qs = new URLSearchParams(params).toString();
+ return call(`/api/metrics/best-time/${channelId}${qs ? '?' + qs : ''}`);
+ },
+ getUserPostMetrics: (userId, channelId, params = {}) => {
+ const qs = new URLSearchParams(params).toString();
+ return call(`/api/metrics/user-posts/${channelId}${qs ? '?' + qs : ''}`, { userId });
+ },
+ collectMetrics: () => call('/api/metrics/collect', { method: 'POST' }),
+ generateFromUrl: (userId, data) => call('/api/generate/from-url', { userId, method: 'POST', body: data }),
+
updateUserPostSchedule: (userId, id, scheduledAt) =>
call(`/api/user-posts/${id}`, { userId, method: 'PATCH', body: { scheduled_at: scheduledAt } }),
};