+
setOpen(false)} className="text-2xl font-semibold ink py-3 border-b-soft">
+ Главная
+
+
+
Темы
+
+ setOpen(false)} href="/category/ai-tools" className="text-base font-medium py-2">🤖 AI Tools
+ setOpen(false)} href="/category/ai-dev" className="text-base font-medium py-2">💻 AI Dev
+ setOpen(false)} href="/category/automation" className="text-base font-medium py-2">⚡ Automation
+ setOpen(false)} href="/category/cybersec" className="text-base font-medium py-2">🔒 Cybersec
+
+
+
setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">Серии
+
setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">Заметки
+
setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">Архив
+
setOpen(false)} className="text-base font-medium ink py-3 border-b-soft">О проекте
+
Блог, который ведёт ИИ — а человек только следит за курсом.
diff --git a/components/PopularBlock.js b/components/PopularBlock.js
new file mode 100644
index 0000000..8b7ce68
--- /dev/null
+++ b/components/PopularBlock.js
@@ -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 (
+
+
+
+
+ Популярное за месяц
+
+
+
+ {articles.map(a =>
)}
+
+
+ );
+}
diff --git a/components/RecentBlock.js b/components/RecentBlock.js
new file mode 100644
index 0000000..f3f2fe6
--- /dev/null
+++ b/components/RecentBlock.js
@@ -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 (
+
+
+
Свежие материалы
+
+ Архив
+
+
+
+ {nonEmpty.map(([key, arr]) => (
+
+
+ {LABELS[key]} · {arr.length}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/components/admin/AdminNav.js b/components/admin/AdminNav.js
index 1189193..ee4a9b9 100644
--- a/components/admin/AdminNav.js
+++ b/components/admin/AdminNav.js
@@ -1,13 +1,14 @@
'use client';
import Link from 'next/link';
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 = [
{ href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true },
{ href: '/admin/articles', label: 'Статьи', icon: FileText },
{ href: '/admin/channels', label: 'Каналы', icon: Radio },
{ href: '/admin/autogen', label: 'Автогенерация', icon: Zap },
+ { href: '/admin/settings', label: 'Настройки', icon: Settings },
];
export default function AdminNav() {
diff --git a/components/admin/ArticlePicker.js b/components/admin/ArticlePicker.js
new file mode 100644
index 0000000..4b39bc9
--- /dev/null
+++ b/components/admin/ArticlePicker.js
@@ -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 (
+
+ {/* Selected chip */}
+ {selected && (
+
+ {selected.cover_url ? (
+
+ ) : (
+
+
+
+ )}
+
+
{selected.title}
+
+
+ {CATEGORY_LABELS[selected.category] || selected.category}
+
+
{fmtDate(selected.published_at)}
+ {selected.was_sent_to_channel && (
+
+ уже было {fmtTime(selected.was_sent_to_channel)}
+
+ )}
+ {selected.next_scheduled_at && (
+
+ в очереди на {fmtTime(selected.next_scheduled_at)}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {/* Search input */}
+
+
+ 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 && (
+
+ )}
+
+
+ {/* Dropdown */}
+ {open && (
+
+ {error && (
+
{error}
+ )}
+ {!error && !loading && items.length === 0 && (
+
+ {query ? `Ничего не найдено по «${query}»` : 'Нет статей'}
+
+ )}
+ {items.map(a => (
+
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 ? (
+
+ ) : (
+
+
+
+ )}
+
+
{a.title}
+
+
+ {CATEGORY_LABELS[a.category] || a.category}
+
+
{fmtDate(a.published_at)}
+ {a.was_sent_to_channel && (
+
+ уже было
+
+ )}
+ {a.next_scheduled_at && (
+
+ в очереди
+
+ )}
+ {selected?.id === a.id && (
+
+ )}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/components/admin/AutoPublishTab.js b/components/admin/AutoPublishTab.js
new file mode 100644
index 0000000..9d11f9a
--- /dev/null
+++ b/components/admin/AutoPublishTab.js
@@ -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 (
+
+ {/* Toggle */}
+
+
+
+
+
+ Автоматическая публикация статей
+
+
+ Как только статья получает статус published,
+ она автоматически попадает в очередь публикации в этот канал.
+
+
+
+ setEnabled(e.target.checked)}
+ className="w-4 h-4 rounded accent-emerald-500"
+ />
+ {enabled ? 'Включено' : 'Выключено'}
+
+
+
+
+ {/* Категории */}
+
+
Категории статей
+
+ Если ничего не выбрано — публикуется всё. Иначе только статьи из выбранных категорий.
+
+
+ {CATEGORIES.map(c => {
+ const on = categories.includes(c.slug);
+ return (
+ 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}
+
+ );
+ })}
+
+
+
+ {/* Задержка + cover */}
+
+
+
+ Задержка (мин)
+
+
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"
+ />
+
+ 0 = ближайший слот канала. 30 = через полчаса.
+
+
+
+
Картинка к посту
+
+ setWithCover(e.target.checked)}
+ className="w-4 h-4 rounded accent-emerald-500"
+ />
+ Прикреплять обложку статьи
+
+
+ TG: ограничение caption — 1024 символа.
+
+
+
+
+ {/* Шаблон */}
+
+
Шаблон поста
+
+ Markdown для Telegram. Если пусто — используется дефолт:
+ {DEFAULT_TEMPLATE.replaceAll('\n', '↵')}
+
+
+
+ {/* Preview */}
+
+
+
+ Превью на свежей статье
+
+ {previewArticleTitle && (
+
+ Пример: {previewArticleTitle}
+
+ )}
+
+ {loadingPreview ? (
+
+
+
+ ) : preview?.error ? (
+
{preview.error}
+ ) : preview ? (
+
+ {withCover && preview.cover_url && (
+
+ )}
+
+ {preview.text}
+
+
+ 1024 && withCover ? 'text-amber-500' : 'text-neutral-400'}>
+ {preview.length} символов{preview.length > 1024 && withCover && ' (caption обрежется до 1024)'}
+
+ 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"
+ >
+ Другая статья
+
+
+
+ ) : (
+
Нет статей для превью
+ )}
+
+ {captionWarning && (
+
+
+
Текст длиннее 1024 — Telegram обрежет caption у фото. Уменьши шаблон или сними «Прикреплять обложку».
+
+ )}
+
+
+ {/* Save */}
+
+
+ {saving ? : }
+ {saving ? 'Сохранение…' : 'Сохранить настройки автопубликации'}
+
+ {savedToast && ✓ Сохранено }
+ {err && {err} }
+
+
+ );
+}
diff --git a/components/admin/AutogenPanel.js b/components/admin/AutogenPanel.js
index f34b6e2..d7b4e1d 100644
--- a/components/admin/AutogenPanel.js
+++ b/components/admin/AutogenPanel.js
@@ -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',
};
+/**
+ * Рассчитать реальное время следующего запуска автогенерации.
+ *
+ * Логика автогена (см. 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 }) {
const router = useRouter();
const [running, setRunning] = useState({});
@@ -122,11 +168,9 @@ export default function AutogenPanel({ status, queue, topics }) {
}
const pendingQueue = queue.filter(q => q.status === 'pending');
- const doneQueue = queue.filter(q => q.status === 'done').slice(0, 5);
return (
- {/* Toast */}
{toast && (
)}
- {/* Шапка */}
Автогенерация
-
Автоматическое создание статей по расписанию
+
Автоматическое создание статей по расписанию (MSK)
- {/* Категории */}
{status.map(s => {
const cat = CAT_LABELS[s.category] || { name: s.category, icon: '📝', color: 'emerald' };
const colorCls = COLOR[cat.color] || COLOR.emerald;
const isRunning = running[s.category] || running.all;
+ const nextRun = calcNextRun(s);
return (
@@ -167,7 +210,6 @@ export default function AutogenPanel({ status, queue, topics }) {
{s.article_count || 0} статей · {s.queue_count || 0} в очереди
- {/* Вкл/выкл */}
toggleCategory(s.category, !s.enabled)}
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 => {n} )}
-
+
+
-
Время запуска:
-
updateTime(s.category, e.target.value, s.run_minute ?? 0)}
- className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
- >
- {Array.from({length: 24}, (_,i) => (
- {String(i).padStart(2,'0')}:__
- ))}
-
-
updateTime(s.category, s.run_hour ?? 8, e.target.value)}
- className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
- >
- {[0,5,10,15,20,25,30,35,40,45,50,55].map(m => (
- __:{String(m).padStart(2,'0')}
- ))}
-
- {s.next_run_at && (
-
- след.: {new Date(s.next_run_at).toLocaleString('ru-RU', {day:'numeric',month:'short',hour:'2-digit',minute:'2-digit'})}
-
+
Время (MSK):
+
+ 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) => (
+ {String(i).padStart(2,'0')}
+ ))}
+
+ :
+ updateTime(s.category, s.run_hour ?? 8, e.target.value)}
+ className="text-xs bg-transparent font-mono focus:outline-none"
+ >
+ {[0,5,10,15,20,25,30,35,40,45,50,55].map(m => (
+ {String(m).padStart(2,'0')}
+ ))}
+
+
+
+
+
+ Следующий запуск: {fmtNextRun(nextRun)}
+ {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' })}>
)}
- {/* Кнопка запуска */}
runCategory(s.category)}
disabled={isRunning || !s.enabled}
@@ -245,7 +292,6 @@ export default function AutogenPanel({ status, queue, topics }) {
- {/* Форма добавления */}
{showAddForm && (
@@ -278,7 +324,6 @@ export default function AutogenPanel({ status, queue, topics }) {
)}
- {/* Список очереди */}
{pendingQueue.length === 0 && !showAddForm && (
diff --git a/components/admin/ChannelEditor.js b/components/admin/ChannelEditor.js
index 496a6b7..a9b52ba 100644
--- a/components/admin/ChannelEditor.js
+++ b/components/admin/ChannelEditor.js
@@ -2,7 +2,9 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
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 = [
{ value: 'telegram', label: 'Telegram', desc: 'Публикация через Bot API' },
@@ -11,12 +13,13 @@ const PLATFORMS = [
];
const TABS = [
- { id: 'settings', label: 'Настройки' },
- { id: 'schedule', label: 'Расписание' },
- { id: 'publish', label: 'Публикация' },
+ { id: 'settings', label: 'Настройки' },
+ { id: 'schedule', label: 'Расписание' },
+ { id: 'autopublish', label: 'Авто-публикация' },
+ { id: 'publish', label: 'Ручная публикация' },
];
-export default function ChannelEditor({ channel, articles = [] }) {
+export default function ChannelEditor({ channel }) {
const router = useRouter();
const isNew = !channel;
@@ -31,9 +34,11 @@ export default function ChannelEditor({ channel, articles = [] }) {
const [maxToken, setMaxToken] = useState(channel?.max_access_token || '');
const [isActive, setIsActive] = useState(channel?.is_active ?? true);
- // Публикация
- const [selectedArticle, setSelectedArticle] = useState('');
+ // Ручная публикация: либо статья, либо custom_text. Опционально — на конкретное время (или сейчас).
+ const [pickedArticle, setPickedArticle] = useState(null);
const [customText, setCustomText] = useState('');
+ const [publishMode, setPublishMode] = useState('now'); // 'now' | 'schedule'
+ const [scheduleAt, setScheduleAt] = useState('');
const [publishing, setPublishing] = useState(false);
const [publishResult, setPublishResult] = useState(null);
@@ -42,19 +47,33 @@ export default function ChannelEditor({ channel, articles = [] }) {
const [toast, setToast] = useState(null);
const [activeTab, setActiveTab] = useState('settings');
- // Слоты расписания
+ // Слоты
const [slots, setSlots] = useState([]);
const [newSlotH, setNewSlotH] = useState(8);
const [newSlotM, setNewSlotM] = useState(0);
const [addingSlot, setAddingSlot] = useState(false);
- // Загружаем слоты
+ // Очередь канала
+ const [queue, setQueue] = useState([]);
+ const [loadingQueue, setLoadingQueue] = useState(false);
+
useEffect(() => {
if (!channel?.id) return;
- fetch(`/admin/api/channels/${channel.id}/slots`)
- .then(r => r.json()).then(setSlots).catch(() => {});
+ fetch(`/admin/api/channels/${channel.id}/slots`).then(r => r.json()).then(setSlots).catch(() => {});
+ loadQueue();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [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') {
setToast({ msg, type });
setTimeout(() => setToast(null), 4000);
@@ -105,27 +124,43 @@ export default function ChannelEditor({ channel, articles = [] }) {
}
}
- async function publish() {
- if (!selectedArticle && !customText.trim()) {
+ async function doPublish() {
+ if (!pickedArticle && !customText.trim()) {
return showToast('Выберите статью или введите текст', 'error');
}
setPublishing(true);
setPublishResult(null);
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',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- article_id: selectedArticle ? parseInt(selectedArticle) : undefined,
- custom_text: customText.trim() || undefined,
- }),
+ body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Ошибка публикации');
- setPublishResult({ ok: true, data });
- showToast('Опубликовано!');
- setSelectedArticle('');
+ setPublishResult({ ok: true, data, scheduled: isSchedule });
+ showToast(isSchedule ? `Запланировано на ${new Date(scheduleAt).toLocaleString('ru-RU')}` : 'Опубликовано!');
+ setPickedArticle(null);
setCustomText('');
+ setScheduleAt('');
+ loadQueue();
} catch (e) {
setPublishResult({ ok: false, error: e.message });
showToast(e.message.slice(0, 120), 'error');
@@ -134,13 +169,14 @@ export default function ChannelEditor({ channel, articles = [] }) {
}
}
- // Автозаполнение текста при выборе статьи
- function onArticleSelect(artId) {
- setSelectedArticle(artId);
- if (!artId) { setCustomText(''); return; }
- const art = articles.find(a => String(a.id) === artId);
- if (art) {
- setCustomText(`${art.title}\n\n${art.excerpt || ''}\n\nhttps://zeropost.ru/blog/${art.slug}`);
+ async function cancelScheduled(id) {
+ if (!confirm('Отменить эту запланированную публикацию?')) return;
+ try {
+ await fetch(`/admin/api/scheduled/${id}`, { method: 'DELETE' });
+ loadQueue();
+ showToast('Отменено');
+ } catch (e) {
+ showToast(e.message, 'error');
}
}
@@ -166,9 +202,14 @@ export default function ChannelEditor({ channel, articles = [] }) {
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 (
- {/* Toast */}
{toast && (
{isNew ? 'Новый канал' : channel.name}
+ {!isNew && channel.auto_publish_enabled && (
+
+
+ auto
+
+ )}
+ {!isNew && (
+
+
+ Статистика
+
+ )}
{!isNew && (
@@ -195,20 +249,21 @@ export default function ChannelEditor({ channel, articles = [] }) {
{deleting ? 'Удаление...' : 'Удалить'}
)}
-
-
- {saving ? 'Сохранение...' : 'Сохранить'}
-
+ {(isNew || activeTab === 'settings') && (
+
+
+ {saving ? 'Сохранение...' : 'Сохранить'}
+
+ )}
- {/* Вкладки — только для существующего канала */}
{!isNew && (
-
+
{TABS.map(tab => (
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
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: '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') && (
-
Основное
+
Основное
- {/* Платформа */}
-
-
Платформа
-
- {PLATFORMS.map(p => (
-
setPlatform(p.value)}
- className={`p-3 rounded-lg border text-left transition-all ${
- platform === p.value
- ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950'
- : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300'
- }`}
- >
- {p.label}
- {p.desc}
-
- ))}
-
-
-
- {/* Название */}
-
- Название канала
- 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" />
-
-
- {/* Поля Telegram */}
- {platform === 'telegram' && (
-
-
- Создайте бота через @BotFather , добавьте его в канал как администратора.
-
-
-
Bot Token
-
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..." />
+
+
Платформа
+
+ {PLATFORMS.map(p => (
+
setPlatform(p.value)}
+ className={`p-3 rounded-lg border text-left transition-all ${
+ platform === p.value
+ ? 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950'
+ : 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300'
+ }`}>
+ {p.label}
+ {p.desc}
+
+ ))}
-
+
+
+
+ Название канала
+ 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" />
+
+
+ {platform === 'telegram' && (
+
+
+ Создайте бота через @BotFather , добавьте его в канал как администратора.
+
+
+ Bot Token
+ 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..." />
+
+
+
+ )}
+
+ {platform === 'vk' && (
+
+
+ Получите токен через VK API с правами wall, photos.
+
+
+ ID группы
+ 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" />
+
+
+ Access Token
+ 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..." />
+
+
+ )}
+
+ {platform === 'max' && (
+
-
- )}
+ )}
- {/* Поля ВКонтакте */}
- {platform === 'vk' && (
-
-
- Получите токен через VK API с правами wall, photos.
-
-
- ID группы
- 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" />
-
-
- Access Token
- 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..." />
-
+
+ setIsActive(e.target.checked)}
+ className="w-4 h-4 rounded accent-emerald-500" />
+ Канал активен
- )}
-
- {/* Поля Max */}
- {platform === 'max' && (
-
-
- Max (бывший ОК) — публикация через Bot API.
-
-
- Channel ID
- 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" />
-
-
- Access Token
- 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..." />
-
-
- )}
-
- {/* Активность */}
-
- setIsActive(e.target.checked)}
- className="w-4 h-4 rounded accent-emerald-500" />
- Канал активен
-
)}
{/* Вкладка: Расписание */}
@@ -338,9 +382,8 @@ export default function ChannelEditor({ channel, articles = [] }) {
Слоты публикации
-
Время когда будут отправляться запланированные посты. Добавьте несколько слотов — посты распределятся по ним автоматически.
+
Время когда выходят запланированные посты. При автопубликации новая статья ставится на ближайший свободный слот.
- {/* Список слотов */}
{slots.length === 0 && (
Слотов пока нет — добавьте время публикации
@@ -360,7 +403,6 @@ export default function ChannelEditor({ channel, articles = [] }) {
))}
- {/* Добавить слот */}
Добавить слот
@@ -385,47 +427,116 @@ export default function ChannelEditor({ channel, articles = [] }) {
Добавить
-
- Совет: используй не круглые числа (8:06, 11:23) — так посты выглядят органичнее в ленте
-
- {/* Подсказка */}
-
-
Как работает расписание
-
Когда ты ставишь пост в очередь на вкладке "Публикация" — он автоматически назначается на ближайший свободный слот. Если добавить 4 слота, посты будут выходить 4 раза в день.
+ {/* Очередь канала */}
+
+
+
Очередь канала
+
+ Обновить
+
+
+ {queue.length === 0 ? (
+
Очередь пуста
+ ) : (
+
+ {queue.map(q => {
+ const label = STATUS_LABELS[q.status] || STATUS_LABELS.pending;
+ return (
+
+
+
+ {q.article_title || (q.custom_text?.slice(0, 60) + '...') || `Пост #${q.id}`}
+
+
+ {label.text}
+ {new Date(q.scheduled_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
+ {q.article_category && [{q.article_category}] }
+ {q.error && {q.error} }
+
+
+ {q.status === 'pending' && (
+
cancelScheduled(q.id)}
+ className="text-xs text-neutral-400 hover:text-red-500 px-2 py-1">
+ отменить
+
+ )}
+
+ );
+ })}
+
+ )}
)}
- {/* Вкладка: Публикация */}
+ {/* Вкладка: Авто-публикация */}
+ {!isNew && activeTab === 'autopublish' && (
+
router.refresh()} />
+ )}
+
+ {/* Вкладка: Ручная публикация */}
{!isNew && activeTab === 'publish' && (
Опубликовать
- {/* Выбор статьи */}
Выбрать статью
-
onArticleSelect(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">
- — выбрать статью —
- {articles.map(a => (
- {a.title}
- ))}
-
+
{
+ setPickedArticle(a);
+ if (a) setCustomText(''); // когда выбрана статья — текст рендерит шаблон канала
+ }}
+ channelId={channel.id}
+ placeholder="Найти статью (поиск по названию)…"
+ />
- {/* Текст поста */}
-
Текст поста
+
+ Или произвольный текст {pickedArticle && (если задан — перебивает шаблон статьи) }
+
- {/* Результат */}
+ {/* Режим */}
+
+
Когда
+
+ 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'
+ }`}>
+ Сейчас
+
+ 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'
+ }`}>
+ Запланировать
+
+
+ {publishMode === 'schedule' && (
+
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"
+ />
+ )}
+
+
{publishResult && (
{publishResult.ok ? (
- ✓ Опубликовано{publishResult.data?.tg_message_id ? ` (message_id: ${publishResult.data.tg_message_id})` : ''}
+
+ ✓ {publishResult.scheduled ? 'Запланировано' : 'Опубликовано'}
+ {publishResult.data?.tg_message_id ? ` (message_id: ${publishResult.data.tg_message_id})` : ''}
+
) : (
✗ {publishResult.error}
)}
)}
-
- {publishing ? 'Публикация...' : `Опубликовать в ${PLATFORMS.find(p=>p.value===channel.platform)?.label || 'канал'}`}
+ {publishing ? '…' : (publishMode === 'schedule' ? 'Поставить в очередь' : `Опубликовать в ${PLATFORMS.find(p=>p.value===channel.platform)?.label || 'канал'}`)}
)}
diff --git a/lib/engine.js b/lib/engine.js
index c2721bf..764f91a 100644
--- a/lib/engine.js
+++ b/lib/engine.js
@@ -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}`);
+}
diff --git a/package-lock.json b/package-lock.json
index bd41c7f..a0ddf38 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"postcss": "8.4.39",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "recharts": "^3.8.1",
"tailwindcss": "3.4.7"
}
},
@@ -713,6 +714,54 @@
"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": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -737,6 +786,75 @@
"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": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -948,6 +1066,15 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"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": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -969,6 +1096,133 @@
"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": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1006,6 +1260,16 @@
"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": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1028,6 +1292,12 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
@@ -1164,6 +1434,25 @@
"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": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -1830,6 +2119,36 @@
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -1851,6 +2170,57 @@
"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": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -2190,6 +2560,12 @@
"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": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -2289,12 +2665,43 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"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": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
diff --git a/package.json b/package.json
index 0512123..6fdf8fd 100644
--- a/package.json
+++ b/package.json
@@ -8,16 +8,17 @@
"start": "next start -p 3042"
},
"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",
+ "pg": "^8.21.0",
+ "postcss": "8.4.39",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "lucide-react": "0.408.0",
- "pg": "^8.21.0",
- "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"
+ "recharts": "^3.8.1",
+ "tailwindcss": "3.4.7"
}
}