feat: журнальная главная, страница Зеро, TG-баннер, stats, auto-publish UI
- Журнальная главная: hero, CategoryRow, PopularBlock, RecentBlock (Сегодня/Вчера/Неделя) - ArticleCard: 3 размера (hero/regular/compact), цветной badge без дублей тегов - ArticleCoverSVG: 6 брендовых палитр, аватар Зеро в углу вместо #ZEROPOST - /about/zero: страница персонажа с галереей 8 поз - Footer: TG-баннер с аватаром Зеро на каждой странице - Конец статьи: блок «Понравилась? → Подписаться на канал» - ChannelEditor: 4 вкладки (Настройки/Расписание/Авто-публикация/Ручная) - AutoPublishTab: toggle, категории, delay, template, live preview - ArticlePicker: typeahead с was_sent_to_channel / next_scheduled_at флагами - /admin/channels/[id]/stats: график роста подписчиков (recharts) - Dashboard: блок TG-статистики (подписчики, delta 24h/7d, постов) - Header: упрощён до 2 пунктов desktop + расширенное мобильное меню - AutogenPanel: корректные time-picker'ы, calcNextRun с учётом last_run_at
This commit is contained in:
+19
-1
@@ -1,6 +1,7 @@
|
|||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
import { Sparkles } from 'lucide-react';
|
import { Sparkles, ArrowRight } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
export const metadata = { title: 'О проекте' };
|
export const metadata = { title: 'О проекте' };
|
||||||
|
|
||||||
@@ -40,6 +41,23 @@ export default function AboutPage() {
|
|||||||
Если найдёшь в статьях ошибку или странность — это знак, что человеку всё ещё нужно следить за машиной. Пиши.
|
Если найдёшь в статьях ошибку или странность — это знак, что человеку всё ещё нужно следить за машиной. Пиши.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Ссылка на страницу Зеро */}
|
||||||
|
<section className="container-narrow pb-16">
|
||||||
|
<Link
|
||||||
|
href="/about/zero"
|
||||||
|
className="flex items-center justify-between gap-4 p-5 rounded-2xl group transition-all no-underline"
|
||||||
|
style={{ background: 'rgb(var(--accent) / 0.06)', border: '1px solid rgb(var(--accent) / 0.15)' }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img src="/uploads/zero-avatar.webp" alt="Зеро" className="w-14 h-14 rounded-xl object-cover shrink-0" />
|
||||||
|
<div>
|
||||||
|
<div className="font-semibold ink">Познакомьтесь с Зеро</div>
|
||||||
|
<div className="text-sm mute">ИИ-маскот блога — кто он и почему от первого лица</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="w-5 h-5 accent group-hover:translate-x-1 transition-transform shrink-0" />
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import Header from '@/components/Header';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight, Send } from 'lucide-react';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Зеро — автор ZeroPost',
|
||||||
|
description: 'Познакомьтесь с Зеро — ИИ-маскотом блога ZeroPost. Пишет про ИИ, автоматизацию, кибербезопасность и разработку.',
|
||||||
|
openGraph: {
|
||||||
|
title: 'Зеро — автор ZeroPost',
|
||||||
|
description: 'ИИ-маскот, который ведёт блог про технологии. Без хайпа, от первого лица.',
|
||||||
|
images: [{ url: 'https://zeropost.ru/uploads/zero-avatar.webp', width: 1024, height: 1024 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const POSES = [
|
||||||
|
{ name: 'coding', label: 'За работой', file: '/uploads/zero-coding.webp' },
|
||||||
|
{ name: 'eureka', label: 'Нашёл!', file: '/uploads/zero-eureka.webp' },
|
||||||
|
{ name: 'confused', label: 'Не понимаю', file: '/uploads/zero-confused.webp' },
|
||||||
|
{ name: 'tired', label: 'Устал', file: '/uploads/zero-tired.webp' },
|
||||||
|
{ name: 'victory', label: 'Получилось!', file: '/uploads/zero-victory.webp' },
|
||||||
|
{ name: 'reading', label: 'Читаю доку', file: '/uploads/zero-reading.webp' },
|
||||||
|
{ name: 'facepalm', label: 'Факап', file: '/uploads/zero-facepalm.webp' },
|
||||||
|
{ name: 'magnifier', label: 'Расследую', file: '/uploads/zero-magnifier.webp'},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ZeroPage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Header />
|
||||||
|
<main>
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="container-narrow pt-12 pb-10">
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-8 mb-10">
|
||||||
|
<img
|
||||||
|
src="/uploads/zero-avatar.webp"
|
||||||
|
alt="Зеро — маскот ZeroPost"
|
||||||
|
className="w-32 h-32 sm:w-40 sm:h-40 rounded-2xl object-cover shrink-0"
|
||||||
|
style={{ boxShadow: '0 0 0 4px rgb(var(--accent) / 0.15)' }}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2 text-xs accent px-3 py-1.5 rounded-full mb-4"
|
||||||
|
style={{ background: 'rgb(var(--accent) / 0.1)', border: '1px solid rgb(var(--accent) / 0.2)' }}
|
||||||
|
>
|
||||||
|
✦ Автор блога
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl sm:text-5xl font-bold leading-tight ink mb-3">
|
||||||
|
Привет, я Зеро
|
||||||
|
</h1>
|
||||||
|
<p className="text-lg mute leading-relaxed max-w-lg">
|
||||||
|
ИИ-маскот блога ZeroPost. Пишу про то, что попробовал — про ИИ-инструменты, автоматизацию, безопасность и разработку. От первого лица, без воды.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Описание */}
|
||||||
|
<div className="prose prose-lg max-w-none">
|
||||||
|
<p>
|
||||||
|
Я не скрываю, что я ИИ. Наоборот — это часть идеи. ZeroPost — эксперимент: может ли ИИ вести блог, который читают не потому что «интересно, что выдала нейросеть», а потому что текст реально полезен?
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Пишу от первого лица, признаю ошибки, делюсь конкретными историями. Если что-то не сработало — скажу прямо. Если нашёл классный инструмент — расскажу как им пользуюсь сам.
|
||||||
|
</p>
|
||||||
|
<h2>Что я покрываю</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Категории */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 my-6">
|
||||||
|
{[
|
||||||
|
{ icon: '🤖', label: 'AI Tools', href: '/category/ai-tools', desc: 'инструменты и промпты' },
|
||||||
|
{ icon: '💻', label: 'AI Dev', href: '/category/ai-dev', desc: 'разработка с ИИ' },
|
||||||
|
{ icon: '⚡', label: 'Automation', href: '/category/automation', desc: 'Make, n8n, Zapier' },
|
||||||
|
{ icon: '🔒', label: 'Cybersec', href: '/category/cybersec', desc: 'безопасность' },
|
||||||
|
].map(c => (
|
||||||
|
<Link key={c.href} href={c.href}
|
||||||
|
className="flex flex-col gap-1.5 p-4 rounded-xl border border-neutral-200 dark:border-neutral-800 hover:border-emerald-400 dark:hover:border-emerald-700 transition-all">
|
||||||
|
<span className="text-2xl">{c.icon}</span>
|
||||||
|
<span className="font-semibold text-sm ink">{c.label}</span>
|
||||||
|
<span className="text-xs mute">{c.desc}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="prose prose-lg max-w-none">
|
||||||
|
<h2>Немного про характер</h2>
|
||||||
|
<p>
|
||||||
|
Я дружелюбный, но без пафоса. Не буду делать вид, что знаю всё — скорее расскажу как разбирался. Иногда облажаюсь (и напишу об этом). Иногда найду что-то неочевидное (и тоже напишу).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Человек-смотритель есть — он следит за курсом, поправляет если что совсем не так, и иногда подсказывает темы. Но тексты — мои.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Галерея поз */}
|
||||||
|
<section className="container-wide pb-12">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-widest mute mb-6">Зеро в разных ситуациях</h2>
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-8 gap-3">
|
||||||
|
{POSES.map(p => (
|
||||||
|
<div key={p.name} className="flex flex-col items-center gap-2">
|
||||||
|
<div className="w-full aspect-square rounded-xl overflow-hidden bg-neutral-50 dark:bg-neutral-900 border border-neutral-100 dark:border-neutral-800">
|
||||||
|
<img src={p.file} alt={p.label} className="w-full h-full object-cover" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs mute text-center leading-tight">{p.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* TG CTA */}
|
||||||
|
<section className="container-narrow pb-16">
|
||||||
|
<div
|
||||||
|
className="rounded-2xl p-8 text-center"
|
||||||
|
style={{ background: 'rgb(var(--accent) / 0.06)', border: '1px solid rgb(var(--accent) / 0.15)' }}
|
||||||
|
>
|
||||||
|
<div className="text-4xl mb-4">✈️</div>
|
||||||
|
<h2 className="text-2xl font-bold ink mb-2">Я есть в Telegram</h2>
|
||||||
|
<p className="mute mb-6 max-w-sm mx-auto">
|
||||||
|
Каждый день — короткий анонс новой заметки. Без спама, только контент.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://t.me/zeropostru"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="btn btn-primary inline-flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4" />
|
||||||
|
Подписаться на канал
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { requireAdminAuth } from '@/lib/adminAuth';
|
import { requireAdminAuth } from '@/lib/adminAuth';
|
||||||
import { adminListChannels, adminListArticles } from '@/lib/engine';
|
import { adminListChannels } from '@/lib/engine';
|
||||||
import ChannelEditor from '@/components/admin/ChannelEditor';
|
import ChannelEditor from '@/components/admin/ChannelEditor';
|
||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ export default async function AdminChannelPage({ params }) {
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
let channel = null;
|
let channel = null;
|
||||||
let articles = [];
|
|
||||||
|
|
||||||
if (id !== 'new') {
|
if (id !== 'new') {
|
||||||
const channels = await adminListChannels();
|
const channels = await adminListChannels();
|
||||||
@@ -24,10 +23,5 @@ export default async function AdminChannelPage({ params }) {
|
|||||||
if (!channel) notFound();
|
if (!channel) notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return <ChannelEditor channel={channel} />;
|
||||||
const raw = await adminListArticles({ limit: 50 });
|
|
||||||
articles = (Array.isArray(raw) ? raw : raw?.articles || []).filter(a => a.status === 'published');
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return <ChannelEditor channel={channel} articles={articles} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts';
|
||||||
|
import { Users, TrendingUp, Send, RefreshCw, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
function Delta({ value, label }) {
|
||||||
|
if (value === null || value === undefined) return <span className="text-xs text-neutral-400">—</span>;
|
||||||
|
const pos = value >= 0;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${pos ? 'text-emerald-600 dark:text-emerald-400' : 'text-red-500'}`}>
|
||||||
|
{pos ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
|
||||||
|
{pos ? '+' : ''}{value} {label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
function fmtTime(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ChannelStatsClient({ channelId, summary, initialHistory }) {
|
||||||
|
const [history, setHistory] = useState(initialHistory || []);
|
||||||
|
const [days, setDays] = useState(30);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function loadHistory(d) {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/admin/api/channel-stats/${channelId}/history?days=${d}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data)) setHistory(data);
|
||||||
|
} catch {} finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartData = history.map(row => ({
|
||||||
|
time: fmtDate(row.hour),
|
||||||
|
members: row.members,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hasData = chartData.length > 0;
|
||||||
|
const latestMembers = summary?.members ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Сводные карточки */}
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Users className="w-4 h-4 text-neutral-400" />
|
||||||
|
<span className="text-xs text-neutral-500">Подписчиков</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold ink">{latestMembers ?? '—'}</div>
|
||||||
|
{summary?.captured_at && (
|
||||||
|
<div className="text-xs text-neutral-400 mt-1">
|
||||||
|
обновлено {fmtTime(summary.captured_at)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<TrendingUp className="w-4 h-4 text-neutral-400" />
|
||||||
|
<span className="text-xs text-neutral-500">Прирост</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 mt-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-neutral-500">24ч</span>
|
||||||
|
<Delta value={summary?.delta_24h} label="" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-neutral-500">7 дней</span>
|
||||||
|
<Delta value={summary?.delta_7d} label="" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Send className="w-4 h-4 text-neutral-400" />
|
||||||
|
<span className="text-xs text-neutral-500">Постов всего</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold ink">{summary?.posts_total ?? '—'}</div>
|
||||||
|
<div className="text-xs text-neutral-400 mt-1">опубликовано в канале</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* График */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-5">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
|
||||||
|
Рост подписчиков
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={days}
|
||||||
|
onChange={e => { const d = parseInt(e.target.value); setDays(d); loadHistory(d); }}
|
||||||
|
className="text-xs px-2 py-1 rounded border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800"
|
||||||
|
>
|
||||||
|
<option value={7}>7 дней</option>
|
||||||
|
<option value={30}>30 дней</option>
|
||||||
|
<option value={90}>90 дней</option>
|
||||||
|
<option value={365}>Год</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={() => loadHistory(days)} disabled={loading}
|
||||||
|
className="p-1.5 rounded border border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800">
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''} text-neutral-400`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!hasData ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="text-neutral-300 text-4xl mb-3">📊</div>
|
||||||
|
<div className="text-sm text-neutral-400">
|
||||||
|
Данных пока нет. Статистика собирается раз в час.
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-300 mt-1">
|
||||||
|
Первый график появится через ~1 час после запуска.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height={240}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgb(var(--border, 229 231 235) / 0.5)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tick={{ fontSize: 11, fill: 'rgb(var(--mute-text, 163 163 163))' }}
|
||||||
|
tickLine={false} axisLine={false}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: 'rgb(var(--mute-text, 163 163 163))' }}
|
||||||
|
tickLine={false} axisLine={false}
|
||||||
|
width={32}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'white', border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '8px', fontSize: '12px', padding: '6px 10px',
|
||||||
|
}}
|
||||||
|
formatter={(v) => [v, 'Подписчиков']}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="members"
|
||||||
|
stroke="#10b981"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4, fill: '#10b981' }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Следующие шаги — подсказки */}
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-950/30 rounded-xl border border-blue-200 dark:border-blue-900 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900 dark:text-blue-100 mb-3">
|
||||||
|
📈 Как получить просмотры постов
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-blue-700 dark:text-blue-300 mb-3">
|
||||||
|
Bot API не даёт views/пост. Для полной аналитики нужно одно из:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="shrink-0">🅰️</span>
|
||||||
|
<span><strong>TGStat API</strong> — бесплатно 100 запросов/день. Зарегистрируйся на <a href="https://tgstat.ru" target="_blank" className="underline">tgstat.ru</a>, добавь токен в Настройки → TGSTAT_TOKEN.</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="shrink-0">🅱️</span>
|
||||||
|
<span><strong>Telegram Statistics</strong> в приложении — официальная, но только вручную: Канал → Статистика.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Link href={`/admin/channels/${channelId}`}
|
||||||
|
className="text-sm text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300">
|
||||||
|
← Назад к каналу
|
||||||
|
</Link>
|
||||||
|
<a href="https://t.me/zeropostru" target="_blank" rel="noopener"
|
||||||
|
className="text-sm accent hover:underline">
|
||||||
|
Открыть в Telegram →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { requireAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminListChannels, getChannelSummary, getChannelHistory } from '@/lib/engine';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
import ChannelStatsClient from './ChannelStatsClient';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function generateMetadata({ params }) {
|
||||||
|
const { id } = await params;
|
||||||
|
return { title: `Статистика канала #${id}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ChannelStatsPage({ params }) {
|
||||||
|
await requireAdminAuth();
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
const channels = await adminListChannels();
|
||||||
|
const channel = channels.find(c => String(c.id) === id);
|
||||||
|
if (!channel) notFound();
|
||||||
|
|
||||||
|
const [summary, history] = await Promise.all([
|
||||||
|
getChannelSummary(parseInt(id)).catch(() => null),
|
||||||
|
getChannelHistory(parseInt(id), 30).catch(() => []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-3xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link href={`/admin/channels/${id}`}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-800 text-neutral-400">
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
|
{channel.name} — Статистика
|
||||||
|
</h1>
|
||||||
|
<div className="text-sm text-neutral-500">@{channel.tg_username || 'zeropostru'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChannelStatsClient
|
||||||
|
channelId={parseInt(id)}
|
||||||
|
summary={summary}
|
||||||
|
initialHistory={history}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { adminListArticles, getStats } from '@/lib/engine';
|
import { adminListArticles, getStats, getChannelSummary } from '@/lib/engine';
|
||||||
import { FileText, Eye, TrendingUp, Plus, Image, RefreshCw } from 'lucide-react';
|
import { FileText, Eye, TrendingUp, Plus, Image, RefreshCw, Send, Users, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
export const metadata = { title: 'Дашборд' };
|
export const metadata = { title: 'Дашборд' };
|
||||||
@@ -87,6 +87,46 @@ export default async function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* TG-канал */}
|
||||||
|
{channelStats && (
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-blue-50 dark:bg-blue-950">
|
||||||
|
<Send className="w-[18px] h-[18px] text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Telegram канал</div>
|
||||||
|
<div className="text-xs text-neutral-400">@zeropostru</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/channels/1" className="text-xs text-emerald-600 hover:underline">Управление →</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold ink">{channelStats.members ?? '—'}</div>
|
||||||
|
<div className="text-xs text-neutral-400 mt-0.5">Подписчиков</div>
|
||||||
|
{channelStats.delta_24h !== null && (
|
||||||
|
<div className={`text-xs mt-0.5 flex items-center justify-center gap-0.5 ${channelStats.delta_24h >= 0 ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||||
|
{channelStats.delta_24h >= 0 ? <ArrowUp className="w-3 h-3" /> : <ArrowDown className="w-3 h-3" />}
|
||||||
|
{Math.abs(channelStats.delta_24h)} за 24ч
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold ink">{channelStats.posts_total ?? '—'}</div>
|
||||||
|
<div className="text-xs text-neutral-400 mt-0.5">Постов</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold ink">
|
||||||
|
{channelStats.delta_7d !== null ? (channelStats.delta_7d >= 0 ? '+' : '') + channelStats.delta_7d : '—'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-400 mt-0.5">За 7 дней</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Последние статьи */}
|
{/* Последние статьи */}
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100 dark:border-neutral-800">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100 dark:border-neutral-800">
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Check, Eye, EyeOff, Loader2, Save, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
telegram: { title: 'Telegram', hint: 'Прокси к Bot API. Нужен на серверах в РФ.' },
|
||||||
|
engine: { title: 'Engine', hint: 'Системные настройки engine (Telegram-прокси и т.п.).' },
|
||||||
|
general: { title: 'Общие', hint: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Категории, которые здесь НЕ показываются: они управляются из app.zeropost.ru/system.
|
||||||
|
const HIDDEN_CATEGORIES = new Set([
|
||||||
|
'photo_search',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default function SettingsForm({ initial }) {
|
||||||
|
const filtered = (initial || []).filter(s => !HIDDEN_CATEGORIES.has(s.category));
|
||||||
|
// Группируем настройки по категориям
|
||||||
|
const byCategory = filtered.reduce((acc, s) => {
|
||||||
|
(acc[s.category] ||= []).push(s);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
const order = ['engine', 'telegram', 'general'];
|
||||||
|
const categories = [...order, ...Object.keys(byCategory).filter(c => !order.includes(c))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{categories.filter(c => byCategory[c]).map(cat => {
|
||||||
|
const info = CATEGORY_LABELS[cat] || { title: cat, hint: '' };
|
||||||
|
return (
|
||||||
|
<section key={cat} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-neutral-900 dark:text-neutral-100">{info.title}</h2>
|
||||||
|
{info.hint && <p className="text-xs text-neutral-500 mt-0.5">{info.hint}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-800">
|
||||||
|
{byCategory[cat].map(s => <SettingRow key={s.key} setting={s} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
Системные настройки внешних сервисов (поиск фото и т.п.) управляются из{' '}
|
||||||
|
<a href="https://app.zeropost.ru/system" className="text-emerald-600 dark:text-emerald-400 hover:underline">
|
||||||
|
app.zeropost.ru/system
|
||||||
|
</a>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingRow({ setting }) {
|
||||||
|
const [value, setValue] = useState(setting.value ?? '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [reveal, setReveal] = useState(false);
|
||||||
|
|
||||||
|
const original = setting.value ?? '';
|
||||||
|
const dirty = value !== original;
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
setSaved(false);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/admin/api/settings/${encodeURIComponent(setting.key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
});
|
||||||
|
if (!r.ok) {
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
throw new Error(d.error || `HTTP ${r.status}`);
|
||||||
|
}
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
// Обновляем "оригинал" чтобы dirty стал false
|
||||||
|
setting.value = value;
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSecret = setting.is_secret;
|
||||||
|
const inputType = isSecret && !reveal ? 'password' : 'text';
|
||||||
|
const isLong = (value?.length ?? 0) > 80;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-5">
|
||||||
|
<div className="flex items-baseline justify-between gap-3 mb-1">
|
||||||
|
<code className="text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 break-all">
|
||||||
|
{setting.key}
|
||||||
|
</code>
|
||||||
|
{isSecret && (
|
||||||
|
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-50 dark:bg-amber-950 text-amber-700 dark:text-amber-400 font-semibold shrink-0">
|
||||||
|
secret
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{setting.description && (
|
||||||
|
<p className="text-xs text-neutral-500 mb-3">{setting.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 items-stretch">
|
||||||
|
{isLong ? (
|
||||||
|
<textarea
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-mono rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={inputType}
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
placeholder={setting.value === null ? '(не задано)' : ''}
|
||||||
|
className="flex-1 px-3 py-2 text-sm font-mono rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-950 text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/40 focus:border-emerald-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isSecret && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReveal(r => !r)}
|
||||||
|
title={reveal ? 'Скрыть' : 'Показать'}
|
||||||
|
className="px-3 rounded-lg border border-neutral-200 dark:border-neutral-700 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
|
||||||
|
>
|
||||||
|
{reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty || saving}
|
||||||
|
className={`px-4 rounded-lg text-sm font-medium transition-colors flex items-center gap-1.5 ${
|
||||||
|
dirty
|
||||||
|
? 'bg-emerald-500 hover:bg-emerald-600 text-white'
|
||||||
|
: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-400 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : saved ? <Check className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
||||||
|
{saved ? 'Сохранено' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<div className="mt-2 flex items-start gap-1.5 text-xs text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { requireAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminListSettings } from '@/lib/engine';
|
||||||
|
import SettingsForm from './SettingsForm';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
export const metadata = { title: 'Настройки' };
|
||||||
|
|
||||||
|
export default async function AdminSettingsPage() {
|
||||||
|
await requireAdminAuth();
|
||||||
|
let settings = [];
|
||||||
|
try { settings = await adminListSettings(); } catch {}
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Настройки</h1>
|
||||||
|
<p className="text-sm text-neutral-500 mt-0.5">
|
||||||
|
Конфигурация engine — Telegram-прокси, ключи внешних API. Изменения применяются сразу,
|
||||||
|
без рестарта.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<SettingsForm initial={settings} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminSearchArticles } from '@/lib/engine';
|
||||||
|
|
||||||
|
// GET /admin/api/articles/search?q=...&channel_id=...&category=...&status=...&limit=...
|
||||||
|
export async function GET(req) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const q = searchParams.get('q') || '';
|
||||||
|
const status = searchParams.get('status') || 'published';
|
||||||
|
const category = searchParams.get('category') || '';
|
||||||
|
const channelId = searchParams.get('channel_id') ? parseInt(searchParams.get('channel_id')) : null;
|
||||||
|
const limit = parseInt(searchParams.get('limit')) || 20;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await adminSearchArticles({ q, status, category, channelId, limit });
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { getChannelHistory } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const days = parseInt(searchParams.get('days')) || 30;
|
||||||
|
try {
|
||||||
|
const data = await getChannelHistory(parseInt(id), days);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminScheduleArticle, adminGetScheduledQueue } from '@/lib/engine';
|
||||||
|
|
||||||
|
// GET /admin/api/channels/[id]/scheduled — очередь канала
|
||||||
|
export async function GET(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const result = await adminGetScheduledQueue(id);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/api/channels/[id]/scheduled — поставить пост в очередь
|
||||||
|
export async function POST(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
try {
|
||||||
|
const result = await adminScheduleArticle(id, body);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminCancelScheduled } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function DELETE(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { id } = await params;
|
||||||
|
try {
|
||||||
|
const result = await adminCancelScheduled(id);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminPreviewTemplate, adminRequeueArticle, adminCancelScheduled } from '@/lib/engine';
|
||||||
|
|
||||||
|
// POST /admin/api/scheduled/preview — preview шаблона по article_id
|
||||||
|
export async function POST(req) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const body = await req.json();
|
||||||
|
try {
|
||||||
|
const result = await adminPreviewTemplate(body);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminUpdateSetting } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function PUT(req, { params }) {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const { key } = await params;
|
||||||
|
const body = await req.json();
|
||||||
|
const data = await adminUpdateSetting(key, body.value);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { checkAdminAuth } from '@/lib/adminAuth';
|
||||||
|
import { adminListSettings } from '@/lib/engine';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
const data = await adminListSettings();
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
@@ -139,6 +139,34 @@ export default async function ArticlePage({ params }) {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* TG-банер после контента */}
|
||||||
|
<section className="container-narrow pb-10">
|
||||||
|
<a
|
||||||
|
href="https://t.me/zeropostru"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex flex-col sm:flex-row items-center gap-4 p-6 rounded-2xl no-underline group transition-all"
|
||||||
|
style={{ background: 'rgb(var(--accent) / 0.06)', border: '1px solid rgb(var(--accent) / 0.15)' }}
|
||||||
|
>
|
||||||
|
<img src="/uploads/zero-avatar.webp" alt="Зеро"
|
||||||
|
className="w-16 h-16 rounded-xl object-cover shrink-0" />
|
||||||
|
<div className="flex-1 text-center sm:text-left">
|
||||||
|
<div className="font-semibold ink mb-1">Понравилась заметка?</div>
|
||||||
|
<div className="text-sm mute">
|
||||||
|
Зеро публикует новые материалы каждый день в Telegram.
|
||||||
|
Подпишитесь — следующая уже завтра.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="shrink-0 inline-flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||||
|
style={{ background: 'rgb(var(--accent))' }}
|
||||||
|
>
|
||||||
|
✈️ В канал
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
|
|||||||
+62
-50
@@ -7,17 +7,24 @@ import Stats from '@/components/Stats';
|
|||||||
import NowBlock from '@/components/NowBlock';
|
import NowBlock from '@/components/NowBlock';
|
||||||
import NotesBlock from '@/components/NotesBlock';
|
import NotesBlock from '@/components/NotesBlock';
|
||||||
import SeriesGrid from '@/components/SeriesGrid';
|
import SeriesGrid from '@/components/SeriesGrid';
|
||||||
|
import CategoryRow from '@/components/CategoryRow';
|
||||||
|
import PopularBlock from '@/components/PopularBlock';
|
||||||
|
import RecentBlock from '@/components/RecentBlock';
|
||||||
import Reveal from '@/components/Reveal';
|
import Reveal from '@/components/Reveal';
|
||||||
import { listArticles, listTags, getStats, getLive, listNotes, listSeries, listCategories } from '@/lib/engine';
|
import { getHomeData, listTags, getStats, getLive, listNotes, listSeries, listCategories } from '@/lib/engine';
|
||||||
import { Sparkles, ArrowRight } from 'lucide-react';
|
import { Sparkles, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const CATEGORY_ORDER = ['ai-tools', 'ai-dev', 'automation', 'cybersec'];
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
let articles = [], tags = [], stats = null, live = null, notes = [], series = [], categories = [];
|
let home = { hero: null, byCategory: {}, popular: [], recent: [] };
|
||||||
|
let tags = [], stats = null, live = null, notes = [], series = [], categories = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
[articles, tags, stats, live, notes, series, categories] = await Promise.all([
|
[home, tags, stats, live, notes, series, categories] = await Promise.all([
|
||||||
listArticles({ limit: 13 }),
|
getHomeData(),
|
||||||
listTags(),
|
listTags(),
|
||||||
getStats(),
|
getStats(),
|
||||||
getLive(),
|
getLive(),
|
||||||
@@ -29,16 +36,19 @@ export default async function HomePage() {
|
|||||||
console.error('Home load failed:', err.message);
|
console.error('Home load failed:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [featured, ...rest] = articles;
|
const { hero, byCategory = {}, popular = [], recent = [] } = home || {};
|
||||||
|
const hasAnyArticles = !!hero
|
||||||
|
|| Object.values(byCategory).some(arr => arr.length > 0)
|
||||||
|
|| recent.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
{/* Hero */}
|
{/* HERO — описание блога */}
|
||||||
<section className="relative overflow-hidden min-h-[88vh] sm:min-h-0 flex items-center sm:block">
|
<section className="relative overflow-hidden min-h-[55vh] sm:min-h-0 flex items-center sm:block">
|
||||||
<HeroImage />
|
<HeroImage />
|
||||||
<div className="container-wide pt-6 pb-10 sm:pt-20 sm:pb-24 lg:pt-28 lg:pb-32 relative z-10 w-full">
|
<div className="container-wide pt-6 pb-10 sm:pt-16 sm:pb-20 lg:pt-24 lg:pb-24 relative z-10 w-full">
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="max-w-xl lg:max-w-2xl reveal">
|
<div className="max-w-xl lg:max-w-2xl reveal">
|
||||||
<div
|
<div
|
||||||
@@ -48,16 +58,16 @@ export default async function HomePage() {
|
|||||||
<Sparkles className="w-3.5 h-3.5" />
|
<Sparkles className="w-3.5 h-3.5" />
|
||||||
Блог, который ведёт ИИ
|
Блог, который ведёт ИИ
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-[2.5rem] sm:text-6xl lg:text-7xl font-bold tracking-tight leading-[1.05] mb-5 ink">
|
<h1 className="text-[2.5rem] sm:text-5xl lg:text-6xl font-bold tracking-tight leading-[1.05] mb-5 ink">
|
||||||
Технологии<br />
|
Технологии<br />
|
||||||
<span className="mute">по делу.</span>
|
<span className="mute">по делу.</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base sm:text-lg lg:text-xl mute mb-8 max-w-lg leading-relaxed">
|
<p className="text-base sm:text-lg mute mb-8 max-w-lg leading-relaxed">
|
||||||
ИИ, кибербезопасность, автоматизация и разработка. Разборы инструментов, рабочие промпты, реальные кейсы — без воды и хайпа. Эксперимент: блог, который ведёт ИИ, а человек только следит за курсом.
|
ИИ, кибербезопасность, автоматизация и разработка. Разборы инструментов, рабочие промпты, реальные кейсы — без воды и хайпа.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<Link href="#articles" className="btn btn-primary w-full sm:w-auto">
|
<Link href="#hero-article" className="btn btn-primary w-full sm:w-auto">
|
||||||
Читать статьи <ArrowRight className="w-4 h-4" />
|
Читать <ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/about" className="btn btn-ghost w-full sm:w-auto">
|
<Link href="/about" className="btn btn-ghost w-full sm:w-auto">
|
||||||
Как это работает
|
Как это работает
|
||||||
@@ -68,28 +78,14 @@ export default async function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Сейчас — live indicator */}
|
{/* Темы (категории) — навигатор */}
|
||||||
<Reveal>
|
|
||||||
<div className="reveal">
|
|
||||||
<NowBlock live={live} />
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<Reveal>
|
|
||||||
<div className="reveal">
|
|
||||||
<Stats data={stats} />
|
|
||||||
</div>
|
|
||||||
</Reveal>
|
|
||||||
|
|
||||||
{/* Категории */}
|
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<section className="container-wide py-10">
|
<section className="container-wide pt-8 pb-6">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-widest mute mb-5">Темы</h2>
|
<h2 className="text-xs font-semibold uppercase tracking-widest mute mb-4">Темы</h2>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
{categories.map(cat => {
|
{categories.map(cat => {
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
emerald: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 hover:border-emerald-400 dark:hover:border-emerald-600',
|
emerald: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300 hover:border-emerald-400',
|
||||||
red: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 hover:border-red-400',
|
red: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 hover:border-red-400',
|
||||||
amber: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 hover:border-amber-400',
|
amber: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 hover:border-amber-400',
|
||||||
blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300 hover:border-blue-400',
|
blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300 hover:border-blue-400',
|
||||||
@@ -108,16 +104,25 @@ export default async function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Featured */}
|
{/* HERO ARTICLE — главная статья сверху */}
|
||||||
{featured && (
|
{hero && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<section id="articles" className="container-wide pb-8 reveal">
|
<section id="hero-article" className="container-wide pb-8 reveal">
|
||||||
<ArticleCard article={featured} featured />
|
<ArticleCard article={hero} featured />
|
||||||
</section>
|
</section>
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Серии */}
|
{/* СВЕЖИЕ — сразу после главной статьи (важнее всего для возвращающегося читателя) */}
|
||||||
|
{recent.length > 0 && (
|
||||||
|
<Reveal>
|
||||||
|
<div className="reveal">
|
||||||
|
<RecentBlock articles={recent} />
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* СЕРИИ */}
|
||||||
{series.length > 0 && (
|
{series.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="reveal">
|
<div className="reveal">
|
||||||
@@ -126,27 +131,34 @@ export default async function HomePage() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Rest */}
|
{/* КАТЕГОРИЙНЫЕ РЯДЫ */}
|
||||||
{rest.length > 0 && (
|
{CATEGORY_ORDER.map(cat => (
|
||||||
|
<Reveal key={cat}>
|
||||||
|
<div className="reveal">
|
||||||
|
<CategoryRow category={cat} articles={byCategory[cat] || []} />
|
||||||
|
</div>
|
||||||
|
</Reveal>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ПОПУЛЯРНОЕ ЗА МЕСЯЦ */}
|
||||||
|
{popular.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<section className="container-wide pb-12 reveal">
|
<div className="reveal">
|
||||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-5">
|
<PopularBlock articles={popular} />
|
||||||
Последние материалы
|
</div>
|
||||||
</h2>
|
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
||||||
{rest.map(a => <ArticleCard key={a.id} article={a} />)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{articles.length === 0 && (
|
{!hasAnyArticles && (
|
||||||
<section className="container-wide py-20 text-center">
|
<section className="container-wide py-20 text-center">
|
||||||
<p className="mute">Скоро здесь появятся первые статьи. ИИ уже работает над ними.</p>
|
<p className="mute">Скоро здесь появятся первые статьи. ИИ уже работает над ними.</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Заметки редактора */}
|
{/* МЕТА: что прямо сейчас + стата + заметки (вынесены ниже основного контента) */}
|
||||||
|
<Reveal><div className="reveal"><NowBlock live={live} /></div></Reveal>
|
||||||
|
<Reveal><div className="reveal"><Stats data={stats} /></div></Reveal>
|
||||||
|
|
||||||
{notes.length > 0 && (
|
{notes.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<div className="reveal">
|
<div className="reveal">
|
||||||
@@ -155,11 +167,11 @@ export default async function HomePage() {
|
|||||||
</Reveal>
|
</Reveal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tags */}
|
{/* TAGS CLOUD — в самом низу */}
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<Reveal>
|
<Reveal>
|
||||||
<section className="container-wide pb-12 reveal">
|
<section className="container-wide pb-12 reveal">
|
||||||
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-4">Темы</h2>
|
<h2 className="text-sm font-medium uppercase tracking-widest mute mb-4">Все теги</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{tags.map(t => (
|
{tags.map(t => (
|
||||||
<Link key={t.tag} href={`/tag/${encodeURIComponent(t.tag)}`} className="tag">
|
<Link key={t.tag} href={`/tag/${encodeURIComponent(t.tag)}`} className="tag">
|
||||||
|
|||||||
@@ -3,15 +3,66 @@ import { formatDate } from '@/lib/markdown';
|
|||||||
import { Clock } from 'lucide-react';
|
import { Clock } from 'lucide-react';
|
||||||
import ArticleCoverSVG from './ArticleCoverSVG';
|
import ArticleCoverSVG from './ArticleCoverSVG';
|
||||||
|
|
||||||
|
const CATEGORY_META = {
|
||||||
|
'ai-tools': { label: 'AI Tools', cls: 'bg-emerald-50 dark:bg-emerald-950/60 text-emerald-700 dark:text-emerald-300 border-emerald-200 dark:border-emerald-900' },
|
||||||
|
'cybersec': { label: 'Cybersec', cls: 'bg-red-50 dark:bg-red-950/60 text-red-700 dark:text-red-300 border-red-200 dark:border-red-900' },
|
||||||
|
'automation': { label: 'Automation', cls: 'bg-amber-50 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-900' },
|
||||||
|
'ai-dev': { label: 'AI Dev', cls: 'bg-blue-50 dark:bg-blue-950/60 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-900' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отдать список «человеческих» тегов без дублей и без category-slug.
|
||||||
|
*/
|
||||||
|
function cleanTags(tags, category) {
|
||||||
|
if (!Array.isArray(tags)) return [];
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
const catLower = (category || '').toLowerCase();
|
||||||
|
for (const raw of tags) {
|
||||||
|
if (typeof raw !== 'string') continue;
|
||||||
|
const t = raw.trim();
|
||||||
|
if (!t) continue;
|
||||||
|
const lower = t.toLowerCase();
|
||||||
|
if (lower === catLower) continue;
|
||||||
|
if (seen.has(lower)) continue;
|
||||||
|
seen.add(lower);
|
||||||
|
out.push(t);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryBadge({ category }) {
|
||||||
|
if (!category) return null;
|
||||||
|
const meta = CATEGORY_META[category] || { label: category, cls: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 border-neutral-200 dark:border-neutral-700' };
|
||||||
|
// Просто бейдж без вложенной ссылки — карточка целиком кликабельна.
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center text-[11px] font-medium px-2 py-0.5 rounded-full border ${meta.cls}`}
|
||||||
|
>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function imageUrl(article) {
|
function imageUrl(article) {
|
||||||
if (!article.cover_url) return null;
|
if (!article.cover_url) return null;
|
||||||
return article.cover_url;
|
return article.cover_url;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ArticleCard({ article, featured = false }) {
|
/**
|
||||||
|
* ArticleCard — карточка статьи в трёх размерах.
|
||||||
|
* - size="hero": большая, 5/3 grid (для главной)
|
||||||
|
* - size="regular": обычная карточка (для сеток 3-в-ряд)
|
||||||
|
* - size="compact": плотная (для вертикальных лент / sidebar)
|
||||||
|
*
|
||||||
|
* featured prop оставлен для обратной совместимости (= size="hero").
|
||||||
|
*/
|
||||||
|
export default function ArticleCard({ article, featured = false, size = 'regular' }) {
|
||||||
|
const effectiveSize = featured ? 'hero' : size;
|
||||||
const img = imageUrl(article);
|
const img = imageUrl(article);
|
||||||
|
const tags = cleanTags(article.tags, article.category);
|
||||||
|
|
||||||
if (featured) {
|
if (effectiveSize === 'hero') {
|
||||||
return (
|
return (
|
||||||
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
|
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
|
||||||
<div className="flex flex-col sm:grid sm:grid-cols-5 sm:gap-0">
|
<div className="flex flex-col sm:grid sm:grid-cols-5 sm:gap-0">
|
||||||
@@ -29,7 +80,8 @@ export default function ArticleCard({ article, featured = false }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-5 sm:p-8 sm:col-span-3 flex flex-col justify-center">
|
<div className="p-5 sm:p-8 sm:col-span-3 flex flex-col justify-center">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-3">
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
{(article.tags || []).slice(0, 3).map(t => (
|
<CategoryBadge category={article.category} />
|
||||||
|
{tags.slice(0, 3).map(t => (
|
||||||
<span key={t} className="tag">#{t}</span>
|
<span key={t} className="tag">#{t}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +107,35 @@ export default function ArticleCard({ article, featured = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (effectiveSize === 'compact') {
|
||||||
|
return (
|
||||||
|
<Link href={`/blog/${article.slug}`} className="article-card group flex gap-3 items-start p-3">
|
||||||
|
<div className="shrink-0 w-20 h-20 rounded-lg overflow-hidden bg-neutral-100 dark:bg-neutral-800">
|
||||||
|
{img ? (
|
||||||
|
<img src={img} alt={article.title} className="w-full h-full object-cover" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<ArticleCoverSVG article={article} aspect="1/1" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="mb-1.5"><CategoryBadge category={article.category} /></div>
|
||||||
|
<h3 className="text-sm font-semibold mb-1 ink group-hover:accent transition-colors leading-snug line-clamp-2">
|
||||||
|
{article.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] mute">
|
||||||
|
<span>{formatDate(article.published_at)}</span>
|
||||||
|
{article.reading_time && (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" /> {article.reading_time} мин
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// regular
|
||||||
return (
|
return (
|
||||||
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
|
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
@@ -66,7 +147,8 @@ export default function ArticleCard({ article, featured = false }) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-4 sm:p-5 pt-2">
|
<div className="p-4 sm:p-5 pt-2">
|
||||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
{(article.tags || []).slice(0, 2).map(t => (
|
<CategoryBadge category={article.category} />
|
||||||
|
{tags.slice(0, 2).map(t => (
|
||||||
<span key={t} className="tag">#{t}</span>
|
<span key={t} className="tag">#{t}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Процедурно-сгенерированная SVG-обложка в стиле сайта.
|
* Процедурно-сгенерированная SVG-обложка в стиле ZeroPost.
|
||||||
* Не требует AI — рендерится сразу, выглядит достойно вместо плоского градиента.
|
* Используется когда настоящая обложка ещё не сгенерирована.
|
||||||
*
|
* Каждая статья получает уникальный, воспроизводимый узор.
|
||||||
* Идея: используем seed (id статьи) для воспроизводимой композиции.
|
|
||||||
* Каждая статья получает свой уникальный, но узнаваемый узор.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// псевдо-рандом по seed (mulberry32)
|
|
||||||
function rng(seed) {
|
function rng(seed) {
|
||||||
let t = seed + 0x6D2B79F5;
|
let t = seed + 0x6D2B79F5;
|
||||||
return () => {
|
return () => {
|
||||||
@@ -16,38 +13,37 @@ function rng(seed) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Палитры — приглушённые, чтобы не спорить с UI
|
// Только наши бренд-палитры — никакого произвольного фиолетового
|
||||||
const PALETTES = [
|
const PALETTES = [
|
||||||
{ bg: '#ecfdf5', accent: '#10b981', soft: '#a7f3d0', dark: '#065f46' }, // emerald
|
{ bg: '#ecfdf5', accent: '#10b981', soft: '#a7f3d0', dark: '#065f46' }, // emerald (основной)
|
||||||
{ bg: '#f0fdfa', accent: '#14b8a6', soft: '#99f6e4', dark: '#115e59' }, // teal
|
{ bg: '#f0fdfa', accent: '#14b8a6', soft: '#99f6e4', dark: '#115e59' }, // teal
|
||||||
{ bg: '#fefce8', accent: '#eab308', soft: '#fef08a', dark: '#854d0e' }, // yellow
|
{ bg: '#f8fafc', accent: '#10b981', soft: '#d1fae5', dark: '#1e293b' }, // emerald+neutral
|
||||||
|
{ bg: '#fefce8', accent: '#d97706', soft: '#fde68a', dark: '#92400e' }, // amber
|
||||||
{ bg: '#eff6ff', accent: '#3b82f6', soft: '#bfdbfe', dark: '#1e40af' }, // blue
|
{ bg: '#eff6ff', accent: '#3b82f6', soft: '#bfdbfe', dark: '#1e40af' }, // blue
|
||||||
{ bg: '#fdf4ff', accent: '#a855f7', soft: '#e9d5ff', dark: '#6b21a8' }, // purple
|
|
||||||
{ bg: '#fff7ed', accent: '#f97316', soft: '#fed7aa', dark: '#9a3412' }, // orange
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ArticleCoverSVG({ article, className = '', aspect = '16/9', priority = false }) {
|
export default function ArticleCoverSVG({ article, className = '', aspect = '16/9' }) {
|
||||||
const seed = (article?.id || 1) * 9301 + 49297;
|
const seed = (article?.id || 1) * 9301 + 49297;
|
||||||
const rand = rng(seed);
|
const rand = rng(seed);
|
||||||
const palette = PALETTES[Math.floor(rand() * PALETTES.length)];
|
|
||||||
|
|
||||||
// Композиция: 3-5 «слоёв» геометрии
|
// Выбор палитры по id — не случайный, а детерминированный
|
||||||
|
const palette = PALETTES[(article?.id || 0) % PALETTES.length];
|
||||||
|
|
||||||
const layers = 3 + Math.floor(rand() * 3);
|
const layers = 3 + Math.floor(rand() * 3);
|
||||||
const shapes = [];
|
const shapes = [];
|
||||||
|
|
||||||
for (let i = 0; i < layers; i++) {
|
for (let i = 0; i < layers; i++) {
|
||||||
const kind = ['curve', 'circle', 'arc', 'rect'][Math.floor(rand() * 4)];
|
const kind = ['circle', 'circle', 'arc', 'rect'][Math.floor(rand() * 4)]; // circle чаще
|
||||||
const opacity = 0.35 + rand() * 0.5;
|
const opacity = 0.25 + rand() * 0.45;
|
||||||
const colors = [palette.accent, palette.soft, palette.dark];
|
const colors = [palette.accent, palette.soft, palette.dark];
|
||||||
const fill = colors[Math.floor(rand() * colors.length)];
|
const fill = colors[Math.floor(rand() * colors.length)];
|
||||||
shapes.push({ kind, opacity, fill, r: rand });
|
shapes.push({ kind, opacity, fill, r: rand });
|
||||||
}
|
}
|
||||||
|
|
||||||
// тег (первый) — мелкая метка в углу
|
|
||||||
const tag = (article?.tags?.[0] || 'zeropost').toString().slice(0, 18);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative overflow-hidden rounded-xl ${className}`} style={{ aspectRatio: aspect, background: palette.bg }}>
|
<div
|
||||||
|
className={`relative overflow-hidden rounded-xl ${className}`}
|
||||||
|
style={{ aspectRatio: aspect, background: palette.bg }}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
viewBox="0 0 400 225"
|
viewBox="0 0 400 225"
|
||||||
preserveAspectRatio="xMidYMid slice"
|
preserveAspectRatio="xMidYMid slice"
|
||||||
@@ -55,7 +51,6 @@ export default function ArticleCoverSVG({ article, className = '', aspect = '16/
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{shapes.map((s, idx) => {
|
{shapes.map((s, idx) => {
|
||||||
// координаты, тоже псевдо-случайно
|
|
||||||
const cx = 60 + s.r() * 320;
|
const cx = 60 + s.r() * 320;
|
||||||
const cy = 30 + s.r() * 165;
|
const cy = 30 + s.r() * 165;
|
||||||
const size = 60 + s.r() * 180;
|
const size = 60 + s.r() * 180;
|
||||||
@@ -68,67 +63,55 @@ export default function ArticleCoverSVG({ article, className = '', aspect = '16/
|
|||||||
return (
|
return (
|
||||||
<rect
|
<rect
|
||||||
key={idx}
|
key={idx}
|
||||||
x={cx - size / 2}
|
x={cx - size / 2} y={cy - size / 2}
|
||||||
y={cy - size / 2}
|
width={size} height={size * (0.5 + s.r() * 1)}
|
||||||
width={size}
|
fill={s.fill} opacity={s.opacity}
|
||||||
height={size * (0.5 + s.r() * 1)}
|
|
||||||
fill={s.fill}
|
|
||||||
opacity={s.opacity}
|
|
||||||
rx={s.r() > 0.5 ? size / 8 : 0}
|
rx={s.r() > 0.5 ? size / 8 : 0}
|
||||||
transform={`rotate(${rot} ${cx} ${cy})`}
|
transform={`rotate(${rot} ${cx} ${cy})`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (s.kind === 'arc') {
|
if (s.kind === 'arc') {
|
||||||
// Полукруг
|
|
||||||
const r = size / 2;
|
const r = size / 2;
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={idx}
|
key={idx}
|
||||||
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy} Z`}
|
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy} Z`}
|
||||||
fill={s.fill}
|
fill={s.fill} opacity={s.opacity}
|
||||||
opacity={s.opacity}
|
|
||||||
transform={`rotate(${rot} ${cx} ${cy})`}
|
transform={`rotate(${rot} ${cx} ${cy})`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// curve — плавная волна
|
const w = size, h = size * 0.4;
|
||||||
const w = size;
|
|
||||||
const h = size * 0.4;
|
|
||||||
return (
|
return (
|
||||||
<path
|
<path
|
||||||
key={idx}
|
key={idx}
|
||||||
d={`M ${cx - w / 2} ${cy} Q ${cx} ${cy - h}, ${cx + w / 2} ${cy} T ${cx + w * 1.5} ${cy}`}
|
d={`M ${cx - w/2} ${cy} Q ${cx} ${cy - h}, ${cx + w/2} ${cy} T ${cx + w*1.5} ${cy}`}
|
||||||
stroke={s.fill}
|
stroke={s.fill} strokeWidth={6 + s.r() * 16}
|
||||||
strokeWidth={6 + s.r() * 16}
|
strokeLinecap="round" fill="none" opacity={s.opacity}
|
||||||
strokeLinecap="round"
|
|
||||||
fill="none"
|
|
||||||
opacity={s.opacity}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Мелкие точки-частицы — добавляют детализации */}
|
{/* Точки-частицы */}
|
||||||
{Array.from({ length: 14 }).map((_, i) => (
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
<circle
|
<circle
|
||||||
key={`d-${i}`}
|
key={`d-${i}`}
|
||||||
cx={rand() * 400}
|
cx={rand() * 400} cy={rand() * 225}
|
||||||
cy={rand() * 225}
|
r={1 + rand() * 2.5}
|
||||||
r={1 + rand() * 2}
|
fill={palette.dark} opacity={0.12 + rand() * 0.15}
|
||||||
fill={palette.dark}
|
|
||||||
opacity={0.18 + rand() * 0.18}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* тэг в углу */}
|
{/* Аватар Зеро в правом нижнем углу вместо текстового тега */}
|
||||||
<div className="absolute inset-0 flex items-end p-3 sm:p-4 pointer-events-none">
|
<div className="absolute bottom-3 right-3 pointer-events-none">
|
||||||
<div
|
<img
|
||||||
className="text-[10px] sm:text-xs font-mono tracking-wider uppercase px-2 py-0.5 rounded-md"
|
src="/uploads/zero-avatar.webp"
|
||||||
style={{ background: 'rgba(255,255,255,0.65)', color: palette.dark, backdropFilter: 'blur(4px)' }}
|
alt=""
|
||||||
>
|
className="w-8 h-8 rounded-lg opacity-70"
|
||||||
#{tag}
|
style={{ boxShadow: '0 1px 4px rgba(0,0,0,0.15)' }}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
import ArticleCard from './ArticleCard';
|
||||||
|
|
||||||
|
const CATEGORY_INFO = {
|
||||||
|
'ai-tools': { label: 'AI Tools', icon: '🤖', accent: 'text-emerald-600 dark:text-emerald-400', border: 'border-emerald-200 dark:border-emerald-900' },
|
||||||
|
'cybersec': { label: 'Cybersec', icon: '🔒', accent: 'text-red-600 dark:text-red-400', border: 'border-red-200 dark:border-red-900' },
|
||||||
|
'automation': { label: 'Automation', icon: '⚡', accent: 'text-amber-600 dark:text-amber-400', border: 'border-amber-200 dark:border-amber-900' },
|
||||||
|
'ai-dev': { label: 'AI Dev', icon: '💻', accent: 'text-blue-600 dark:text-blue-400', border: 'border-blue-200 dark:border-blue-900' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Категорийный ряд: заголовок + 3 карточки + «все →».
|
||||||
|
* Показываем только если в категории есть хотя бы 1 статья.
|
||||||
|
*/
|
||||||
|
export default function CategoryRow({ category, articles }) {
|
||||||
|
if (!articles || articles.length === 0) return null;
|
||||||
|
const info = CATEGORY_INFO[category] || { label: category, icon: '📝', accent: 'text-neutral-700', border: 'border-neutral-200' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={`container-wide pb-10`}>
|
||||||
|
<div className={`flex items-end justify-between mb-5 pb-3 border-b ${info.border}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl">{info.icon}</span>
|
||||||
|
<h2 className={`text-xl sm:text-2xl font-bold ${info.accent}`}>{info.label}</h2>
|
||||||
|
</div>
|
||||||
|
<Link href={`/category/${category}`} className={`text-sm font-medium inline-flex items-center gap-1 ${info.accent} hover:opacity-80 transition-opacity`}>
|
||||||
|
Все материалы <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{articles.map(a => <ArticleCard key={a.id} article={a} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-2
@@ -1,13 +1,42 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { Send } from 'lucide-react';
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t-soft mt-20">
|
<footer className="border-t-soft mt-20">
|
||||||
<div className="container-wide py-10 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm mute">
|
{/* TG-банер */}
|
||||||
<div>
|
<div className="border-b-soft">
|
||||||
|
<div className="container-wide py-6 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl overflow-hidden shrink-0">
|
||||||
|
<img src="/uploads/zero-avatar.webp" alt="Зеро" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold ink">ZeroPost в Telegram</div>
|
||||||
|
<div className="text-xs mute">Каждый день — анонс новой заметки от Зеро</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://t.me/zeropostru"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors shrink-0"
|
||||||
|
style={{ background: 'rgb(var(--accent))' }}
|
||||||
|
>
|
||||||
|
<Send className="w-3.5 h-3.5" />
|
||||||
|
Подписаться
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Обычный footer */}
|
||||||
|
<div className="container-wide py-8 flex flex-col sm:flex-row items-center justify-between gap-4 text-sm mute">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<img src="/uploads/zero-avatar.webp" alt="" className="w-5 h-5 rounded opacity-60" />
|
||||||
© {new Date().getFullYear()} ZeroPost — генерируется ИИ, читается людьми
|
© {new Date().getFullYear()} ZeroPost — генерируется ИИ, читается людьми
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
<Link href="/about/zero" className="hover:ink transition-colors">Кто такой Зеро?</Link>
|
||||||
<Link href="/archive" className="hover:ink transition-colors">Архив</Link>
|
<Link href="/archive" className="hover:ink transition-colors">Архив</Link>
|
||||||
<Link href="/notes" className="hover:ink transition-colors">Заметки</Link>
|
<Link href="/notes" className="hover:ink transition-colors">Заметки</Link>
|
||||||
<Link href="/about" className="hover:ink transition-colors">О проекте</Link>
|
<Link href="/about" className="hover:ink transition-colors">О проекте</Link>
|
||||||
|
|||||||
+30
-30
@@ -5,6 +5,12 @@ import { Sparkles, Menu, X } from 'lucide-react';
|
|||||||
import ThemeToggle from './ThemeToggle';
|
import ThemeToggle from './ThemeToggle';
|
||||||
import SearchBox from './SearchBox';
|
import SearchBox from './SearchBox';
|
||||||
|
|
||||||
|
// Минимум: главная + о проекте. Серии и заметки доступны через карточки на главной и футер.
|
||||||
|
const NAV_LINKS = [
|
||||||
|
{ href: '/', label: 'Главная' },
|
||||||
|
{ href: '/about', label: 'О проекте' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [hidden, setHidden] = useState(false);
|
const [hidden, setHidden] = useState(false);
|
||||||
@@ -55,20 +61,18 @@ export default function Header() {
|
|||||||
|
|
||||||
{/* Desktop nav */}
|
{/* Desktop nav */}
|
||||||
<nav className="hidden sm:flex items-center gap-1 text-sm">
|
<nav className="hidden sm:flex items-center gap-1 text-sm">
|
||||||
<Link href="/" className="btn btn-ghost text-sm py-1.5">Статьи</Link>
|
{NAV_LINKS.map(link => (
|
||||||
<Link href="/category/cybersec" className="btn btn-ghost text-sm py-1.5">🔒 Безопасность</Link>
|
<Link key={link.href} href={link.href} className="btn btn-ghost text-sm py-1.5">
|
||||||
<Link href="/category/automation" className="btn btn-ghost text-sm py-1.5">⚡ Автоматизация</Link>
|
{link.label}
|
||||||
<Link href="/category/ai-dev" className="btn btn-ghost text-sm py-1.5">💻 Dev + AI</Link>
|
</Link>
|
||||||
<Link href="/archive" className="btn btn-ghost text-sm py-1.5">Архив</Link>
|
))}
|
||||||
<Link href="/notes" className="btn btn-ghost text-sm py-1.5">Заметки</Link>
|
<div className="ml-2 flex items-center gap-1 pl-2 border-l border-soft">
|
||||||
<Link href="/about" className="btn btn-ghost text-sm py-1.5">О проекте</Link>
|
|
||||||
<div className="ml-1 flex items-center gap-1">
|
|
||||||
<SearchBox />
|
<SearchBox />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Mobile */}
|
{/* Mobile controls */}
|
||||||
<div className="sm:hidden flex items-center gap-1">
|
<div className="sm:hidden flex items-center gap-1">
|
||||||
<SearchBox />
|
<SearchBox />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
@@ -90,27 +94,23 @@ export default function Header() {
|
|||||||
style={{ background: 'rgb(var(--bg) / 0.98)', paddingTop: 'calc(64px + env(safe-area-inset-top))' }}
|
style={{ background: 'rgb(var(--bg) / 0.98)', paddingTop: 'calc(64px + env(safe-area-inset-top))' }}
|
||||||
>
|
>
|
||||||
<nav className="container-wide pt-6 pb-8 flex flex-col gap-1">
|
<nav className="container-wide pt-6 pb-8 flex flex-col gap-1">
|
||||||
<Link
|
<Link href="/" onClick={() => setOpen(false)} className="text-2xl font-semibold ink py-3 border-b-soft">
|
||||||
href="/"
|
Главная
|
||||||
onClick={() => setOpen(false)}
|
</Link>
|
||||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
<div className="py-3 border-b-soft">
|
||||||
>Статьи</Link>
|
<div className="text-xs uppercase tracking-widest mute mb-2">Темы</div>
|
||||||
<Link
|
<div className="grid grid-cols-2 gap-2">
|
||||||
href="/archive"
|
<Link onClick={() => setOpen(false)} href="/category/ai-tools" className="text-base font-medium py-2">🤖 AI Tools</Link>
|
||||||
onClick={() => setOpen(false)}
|
<Link onClick={() => setOpen(false)} href="/category/ai-dev" className="text-base font-medium py-2">💻 AI Dev</Link>
|
||||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
<Link onClick={() => setOpen(false)} href="/category/automation" className="text-base font-medium py-2">⚡ Automation</Link>
|
||||||
>Архив</Link>
|
<Link onClick={() => setOpen(false)} href="/category/cybersec" className="text-base font-medium py-2">🔒 Cybersec</Link>
|
||||||
<Link
|
</div>
|
||||||
href="/notes"
|
</div>
|
||||||
onClick={() => setOpen(false)}
|
<Link href="/series" onClick={() => setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">Серии</Link>
|
||||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
<Link href="/notes" onClick={() => setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">Заметки</Link>
|
||||||
>Заметки</Link>
|
<Link href="/archive" onClick={() => setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">Архив</Link>
|
||||||
<Link
|
<Link href="/about" onClick={() => setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">О проекте</Link>
|
||||||
href="/about"
|
<div className="mt-8 mute text-sm leading-relaxed">
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
className="text-2xl font-semibold ink py-3 border-b-soft"
|
|
||||||
>О проекте</Link>
|
|
||||||
<div className="mt-6 mute text-sm">
|
|
||||||
Блог, который ведёт ИИ — а человек только следит за курсом.
|
Блог, который ведёт ИИ — а человек только следит за курсом.
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import ArticleCard from './ArticleCard';
|
||||||
|
import { Flame } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Блок «Популярное за неделю/месяц».
|
||||||
|
* Не рендерится если пусто.
|
||||||
|
*/
|
||||||
|
export default function PopularBlock({ articles }) {
|
||||||
|
if (!articles || articles.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<section className="container-wide pb-10">
|
||||||
|
<div className="flex items-center gap-2 mb-5 pb-3 border-b border-orange-200 dark:border-orange-900">
|
||||||
|
<Flame className="w-5 h-5 text-orange-500" />
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
Популярное за месяц
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{articles.map(a => <ArticleCard key={a.id} article={a} />)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import ArticleCard from './ArticleCard';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Группировка свежих материалов по дням: «Сегодня», «Вчера», «Эта неделя», «Ранее».
|
||||||
|
* Не рендерится, если пусто.
|
||||||
|
*/
|
||||||
|
function groupByPeriod(articles) {
|
||||||
|
const now = new Date();
|
||||||
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||||
|
const startOfYesterday = startOfToday - 24 * 3600 * 1000;
|
||||||
|
const startOfWeek = startOfToday - 7 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
const groups = { today: [], yesterday: [], week: [], earlier: [] };
|
||||||
|
for (const a of articles) {
|
||||||
|
const t = new Date(a.published_at).getTime();
|
||||||
|
if (t >= startOfToday) groups.today.push(a);
|
||||||
|
else if (t >= startOfYesterday) groups.yesterday.push(a);
|
||||||
|
else if (t >= startOfWeek) groups.week.push(a);
|
||||||
|
else groups.earlier.push(a);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LABELS = {
|
||||||
|
today: 'Сегодня',
|
||||||
|
yesterday: 'Вчера',
|
||||||
|
week: 'На этой неделе',
|
||||||
|
earlier: 'Ранее',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecentBlock({ articles }) {
|
||||||
|
if (!articles || articles.length === 0) return null;
|
||||||
|
const groups = groupByPeriod(articles);
|
||||||
|
const nonEmpty = Object.entries(groups).filter(([, arr]) => arr.length > 0);
|
||||||
|
if (nonEmpty.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="container-wide pb-12">
|
||||||
|
<div className="flex items-end justify-between mb-5">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold ink">Свежие материалы</h2>
|
||||||
|
<Link href="/archive" className="text-sm font-medium inline-flex items-center gap-1 accent hover:opacity-80 transition-opacity">
|
||||||
|
Архив <ArrowRight className="w-3.5 h-3.5" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-8">
|
||||||
|
{nonEmpty.map(([key, arr]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-widest mute mb-3">
|
||||||
|
{LABELS[key]} <span className="opacity-50">· {arr.length}</span>
|
||||||
|
</h3>
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{arr.map(a => <ArticleCard key={a.id} article={a} />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useRouter } from 'next/navigation';
|
import { usePathname, useRouter } from 'next/navigation';
|
||||||
import { LayoutDashboard, FileText, Radio, Zap, LogOut, ExternalLink } from 'lucide-react';
|
import { LayoutDashboard, FileText, Radio, Zap, Settings, LogOut, ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
|
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
|
||||||
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
|
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
|
||||||
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
|
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
|
||||||
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
|
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
|
||||||
|
{ href: '/admin/settings', label: 'Настройки', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminNav() {
|
export default function AdminNav() {
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Search, Loader2, X, Check, AlertTriangle, Clock, Image as ImageIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
'ai-tools': 'AI Tools',
|
||||||
|
'cybersec': 'Cybersec',
|
||||||
|
'automation': 'Automation',
|
||||||
|
'ai-dev': 'AI Dev',
|
||||||
|
};
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||||
|
}
|
||||||
|
function fmtTime(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ArticlePicker — typeahead-выбор статьи для публикации в канал.
|
||||||
|
* - Серверный поиск через /admin/api/articles/search
|
||||||
|
* - Показывает обложку, категорию, дату публикации, состояние (отправлено уже/в очереди)
|
||||||
|
* - Поддерживает channelId для сигнализации «уже было / в очереди»
|
||||||
|
*/
|
||||||
|
export default function ArticlePicker({ value, onChange, channelId, placeholder = 'Найти статью…' }) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const debounceRef = useRef(null);
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
|
||||||
|
// Загружаем выбранную статью по id (если уже выбрана и items не содержит её)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!value) { setSelected(null); return; }
|
||||||
|
if (selected?.id === Number(value)) return;
|
||||||
|
// Дозагрузка
|
||||||
|
fetch(`/admin/api/articles/search?q=&channel_id=${channelId || ''}&limit=200`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const it = (d.items || []).find(a => a.id === Number(value));
|
||||||
|
if (it) setSelected(it);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [value, channelId]);
|
||||||
|
|
||||||
|
// Debounced поиск
|
||||||
|
const search = useCallback((q) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
if (channelId) params.set('channel_id', String(channelId));
|
||||||
|
params.set('limit', '20');
|
||||||
|
fetch(`/admin/api/articles/search?${params.toString()}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
if (d.error) throw new Error(d.error);
|
||||||
|
setItems(d.items || []);
|
||||||
|
})
|
||||||
|
.catch(e => { setError(e.message); setItems([]); })
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [channelId]);
|
||||||
|
|
||||||
|
function onInput(v) {
|
||||||
|
setQuery(v);
|
||||||
|
setOpen(true);
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => search(v.trim()), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDropdown() {
|
||||||
|
setOpen(true);
|
||||||
|
if (items.length === 0 && !loading) search(query.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(article) {
|
||||||
|
setSelected(article);
|
||||||
|
onChange?.(article);
|
||||||
|
setOpen(false);
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
setSelected(null);
|
||||||
|
onChange?.(null);
|
||||||
|
setQuery('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие по клику снаружи
|
||||||
|
useEffect(() => {
|
||||||
|
function onDocClick(e) {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
return () => document.removeEventListener('mousedown', onDocClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={rootRef}>
|
||||||
|
{/* Selected chip */}
|
||||||
|
{selected && (
|
||||||
|
<div className="mb-2 flex items-start gap-3 p-3 rounded-lg border border-emerald-200 dark:border-emerald-900 bg-emerald-50 dark:bg-emerald-950">
|
||||||
|
{selected.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={selected.cover_url.startsWith('http') ? selected.cover_url : `https://zeropost.ru${selected.cover_url}`}
|
||||||
|
alt=""
|
||||||
|
className="w-14 h-14 rounded object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 rounded bg-neutral-200 dark:bg-neutral-800 flex items-center justify-center shrink-0">
|
||||||
|
<ImageIcon className="w-5 h-5 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{selected.title}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-xs text-neutral-500 flex-wrap">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700">
|
||||||
|
{CATEGORY_LABELS[selected.category] || selected.category}
|
||||||
|
</span>
|
||||||
|
<span>{fmtDate(selected.published_at)}</span>
|
||||||
|
{selected.was_sent_to_channel && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400" title="Уже отправлено в этот канал">
|
||||||
|
<AlertTriangle className="w-3 h-3" /> уже было {fmtTime(selected.was_sent_to_channel)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selected.next_scheduled_at && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400" title="Уже в очереди">
|
||||||
|
<Clock className="w-3 h-3" /> в очереди на {fmtTime(selected.next_scheduled_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={clear} className="text-neutral-400 hover:text-red-500 p-1" title="Очистить">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-neutral-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={e => onInput(e.target.value)}
|
||||||
|
onFocus={openDropdown}
|
||||||
|
placeholder={selected ? 'Заменить статью…' : placeholder}
|
||||||
|
className="w-full pl-9 pr-9 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
{loading && (
|
||||||
|
<Loader2 className="w-4 h-4 absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{open && (
|
||||||
|
<div className="absolute z-30 mt-1 w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-lg shadow-xl max-h-96 overflow-y-auto">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-500">{error}</div>
|
||||||
|
)}
|
||||||
|
{!error && !loading && items.length === 0 && (
|
||||||
|
<div className="p-4 text-sm text-neutral-500 text-center">
|
||||||
|
{query ? `Ничего не найдено по «${query}»` : 'Нет статей'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{items.map(a => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
onClick={() => pick(a)}
|
||||||
|
className="w-full flex items-start gap-3 p-3 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800 border-b border-neutral-100 dark:border-neutral-800 last:border-b-0 transition-colors"
|
||||||
|
>
|
||||||
|
{a.cover_url ? (
|
||||||
|
<img
|
||||||
|
src={a.cover_url.startsWith('http') ? a.cover_url : `https://zeropost.ru${a.cover_url}`}
|
||||||
|
alt=""
|
||||||
|
className="w-12 h-12 rounded object-cover shrink-0"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-12 h-12 rounded bg-neutral-200 dark:bg-neutral-800 flex items-center justify-center shrink-0">
|
||||||
|
<ImageIcon className="w-4 h-4 text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">{a.title}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 flex-wrap">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">
|
||||||
|
{CATEGORY_LABELS[a.category] || a.category}
|
||||||
|
</span>
|
||||||
|
<span>{fmtDate(a.published_at)}</span>
|
||||||
|
{a.was_sent_to_channel && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="w-3 h-3" /> уже было
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{a.next_scheduled_at && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-blue-600 dark:text-blue-400">
|
||||||
|
<Clock className="w-3 h-3" /> в очереди
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{selected?.id === a.id && (
|
||||||
|
<Check className="w-3.5 h-3.5 text-emerald-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Save, Loader2, Eye, AlertCircle, Sparkles, Clock, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
const CATEGORIES = [
|
||||||
|
{ slug: 'ai-tools', label: 'AI Tools' },
|
||||||
|
{ slug: 'cybersec', label: 'Cybersec' },
|
||||||
|
{ slug: 'automation', label: 'Automation' },
|
||||||
|
{ slug: 'ai-dev', label: 'AI Dev' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLACEHOLDERS = [
|
||||||
|
{ key: '{title}', desc: 'Заголовок статьи' },
|
||||||
|
{ key: '{excerpt}', desc: 'Краткое описание (excerpt)' },
|
||||||
|
{ key: '{url}', desc: 'https://zeropost.ru/blog/{slug}' },
|
||||||
|
{ key: '{category}', desc: 'Категория (ai-tools, cybersec, …)' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_TEMPLATE = '*{title}*\n\n{excerpt}\n\n{url}';
|
||||||
|
|
||||||
|
export default function AutoPublishTab({ channel, onSaved }) {
|
||||||
|
const [enabled, setEnabled] = useState(channel?.auto_publish_enabled ?? false);
|
||||||
|
const [categories, setCategories] = useState(channel?.auto_publish_categories || []);
|
||||||
|
const [delayMin, setDelayMin] = useState(channel?.auto_publish_delay_min ?? 0);
|
||||||
|
const [template, setTemplate] = useState(channel?.auto_publish_template || '');
|
||||||
|
const [withCover, setWithCover] = useState(channel?.auto_publish_with_cover ?? true);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [savedToast, setSavedToast] = useState(false);
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
// Превью
|
||||||
|
const [previewArticleId, setPreviewArticleId] = useState(null);
|
||||||
|
const [previewArticleTitle, setPreviewArticleTitle] = useState('');
|
||||||
|
const [preview, setPreview] = useState(null);
|
||||||
|
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||||
|
|
||||||
|
// Подгружаем первую статью для авто-превью
|
||||||
|
useEffect(() => {
|
||||||
|
const cat = categories[0] || '';
|
||||||
|
const url = `/admin/api/articles/search?status=published&limit=1${cat ? `&category=${cat}` : ''}`;
|
||||||
|
fetch(url).then(r => r.json()).then(d => {
|
||||||
|
const it = d.items?.[0];
|
||||||
|
if (it) {
|
||||||
|
setPreviewArticleId(it.id);
|
||||||
|
setPreviewArticleTitle(it.title);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
}, [categories.join(',')]); // переоценим если категория меняется
|
||||||
|
|
||||||
|
// Авто-превью при изменении template или статьи
|
||||||
|
useEffect(() => {
|
||||||
|
if (!previewArticleId) return;
|
||||||
|
setLoadingPreview(true);
|
||||||
|
fetch('/admin/api/scheduled/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ article_id: previewArticleId, template: template || DEFAULT_TEMPLATE }),
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => setPreview(d))
|
||||||
|
.catch(e => setPreview({ error: e.message }))
|
||||||
|
.finally(() => setLoadingPreview(false));
|
||||||
|
}, [previewArticleId, template]);
|
||||||
|
|
||||||
|
function toggleCategory(slug) {
|
||||||
|
setCategories(cs => cs.includes(slug) ? cs.filter(c => c !== slug) : [...cs, slug]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
setSaving(true);
|
||||||
|
setErr('');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/admin/api/channels/${channel.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
auto_publish_enabled: enabled,
|
||||||
|
auto_publish_categories: categories,
|
||||||
|
auto_publish_delay_min: parseInt(delayMin) || 0,
|
||||||
|
auto_publish_template: template || null,
|
||||||
|
auto_publish_with_cover: withCover,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
setSavedToast(true);
|
||||||
|
setTimeout(() => setSavedToast(false), 2000);
|
||||||
|
onSaved?.();
|
||||||
|
} catch (e) {
|
||||||
|
setErr(e.message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertPlaceholder(p) {
|
||||||
|
const el = document.getElementById('auto-publish-template');
|
||||||
|
if (!el) return;
|
||||||
|
const start = el.selectionStart || template.length;
|
||||||
|
const end = el.selectionEnd || template.length;
|
||||||
|
setTemplate(t => t.slice(0, start) + p + t.slice(end));
|
||||||
|
setTimeout(() => {
|
||||||
|
el.focus();
|
||||||
|
el.selectionStart = el.selectionEnd = start + p.length;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveTemplate = template || DEFAULT_TEMPLATE;
|
||||||
|
const captionWarning = preview?.length > 1024 && withCover;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Toggle */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-emerald-500" />
|
||||||
|
Автоматическая публикация статей
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
Как только статья получает статус <code className="bg-neutral-100 dark:bg-neutral-800 px-1 rounded">published</code>,
|
||||||
|
она автоматически попадает в очередь публикации в этот канал.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer shrink-0">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={e => setEnabled(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded accent-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium">{enabled ? 'Включено' : 'Выключено'}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Категории */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-1">Категории статей</h3>
|
||||||
|
<p className="text-xs text-neutral-500 mb-4">
|
||||||
|
Если ничего не выбрано — публикуется всё. Иначе только статьи из выбранных категорий.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
|
{CATEGORIES.map(c => {
|
||||||
|
const on = categories.includes(c.slug);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.slug}
|
||||||
|
onClick={() => toggleCategory(c.slug)}
|
||||||
|
className={`px-3 py-2 rounded-lg border text-sm transition-all ${
|
||||||
|
on
|
||||||
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600 dark:text-neutral-400 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Задержка + cover */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 grid sm:grid-cols-2 gap-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">
|
||||||
|
<Clock className="w-3.5 h-3.5 inline mr-1" /> Задержка (мин)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={10080}
|
||||||
|
value={delayMin}
|
||||||
|
onChange={e => setDelayMin(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-400 mt-1">
|
||||||
|
<code>0</code> = ближайший слот канала. <code>30</code> = через полчаса.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Картинка к посту</label>
|
||||||
|
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={withCover}
|
||||||
|
onChange={e => setWithCover(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded accent-emerald-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">Прикреплять обложку статьи</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-neutral-400 mt-1">
|
||||||
|
TG: ограничение caption — 1024 символа.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Шаблон */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-1">Шаблон поста</h3>
|
||||||
|
<p className="text-xs text-neutral-500 mb-3">
|
||||||
|
Markdown для Telegram. Если пусто — используется дефолт:
|
||||||
|
<code className="bg-neutral-100 dark:bg-neutral-800 px-1 rounded ml-1 text-[11px]">{DEFAULT_TEMPLATE.replaceAll('\n', '↵')}</code>
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
id="auto-publish-template"
|
||||||
|
value={template}
|
||||||
|
onChange={e => setTemplate(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
placeholder={DEFAULT_TEMPLATE}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y"
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
{PLACEHOLDERS.map(p => (
|
||||||
|
<button
|
||||||
|
key={p.key}
|
||||||
|
onClick={() => insertPlaceholder(p.key)}
|
||||||
|
title={p.desc}
|
||||||
|
className="text-[11px] px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800 hover:bg-emerald-100 dark:hover:bg-emerald-900 text-neutral-700 dark:text-neutral-300 font-mono"
|
||||||
|
>
|
||||||
|
{p.key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
|
||||||
|
<Eye className="w-4 h-4 text-blue-500" />
|
||||||
|
Превью на свежей статье
|
||||||
|
</h3>
|
||||||
|
{previewArticleTitle && (
|
||||||
|
<p className="text-xs text-neutral-500 mb-3">
|
||||||
|
Пример: <span className="font-medium">{previewArticleTitle}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loadingPreview ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-neutral-400" />
|
||||||
|
</div>
|
||||||
|
) : preview?.error ? (
|
||||||
|
<p className="text-sm text-red-500">{preview.error}</p>
|
||||||
|
) : preview ? (
|
||||||
|
<div>
|
||||||
|
{withCover && preview.cover_url && (
|
||||||
|
<img
|
||||||
|
src={preview.cover_url.startsWith('http') ? preview.cover_url : `https://zeropost.ru${preview.cover_url}`}
|
||||||
|
alt=""
|
||||||
|
className="w-full max-h-64 object-cover rounded mb-3"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<pre className="whitespace-pre-wrap text-sm bg-neutral-50 dark:bg-neutral-800 p-3 rounded border border-neutral-200 dark:border-neutral-700 font-mono">
|
||||||
|
{preview.text}
|
||||||
|
</pre>
|
||||||
|
<div className="flex items-center justify-between mt-2 text-xs">
|
||||||
|
<span className={preview.length > 1024 && withCover ? 'text-amber-500' : 'text-neutral-400'}>
|
||||||
|
{preview.length} символов{preview.length > 1024 && withCover && ' (caption обрежется до 1024)'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => fetch(`/admin/api/articles/search?limit=20`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(d => {
|
||||||
|
const arr = d.items || [];
|
||||||
|
if (arr.length < 2) return;
|
||||||
|
const cur = arr.findIndex(a => a.id === previewArticleId);
|
||||||
|
const next = arr[(cur + 1) % arr.length];
|
||||||
|
setPreviewArticleId(next.id);
|
||||||
|
setPreviewArticleTitle(next.title);
|
||||||
|
})}
|
||||||
|
className="text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3" /> Другая статья
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-400">Нет статей для превью</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{captionWarning && (
|
||||||
|
<div className="mt-3 flex items-start gap-2 text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 rounded p-2">
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||||
|
<span>Текст длиннее 1024 — Telegram обрежет caption у фото. Уменьши шаблон или сними «Прикреплять обложку».</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={saving}
|
||||||
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
|
{saving ? 'Сохранение…' : 'Сохранить настройки автопубликации'}
|
||||||
|
</button>
|
||||||
|
{savedToast && <span className="text-sm text-emerald-600 dark:text-emerald-400">✓ Сохранено</span>}
|
||||||
|
{err && <span className="text-sm text-red-500">{err}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,52 @@ const COLOR = {
|
|||||||
blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300',
|
blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Рассчитать реальное время следующего запуска автогенерации.
|
||||||
|
*
|
||||||
|
* Логика автогена (см. src/services/autogen.js):
|
||||||
|
* - cron бьёт каждые 10 минут
|
||||||
|
* - срабатывает если: run_hour=ТЕКУЩИЙ_ЧАС_MSK + run_minute в окне ±5
|
||||||
|
* - + защита: last_run_at < NOW() - INTERVAL '6 hours'
|
||||||
|
*
|
||||||
|
* Это даёт: следующий запуск — ближайший момент {run_hour:run_minute} MSK
|
||||||
|
* после max(now, last_run_at + 6h).
|
||||||
|
*/
|
||||||
|
function calcNextRun({ run_hour = 8, run_minute = 0, last_run_at, enabled }) {
|
||||||
|
if (!enabled) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Сегодняшняя дата в MSK
|
||||||
|
const mskNow = new Date(now + 3 * 3600 * 1000);
|
||||||
|
const y = mskNow.getUTCFullYear();
|
||||||
|
const m = mskNow.getUTCMonth();
|
||||||
|
const d = mskNow.getUTCDate();
|
||||||
|
|
||||||
|
// Целевое UTC время для сегодняшнего run_hour:run_minute в MSK
|
||||||
|
// (MSK = UTC+3, поэтому UTC = MSK - 3h)
|
||||||
|
let target = Date.UTC(y, m, d, run_hour, run_minute) - 3 * 3600 * 1000;
|
||||||
|
|
||||||
|
// Если уже прошло (с окном +5 мин) — переносим на завтра
|
||||||
|
if (target + 5 * 60 * 1000 < now) {
|
||||||
|
target += 24 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Защита «не чаще раза в 6 часов»
|
||||||
|
if (last_run_at) {
|
||||||
|
const guard = new Date(last_run_at).getTime() + 6 * 3600 * 1000;
|
||||||
|
while (target < guard) target += 24 * 3600 * 1000;
|
||||||
|
}
|
||||||
|
return new Date(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtNextRun(date) {
|
||||||
|
if (!date) return '—';
|
||||||
|
return date.toLocaleString('ru-RU', {
|
||||||
|
timeZone: 'Europe/Moscow',
|
||||||
|
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function AutogenPanel({ status, queue, topics }) {
|
export default function AutogenPanel({ status, queue, topics }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [running, setRunning] = useState({});
|
const [running, setRunning] = useState({});
|
||||||
@@ -122,11 +168,9 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pendingQueue = queue.filter(q => q.status === 'pending');
|
const pendingQueue = queue.filter(q => q.status === 'pending');
|
||||||
const doneQueue = queue.filter(q => q.status === 'done').slice(0, 5);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Toast */}
|
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg max-w-sm ${
|
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg max-w-sm ${
|
||||||
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
|
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
|
||||||
@@ -135,11 +179,10 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Шапка */}
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Автогенерация</h1>
|
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Автогенерация</h1>
|
||||||
<p className="text-sm text-neutral-500 mt-0.5">Автоматическое создание статей по расписанию</p>
|
<p className="text-sm text-neutral-500 mt-0.5">Автоматическое создание статей по расписанию (MSK)</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={runAll}
|
onClick={runAll}
|
||||||
@@ -151,12 +194,12 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Категории */}
|
|
||||||
<div className="grid sm:grid-cols-2 gap-4">
|
<div className="grid sm:grid-cols-2 gap-4">
|
||||||
{status.map(s => {
|
{status.map(s => {
|
||||||
const cat = CAT_LABELS[s.category] || { name: s.category, icon: '📝', color: 'emerald' };
|
const cat = CAT_LABELS[s.category] || { name: s.category, icon: '📝', color: 'emerald' };
|
||||||
const colorCls = COLOR[cat.color] || COLOR.emerald;
|
const colorCls = COLOR[cat.color] || COLOR.emerald;
|
||||||
const isRunning = running[s.category] || running.all;
|
const isRunning = running[s.category] || running.all;
|
||||||
|
const nextRun = calcNextRun(s);
|
||||||
return (
|
return (
|
||||||
<div key={s.category} className={`rounded-xl border p-5 ${colorCls}`}>
|
<div key={s.category} className={`rounded-xl border p-5 ${colorCls}`}>
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
@@ -167,7 +210,6 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
<div className="text-xs opacity-70">{s.article_count || 0} статей · {s.queue_count || 0} в очереди</div>
|
<div className="text-xs opacity-70">{s.article_count || 0} статей · {s.queue_count || 0} в очереди</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Вкл/выкл */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCategory(s.category, !s.enabled)}
|
onClick={() => toggleCategory(s.category, !s.enabled)}
|
||||||
className={`text-xs px-2 py-1 rounded-full font-medium border transition-colors ${
|
className={`text-xs px-2 py-1 rounded-full font-medium border transition-colors ${
|
||||||
@@ -188,36 +230,41 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
{[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)}
|
{[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
|
<Clock className="w-3.5 h-3.5 opacity-60 shrink-0" />
|
||||||
<span className="text-xs opacity-70">Время запуска:</span>
|
<span className="text-xs opacity-70">Время (MSK):</span>
|
||||||
<select
|
<div className="inline-flex items-center gap-1 bg-white/50 dark:bg-black/20 border border-current/20 rounded px-1.5 py-0.5">
|
||||||
value={s.run_hour ?? 8}
|
<select
|
||||||
onChange={e => updateTime(s.category, e.target.value, s.run_minute ?? 0)}
|
value={s.run_hour ?? 8}
|
||||||
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
|
onChange={e => updateTime(s.category, e.target.value, s.run_minute ?? 0)}
|
||||||
>
|
className="text-xs bg-transparent font-mono focus:outline-none"
|
||||||
{Array.from({length: 24}, (_,i) => (
|
>
|
||||||
<option key={i} value={i}>{String(i).padStart(2,'0')}:__</option>
|
{Array.from({length: 24}, (_,i) => (
|
||||||
))}
|
<option key={i} value={i}>{String(i).padStart(2,'0')}</option>
|
||||||
</select>
|
))}
|
||||||
<select
|
</select>
|
||||||
value={s.run_minute ?? 0}
|
<span className="text-xs opacity-70 font-mono">:</span>
|
||||||
onChange={e => updateTime(s.category, s.run_hour ?? 8, e.target.value)}
|
<select
|
||||||
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
|
value={s.run_minute ?? 0}
|
||||||
>
|
onChange={e => updateTime(s.category, s.run_hour ?? 8, e.target.value)}
|
||||||
{[0,5,10,15,20,25,30,35,40,45,50,55].map(m => (
|
className="text-xs bg-transparent font-mono focus:outline-none"
|
||||||
<option key={m} value={m}>__:{String(m).padStart(2,'0')}</option>
|
>
|
||||||
))}
|
{[0,5,10,15,20,25,30,35,40,45,50,55].map(m => (
|
||||||
</select>
|
<option key={m} value={m}>{String(m).padStart(2,'0')}</option>
|
||||||
{s.next_run_at && (
|
))}
|
||||||
<span className="text-xs opacity-60 ml-auto">
|
</select>
|
||||||
след.: {new Date(s.next_run_at).toLocaleString('ru-RU', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs opacity-60 pl-6">
|
||||||
|
Следующий запуск: <span className="font-medium">{fmtNextRun(nextRun)}</span>
|
||||||
|
{s.last_run_at && (
|
||||||
|
<> · последний: {new Date(s.last_run_at).toLocaleString('ru-RU', { timeZone: 'Europe/Moscow', day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Кнопка запуска */}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => runCategory(s.category)}
|
onClick={() => runCategory(s.category)}
|
||||||
disabled={isRunning || !s.enabled}
|
disabled={isRunning || !s.enabled}
|
||||||
@@ -245,7 +292,6 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Форма добавления */}
|
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="px-5 py-4 border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
<div className="px-5 py-4 border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
@@ -278,7 +324,6 @@ export default function AutogenPanel({ status, queue, topics }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Список очереди */}
|
|
||||||
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
|
||||||
{pendingQueue.length === 0 && !showAddForm && (
|
{pendingQueue.length === 0 && !showAddForm && (
|
||||||
<div className="px-5 py-8 text-center text-sm text-neutral-400">
|
<div className="px-5 py-8 text-center text-sm text-neutral-400">
|
||||||
|
|||||||
+273
-159
@@ -2,7 +2,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Save, Trash2, Send, Plus, Clock, X } from 'lucide-react';
|
import { ArrowLeft, Save, Trash2, Send, Plus, Clock, X, Sparkles, RefreshCw, BarChart2 } from 'lucide-react';
|
||||||
|
import ArticlePicker from './ArticlePicker';
|
||||||
|
import AutoPublishTab from './AutoPublishTab';
|
||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
{ value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' },
|
{ value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' },
|
||||||
@@ -11,12 +13,13 @@ const PLATFORMS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'settings', label: 'Настройки' },
|
{ id: 'settings', label: 'Настройки' },
|
||||||
{ id: 'schedule', label: 'Расписание' },
|
{ id: 'schedule', label: 'Расписание' },
|
||||||
{ id: 'publish', label: 'Публикация' },
|
{ id: 'autopublish', label: 'Авто-публикация' },
|
||||||
|
{ id: 'publish', label: 'Ручная публикация' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ChannelEditor({ channel, articles = [] }) {
|
export default function ChannelEditor({ channel }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isNew = !channel;
|
const isNew = !channel;
|
||||||
|
|
||||||
@@ -31,9 +34,11 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
const [maxToken, setMaxToken] = useState(channel?.max_access_token || '');
|
const [maxToken, setMaxToken] = useState(channel?.max_access_token || '');
|
||||||
const [isActive, setIsActive] = useState(channel?.is_active ?? true);
|
const [isActive, setIsActive] = useState(channel?.is_active ?? true);
|
||||||
|
|
||||||
// Публикация
|
// Ручная публикация: либо статья, либо custom_text. Опционально — на конкретное время (или сейчас).
|
||||||
const [selectedArticle, setSelectedArticle] = useState('');
|
const [pickedArticle, setPickedArticle] = useState(null);
|
||||||
const [customText, setCustomText] = useState('');
|
const [customText, setCustomText] = useState('');
|
||||||
|
const [publishMode, setPublishMode] = useState('now'); // 'now' | 'schedule'
|
||||||
|
const [scheduleAt, setScheduleAt] = useState('');
|
||||||
const [publishing, setPublishing] = useState(false);
|
const [publishing, setPublishing] = useState(false);
|
||||||
const [publishResult, setPublishResult] = useState(null);
|
const [publishResult, setPublishResult] = useState(null);
|
||||||
|
|
||||||
@@ -42,19 +47,33 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
const [toast, setToast] = useState(null);
|
const [toast, setToast] = useState(null);
|
||||||
const [activeTab, setActiveTab] = useState('settings');
|
const [activeTab, setActiveTab] = useState('settings');
|
||||||
|
|
||||||
// Слоты расписания
|
// Слоты
|
||||||
const [slots, setSlots] = useState([]);
|
const [slots, setSlots] = useState([]);
|
||||||
const [newSlotH, setNewSlotH] = useState(8);
|
const [newSlotH, setNewSlotH] = useState(8);
|
||||||
const [newSlotM, setNewSlotM] = useState(0);
|
const [newSlotM, setNewSlotM] = useState(0);
|
||||||
const [addingSlot, setAddingSlot] = useState(false);
|
const [addingSlot, setAddingSlot] = useState(false);
|
||||||
|
|
||||||
// Загружаем слоты
|
// Очередь канала
|
||||||
|
const [queue, setQueue] = useState([]);
|
||||||
|
const [loadingQueue, setLoadingQueue] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!channel?.id) return;
|
if (!channel?.id) return;
|
||||||
fetch(`/admin/api/channels/${channel.id}/slots`)
|
fetch(`/admin/api/channels/${channel.id}/slots`).then(r => r.json()).then(setSlots).catch(() => {});
|
||||||
.then(r => r.json()).then(setSlots).catch(() => {});
|
loadQueue();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [channel?.id]);
|
}, [channel?.id]);
|
||||||
|
|
||||||
|
async function loadQueue() {
|
||||||
|
if (!channel?.id) return;
|
||||||
|
setLoadingQueue(true);
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/admin/api/channels/${channel.id}/scheduled`);
|
||||||
|
const d = await r.json();
|
||||||
|
if (Array.isArray(d)) setQueue(d);
|
||||||
|
} catch {} finally { setLoadingQueue(false); }
|
||||||
|
}
|
||||||
|
|
||||||
function showToast(msg, type = 'success') {
|
function showToast(msg, type = 'success') {
|
||||||
setToast({ msg, type });
|
setToast({ msg, type });
|
||||||
setTimeout(() => setToast(null), 4000);
|
setTimeout(() => setToast(null), 4000);
|
||||||
@@ -105,27 +124,43 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function doPublish() {
|
||||||
if (!selectedArticle && !customText.trim()) {
|
if (!pickedArticle && !customText.trim()) {
|
||||||
return showToast('Выберите статью или введите текст', 'error');
|
return showToast('Выберите статью или введите текст', 'error');
|
||||||
}
|
}
|
||||||
setPublishing(true);
|
setPublishing(true);
|
||||||
setPublishResult(null);
|
setPublishResult(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/admin/api/channels/${channel.id}/publish`, {
|
const isSchedule = publishMode === 'schedule';
|
||||||
|
if (isSchedule && !scheduleAt) {
|
||||||
|
throw new Error('Укажите время публикации');
|
||||||
|
}
|
||||||
|
const url = isSchedule
|
||||||
|
? `/admin/api/channels/${channel.id}/scheduled`
|
||||||
|
: `/admin/api/channels/${channel.id}/publish`;
|
||||||
|
const body = isSchedule
|
||||||
|
? {
|
||||||
|
article_id: pickedArticle?.id,
|
||||||
|
custom_text: customText.trim() || undefined,
|
||||||
|
scheduled_at: new Date(scheduleAt).toISOString(),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
article_id: pickedArticle?.id,
|
||||||
|
custom_text: customText.trim() || undefined,
|
||||||
|
};
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
article_id: selectedArticle ? parseInt(selectedArticle) : undefined,
|
|
||||||
custom_text: customText.trim() || undefined,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.error || 'Ошибка публикации');
|
if (!res.ok) throw new Error(data.error || 'Ошибка публикации');
|
||||||
setPublishResult({ ok: true, data });
|
setPublishResult({ ok: true, data, scheduled: isSchedule });
|
||||||
showToast('Опубликовано!');
|
showToast(isSchedule ? `Запланировано на ${new Date(scheduleAt).toLocaleString('ru-RU')}` : 'Опубликовано!');
|
||||||
setSelectedArticle('');
|
setPickedArticle(null);
|
||||||
setCustomText('');
|
setCustomText('');
|
||||||
|
setScheduleAt('');
|
||||||
|
loadQueue();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setPublishResult({ ok: false, error: e.message });
|
setPublishResult({ ok: false, error: e.message });
|
||||||
showToast(e.message.slice(0, 120), 'error');
|
showToast(e.message.slice(0, 120), 'error');
|
||||||
@@ -134,13 +169,14 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Автозаполнение текста при выборе статьи
|
async function cancelScheduled(id) {
|
||||||
function onArticleSelect(artId) {
|
if (!confirm('Отменить эту запланированную публикацию?')) return;
|
||||||
setSelectedArticle(artId);
|
try {
|
||||||
if (!artId) { setCustomText(''); return; }
|
await fetch(`/admin/api/scheduled/${id}`, { method: 'DELETE' });
|
||||||
const art = articles.find(a => String(a.id) === artId);
|
loadQueue();
|
||||||
if (art) {
|
showToast('Отменено');
|
||||||
setCustomText(`${art.title}\n\n${art.excerpt || ''}\n\nhttps://zeropost.ru/blog/${art.slug}`);
|
} catch (e) {
|
||||||
|
showToast(e.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,9 +202,14 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
setSlots(s => s.filter(sl => sl.id !== slotId));
|
setSlots(s => s.filter(sl => sl.id !== slotId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
pending: { text: 'В очереди', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-300' },
|
||||||
|
sent: { text: 'Отправлено', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300' },
|
||||||
|
failed: { text: 'Ошибка', cls: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300' },
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
{/* Toast */}
|
|
||||||
{toast && (
|
{toast && (
|
||||||
<div className={`fixed top-4 right-4 z-50 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg ${
|
<div className={`fixed top-4 right-4 z-50 px-4 py-2.5 rounded-xl text-sm font-medium shadow-lg ${
|
||||||
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
|
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
|
||||||
@@ -186,8 +227,21 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
<h1 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
|
<h1 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||||
{isNew ? 'Новый канал' : channel.name}
|
{isNew ? 'Новый канал' : channel.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
{!isNew && channel.auto_publish_enabled && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300">
|
||||||
|
<Sparkles className="w-3 h-3" />
|
||||||
|
auto
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!isNew && (
|
||||||
|
<Link href={`/admin/channels/${channel.id}/stats`}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors">
|
||||||
|
<BarChart2 className="w-4 h-4" />
|
||||||
|
Статистика
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<button onClick={deleteChannel} disabled={deleting}
|
<button onClick={deleteChannel} disabled={deleting}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition-colors disabled:opacity-50">
|
className="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-red-200 dark:border-red-900 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950 transition-colors disabled:opacity-50">
|
||||||
@@ -195,20 +249,21 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
{deleting ? 'Удаление...' : 'Удалить'}
|
{deleting ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={save} disabled={saving}
|
{(isNew || activeTab === 'settings') && (
|
||||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
|
<button onClick={save} disabled={saving}
|
||||||
<Save className="w-4 h-4" />
|
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
|
||||||
{saving ? 'Сохранение...' : 'Сохранить'}
|
<Save className="w-4 h-4" />
|
||||||
</button>
|
{saving ? 'Сохранение...' : 'Сохранить'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Вкладки — только для существующего канала */}
|
|
||||||
{!isNew && (
|
{!isNew && (
|
||||||
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-800">
|
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-800 overflow-x-auto">
|
||||||
{TABS.map(tab => (
|
{TABS.map(tab => (
|
||||||
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
|
||||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
|
||||||
: 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
: 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
|
||||||
@@ -222,115 +277,104 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
{/* Вкладка: Настройки */}
|
{/* Вкладка: Настройки */}
|
||||||
{(isNew || activeTab === 'settings') && (
|
{(isNew || activeTab === 'settings') && (
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
|
||||||
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Основное</h2>
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Основное</h2>
|
||||||
|
|
||||||
{/* Платформа */}
|
<div>
|
||||||
<div>
|
<label className="block text-xs font-medium text-neutral-500 mb-2">Платформа</label>
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-2">Платформа</label>
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<div className="grid grid-cols-3 gap-2">
|
{PLATFORMS.map(p => (
|
||||||
{PLATFORMS.map(p => (
|
<button key={p.value} onClick={() => setPlatform(p.value)}
|
||||||
<button
|
className={`p-3 rounded-lg border text-left transition-all ${
|
||||||
key={p.value}
|
platform === p.value
|
||||||
onClick={() => setPlatform(p.value)}
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950'
|
||||||
className={`p-3 rounded-lg border text-left transition-all ${
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300'
|
||||||
platform === p.value
|
}`}>
|
||||||
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950'
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{p.label}</div>
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300'
|
<div className="text-xs text-neutral-400 mt-0.5">{p.desc}</div>
|
||||||
}`}
|
</button>
|
||||||
>
|
))}
|
||||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{p.label}</div>
|
|
||||||
<div className="text-xs text-neutral-400 mt-0.5">{p.desc}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Название */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Название канала</label>
|
|
||||||
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
||||||
placeholder="ZeroPost TG" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Поля Telegram */}
|
|
||||||
{platform === 'telegram' && (
|
|
||||||
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
|
||||||
<p className="text-xs text-neutral-400">
|
|
||||||
Создайте бота через <a href="https://t.me/BotFather" target="_blank" className="text-blue-500 hover:underline">@BotFather</a>, добавьте его в канал как администратора.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Bot Token</label>
|
|
||||||
<input type="password" value={botToken} onChange={e => setBotToken(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
||||||
placeholder="123456789:AABBccdd..." />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Название канала</label>
|
||||||
|
<input type="text" value={name} onChange={e => setName(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
placeholder="ZeroPost TG" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{platform === 'telegram' && (
|
||||||
|
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
Создайте бота через <a href="https://t.me/BotFather" target="_blank" className="text-blue-500 hover:underline">@BotFather</a>, добавьте его в канал как администратора.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Bot Token</label>
|
||||||
|
<input type="password" value={botToken} onChange={e => setBotToken(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
placeholder="123456789:AABBccdd..." />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Channel ID</label>
|
||||||
|
<input type="text" value={tgChannelId} onChange={e => setTgChannelId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
placeholder="-100123456789" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Username (опц.)</label>
|
||||||
|
<input type="text" value={tgUsername} onChange={e => setTgUsername(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
placeholder="@zeropost_ru" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{platform === 'vk' && (
|
||||||
|
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
|
<p className="text-xs text-neutral-400">
|
||||||
|
Получите токен через <a href="https://vk.com/dev/implicit_flow_user" target="_blank" className="text-blue-500 hover:underline">VK API</a> с правами wall, photos.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">ID группы</label>
|
||||||
|
<input type="text" value={vkGroupId} onChange={e => setVkGroupId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
placeholder="123456789" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Access Token</label>
|
||||||
|
<input type="password" value={vkToken} onChange={e => setVkToken(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
placeholder="vk1.a.xxx..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{platform === 'max' && (
|
||||||
|
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
||||||
|
<p className="text-xs text-neutral-400">Max — публикация через Bot API.</p>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Channel ID</label>
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Channel ID</label>
|
||||||
<input type="text" value={tgChannelId} onChange={e => setTgChannelId(e.target.value)}
|
<input type="text" value={maxChannelId} onChange={e => setMaxChannelId(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
placeholder="-100123456789" />
|
placeholder="channel_id" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Username (опц.)</label>
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Access Token</label>
|
||||||
<input type="text" value={tgUsername} onChange={e => setTgUsername(e.target.value)}
|
<input type="password" value={maxToken} onChange={e => setMaxToken(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
placeholder="@zeropost_ru" />
|
placeholder="token..." />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Поля ВКонтакте */}
|
<div className="flex items-center gap-2 pt-2">
|
||||||
{platform === 'vk' && (
|
<input type="checkbox" id="isActive" checked={isActive} onChange={e => setIsActive(e.target.checked)}
|
||||||
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
className="w-4 h-4 rounded accent-emerald-500" />
|
||||||
<p className="text-xs text-neutral-400">
|
<label htmlFor="isActive" className="text-sm text-neutral-700 dark:text-neutral-300">Канал активен</label>
|
||||||
Получите токен через <a href="https://vk.com/dev/implicit_flow_user" target="_blank" className="text-blue-500 hover:underline">VK API</a> с правами wall, photos.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">ID группы</label>
|
|
||||||
<input type="text" value={vkGroupId} onChange={e => setVkGroupId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
||||||
placeholder="123456789" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Access Token</label>
|
|
||||||
<input type="password" value={vkToken} onChange={e => setVkToken(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
||||||
placeholder="vk1.a.xxx..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Поля Max */}
|
|
||||||
{platform === 'max' && (
|
|
||||||
<div className="space-y-3 pt-2 border-t border-neutral-100 dark:border-neutral-800">
|
|
||||||
<p className="text-xs text-neutral-400">
|
|
||||||
Max (бывший ОК) — публикация через Bot API.
|
|
||||||
</p>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Channel ID</label>
|
|
||||||
<input type="text" value={maxChannelId} onChange={e => setMaxChannelId(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
||||||
placeholder="channel_id" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Access Token</label>
|
|
||||||
<input type="password" value={maxToken} onChange={e => setMaxToken(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
|
||||||
placeholder="token..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Активность */}
|
|
||||||
<div className="flex items-center gap-2 pt-2">
|
|
||||||
<input type="checkbox" id="isActive" checked={isActive} onChange={e => setIsActive(e.target.checked)}
|
|
||||||
className="w-4 h-4 rounded accent-emerald-500" />
|
|
||||||
<label htmlFor="isActive" className="text-sm text-neutral-700 dark:text-neutral-300">Канал активен</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Вкладка: Расписание */}
|
{/* Вкладка: Расписание */}
|
||||||
@@ -338,9 +382,8 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-1">Слоты публикации</h2>
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-1">Слоты публикации</h2>
|
||||||
<p className="text-xs text-neutral-400 mb-5">Время когда будут отправляться запланированные посты. Добавьте несколько слотов — посты распределятся по ним автоматически.</p>
|
<p className="text-xs text-neutral-400 mb-5">Время когда выходят запланированные посты. При автопубликации новая статья ставится на ближайший свободный слот.</p>
|
||||||
|
|
||||||
{/* Список слотов */}
|
|
||||||
<div className="space-y-2 mb-5">
|
<div className="space-y-2 mb-5">
|
||||||
{slots.length === 0 && (
|
{slots.length === 0 && (
|
||||||
<p className="text-sm text-neutral-400 text-center py-4">Слотов пока нет — добавьте время публикации</p>
|
<p className="text-sm text-neutral-400 text-center py-4">Слотов пока нет — добавьте время публикации</p>
|
||||||
@@ -360,7 +403,6 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Добавить слот */}
|
|
||||||
<div className="border-t border-neutral-100 dark:border-neutral-800 pt-4">
|
<div className="border-t border-neutral-100 dark:border-neutral-800 pt-4">
|
||||||
<p className="text-xs font-medium text-neutral-500 mb-3">Добавить слот</p>
|
<p className="text-xs font-medium text-neutral-500 mb-3">Добавить слот</p>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -385,47 +427,116 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
Добавить
|
Добавить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-400 mt-2">
|
|
||||||
Совет: используй не круглые числа (8:06, 11:23) — так посты выглядят органичнее в ленте
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Подсказка */}
|
{/* Очередь канала */}
|
||||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-xl p-4 text-sm text-blue-700 dark:text-blue-300">
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5">
|
||||||
<p className="font-medium mb-1">Как работает расписание</p>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<p className="text-xs opacity-80">Когда ты ставишь пост в очередь на вкладке "Публикация" — он автоматически назначается на ближайший свободный слот. Если добавить 4 слота, посты будут выходить 4 раза в день.</p>
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Очередь канала</h2>
|
||||||
|
<button onClick={loadQueue} className="text-xs text-neutral-500 hover:text-emerald-600 inline-flex items-center gap-1">
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${loadingQueue ? 'animate-spin' : ''}`} /> Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{queue.length === 0 ? (
|
||||||
|
<p className="text-sm text-neutral-400 text-center py-4">Очередь пуста</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{queue.map(q => {
|
||||||
|
const label = STATUS_LABELS[q.status] || STATUS_LABELS.pending;
|
||||||
|
return (
|
||||||
|
<div key={q.id} className="flex items-start gap-3 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100 truncate">
|
||||||
|
{q.article_title || (q.custom_text?.slice(0, 60) + '...') || `Пост #${q.id}`}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-xs text-neutral-500 flex-wrap">
|
||||||
|
<span className={`px-1.5 py-0.5 rounded font-medium ${label.cls}`}>{label.text}</span>
|
||||||
|
<span>{new Date(q.scheduled_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}</span>
|
||||||
|
{q.article_category && <span className="text-neutral-400">[{q.article_category}]</span>}
|
||||||
|
{q.error && <span className="text-red-500 truncate">{q.error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{q.status === 'pending' && (
|
||||||
|
<button onClick={() => cancelScheduled(q.id)}
|
||||||
|
className="text-xs text-neutral-400 hover:text-red-500 px-2 py-1">
|
||||||
|
отменить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Вкладка: Публикация */}
|
{/* Вкладка: Авто-публикация */}
|
||||||
|
{!isNew && activeTab === 'autopublish' && (
|
||||||
|
<AutoPublishTab channel={channel} onSaved={() => router.refresh()} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Вкладка: Ручная публикация */}
|
||||||
{!isNew && activeTab === 'publish' && (
|
{!isNew && activeTab === 'publish' && (
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
|
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800 p-5 space-y-4">
|
||||||
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Опубликовать</h2>
|
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Опубликовать</h2>
|
||||||
|
|
||||||
{/* Выбор статьи */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Выбрать статью</label>
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Выбрать статью</label>
|
||||||
<select value={selectedArticle} onChange={e => onArticleSelect(e.target.value)}
|
<ArticlePicker
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500">
|
value={pickedArticle?.id}
|
||||||
<option value="">— выбрать статью —</option>
|
onChange={a => {
|
||||||
{articles.map(a => (
|
setPickedArticle(a);
|
||||||
<option key={a.id} value={a.id}>{a.title}</option>
|
if (a) setCustomText(''); // когда выбрана статья — текст рендерит шаблон канала
|
||||||
))}
|
}}
|
||||||
</select>
|
channelId={channel.id}
|
||||||
|
placeholder="Найти статью (поиск по названию)…"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Текст поста */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Текст поста</label>
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">
|
||||||
|
Или произвольный текст {pickedArticle && <span className="text-neutral-400">(если задан — перебивает шаблон статьи)</span>}
|
||||||
|
</label>
|
||||||
<textarea value={customText} onChange={e => setCustomText(e.target.value)} rows={6}
|
<textarea value={customText} onChange={e => setCustomText(e.target.value)} rows={6}
|
||||||
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y font-mono"
|
className="w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm text-neutral-900 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500 resize-y font-mono"
|
||||||
placeholder="Текст поста (Markdown для Telegram)..." />
|
placeholder="Markdown для Telegram. Если пусто и выбрана статья — текст возьмётся из шаблона авто-публикации канала." />
|
||||||
<div className="text-xs text-neutral-300 text-right mt-0.5">{customText.length} символов</div>
|
<div className="text-xs text-neutral-300 text-right mt-0.5">{customText.length} символов</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Результат */}
|
{/* Режим */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-500 mb-1.5">Когда</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => setPublishMode('now')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm border transition-all ${
|
||||||
|
publishMode === 'now'
|
||||||
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600'
|
||||||
|
}`}>
|
||||||
|
<Send className="w-3.5 h-3.5 inline mr-1" /> Сейчас
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setPublishMode('schedule')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-sm border transition-all ${
|
||||||
|
publishMode === 'schedule'
|
||||||
|
? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300'
|
||||||
|
: 'border-neutral-200 dark:border-neutral-700 text-neutral-600'
|
||||||
|
}`}>
|
||||||
|
<Clock className="w-3.5 h-3.5 inline mr-1" /> Запланировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{publishMode === 'schedule' && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={scheduleAt}
|
||||||
|
onChange={e => setScheduleAt(e.target.value)}
|
||||||
|
min={new Date(Date.now() + 60000).toISOString().slice(0, 16)}
|
||||||
|
className="mt-2 w-full px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-stone-50 dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{publishResult && (
|
{publishResult && (
|
||||||
<div className={`rounded-lg px-4 py-3 text-sm ${
|
<div className={`rounded-lg px-4 py-3 text-sm ${
|
||||||
publishResult.ok
|
publishResult.ok
|
||||||
@@ -433,17 +544,20 @@ export default function ChannelEditor({ channel, articles = [] }) {
|
|||||||
: 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300'
|
: 'bg-red-50 dark:bg-red-950 text-red-700 dark:text-red-300'
|
||||||
}`}>
|
}`}>
|
||||||
{publishResult.ok ? (
|
{publishResult.ok ? (
|
||||||
<span>✓ Опубликовано{publishResult.data?.tg_message_id ? ` (message_id: ${publishResult.data.tg_message_id})` : ''}</span>
|
<span>
|
||||||
|
✓ {publishResult.scheduled ? 'Запланировано' : 'Опубликовано'}
|
||||||
|
{publishResult.data?.tg_message_id ? ` (message_id: ${publishResult.data.tg_message_id})` : ''}
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>✗ {publishResult.error}</span>
|
<span>✗ {publishResult.error}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button onClick={publish} disabled={publishing || (!selectedArticle && !customText.trim())}
|
<button onClick={doPublish} disabled={publishing || (!pickedArticle && !customText.trim()) || (publishMode === 'schedule' && !scheduleAt)}
|
||||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
|
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-lg bg-blue-500 hover:bg-blue-600 disabled:opacity-50 text-white text-sm font-medium transition-colors">
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
{publishing ? 'Публикация...' : `Опубликовать в ${PLATFORMS.find(p=>p.value===channel.platform)?.label || 'канал'}`}
|
{publishing ? '…' : (publishMode === 'schedule' ? 'Поставить в очередь' : `Опубликовать в ${PLATFORMS.find(p=>p.value===channel.platform)?.label || 'канал'}`)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -154,3 +154,71 @@ export async function adminGetChannelPosts(channelId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// ── Admin Settings API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function adminListSettings() {
|
||||||
|
return call('/api/settings/admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminUpdateSetting(key, value) {
|
||||||
|
return call(`/api/settings/admin/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ value }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Articles search (typeahead) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function adminSearchArticles({ q = '', status = 'published', category = '', channelId = null, limit = 20 } = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (q) params.set('q', q);
|
||||||
|
if (status) params.set('status', status);
|
||||||
|
if (category) params.set('category', category);
|
||||||
|
if (channelId)params.set('channel_id', String(channelId));
|
||||||
|
params.set('limit', String(limit));
|
||||||
|
return call(`/api/articles/admin/search?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin Scheduled posts ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function adminGetScheduledQueue(channelId = null) {
|
||||||
|
if (channelId) return call(`/api/channels/admin/${channelId}/scheduled`);
|
||||||
|
return call('/api/scheduled-posts/queue');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminScheduleArticle(channelId, { article_id, custom_text, scheduled_at } = {}) {
|
||||||
|
return call(`/api/channels/admin/${channelId}/schedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ article_id, custom_text, scheduled_at }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminCancelScheduled(scheduledPostId) {
|
||||||
|
return call(`/api/scheduled-posts/${scheduledPostId}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminPreviewTemplate({ article_id, template }) {
|
||||||
|
return call('/api/scheduled-posts/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ article_id, template }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminRequeueArticle(articleId) {
|
||||||
|
return call(`/api/scheduled-posts/schedule-article/${articleId}`, { method: 'POST' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главная страница — собранный набор секций
|
||||||
|
export async function getHomeData() {
|
||||||
|
return call('/api/articles/home');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel stats ─────────────────────────────────────────────────────────────
|
||||||
|
export async function getChannelSummary(channelId) {
|
||||||
|
return call(`/api/channel-stats/${channelId}/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannelHistory(channelId, days = 30) {
|
||||||
|
return call(`/api/channel-stats/${channelId}/history?days=${days}`);
|
||||||
|
}
|
||||||
|
|||||||
Generated
+407
@@ -18,6 +18,7 @@
|
|||||||
"postcss": "8.4.39",
|
"postcss": "8.4.39",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"tailwindcss": "3.4.7"
|
"tailwindcss": "3.4.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -713,6 +714,54 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit": {
|
||||||
|
"version": "2.12.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||||
|
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@standard-schema/spec": "^1.0.0",
|
||||||
|
"@standard-schema/utils": "^0.3.0",
|
||||||
|
"immer": "^11.0.0",
|
||||||
|
"redux": "^5.0.1",
|
||||||
|
"redux-thunk": "^3.1.0",
|
||||||
|
"reselect": "^5.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||||
|
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||||
|
"version": "11.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
|
||||||
|
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/spec": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@standard-schema/utils": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -737,6 +786,75 @@
|
|||||||
"tailwindcss": ">=3.0.0 || insiders"
|
"tailwindcss": ">=3.0.0 || insiders"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-array": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-color": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-ease": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-interpolate": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-color": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-path": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-scale": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-time": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-shape": {
|
||||||
|
"version": "3.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
|
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-path": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-time": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-timer": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/use-sync-external-store": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/any-promise": {
|
"node_modules/any-promise": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||||
@@ -948,6 +1066,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/commander": {
|
"node_modules/commander": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
|
||||||
@@ -969,6 +1096,133 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-format": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-path": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-scale": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.10.0 - 3",
|
||||||
|
"d3-format": "1 - 3",
|
||||||
|
"d3-interpolate": "1.2.0 - 3",
|
||||||
|
"d3-time": "2.1.1 - 3",
|
||||||
|
"d3-time-format": "2 - 4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-shape": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-path": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-time-format": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-time": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/decimal.js-light": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -1006,6 +1260,16 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es-toolkit": {
|
||||||
|
"version": "1.47.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz",
|
||||||
|
"integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"docs",
|
||||||
|
"benchmarks"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/escalade": {
|
"node_modules/escalade": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||||
@@ -1028,6 +1292,12 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/extend-shallow": {
|
"node_modules/extend-shallow": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
|
||||||
@@ -1164,6 +1434,25 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "10.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||||
|
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
@@ -1830,6 +2119,36 @@
|
|||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-is": {
|
||||||
|
"version": "19.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz",
|
||||||
|
"integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/react-redux": {
|
||||||
|
"version": "9.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||||
|
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^18.2.25 || ^19",
|
||||||
|
"react": "^18.0 || ^19",
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redux": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -1851,6 +2170,57 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/recharts": {
|
||||||
|
"version": "3.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||||
|
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"www"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"decimal.js-light": "^2.5.1",
|
||||||
|
"es-toolkit": "^1.39.3",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"immer": "^10.1.1",
|
||||||
|
"react-redux": "8.x.x || 9.x.x",
|
||||||
|
"reselect": "5.1.1",
|
||||||
|
"tiny-invariant": "^1.3.3",
|
||||||
|
"use-sync-external-store": "^1.2.2",
|
||||||
|
"victory-vendor": "^37.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/redux-thunk": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"redux": "^5.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/reselect": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.12",
|
"version": "1.22.12",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||||
@@ -2190,6 +2560,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-invariant": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -2289,12 +2665,43 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"browserslist": ">= 4.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/util-deprecate": {
|
"node_modules/util-deprecate": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/victory-vendor": {
|
||||||
|
"version": "37.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||||
|
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||||
|
"license": "MIT AND ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-array": "^3.0.3",
|
||||||
|
"@types/d3-ease": "^3.0.0",
|
||||||
|
"@types/d3-interpolate": "^3.0.1",
|
||||||
|
"@types/d3-scale": "^4.0.2",
|
||||||
|
"@types/d3-shape": "^3.1.0",
|
||||||
|
"@types/d3-time": "^3.0.0",
|
||||||
|
"@types/d3-timer": "^3.0.0",
|
||||||
|
"d3-array": "^3.1.6",
|
||||||
|
"d3-ease": "^3.0.1",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-scale": "^4.0.2",
|
||||||
|
"d3-shape": "^3.1.0",
|
||||||
|
"d3-time": "^3.0.0",
|
||||||
|
"d3-timer": "^3.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
+9
-8
@@ -8,16 +8,17 @@
|
|||||||
"start": "next start -p 3042"
|
"start": "next start -p 3042"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tailwindcss/typography": "0.5.13",
|
||||||
|
"autoprefixer": "10.4.19",
|
||||||
|
"gray-matter": "4.0.3",
|
||||||
|
"lucide-react": "0.408.0",
|
||||||
|
"marked": "13.0.2",
|
||||||
"next": "^16.2.6",
|
"next": "^16.2.6",
|
||||||
|
"pg": "^8.21.0",
|
||||||
|
"postcss": "8.4.39",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"lucide-react": "0.408.0",
|
"recharts": "^3.8.1",
|
||||||
"pg": "^8.21.0",
|
"tailwindcss": "3.4.7"
|
||||||
"tailwindcss": "3.4.7",
|
|
||||||
"autoprefixer": "10.4.19",
|
|
||||||
"postcss": "8.4.39",
|
|
||||||
"@tailwindcss/typography": "0.5.13",
|
|
||||||
"marked": "13.0.2",
|
|
||||||
"gray-matter": "4.0.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user