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:
@@ -3,15 +3,66 @@ import { formatDate } from '@/lib/markdown';
|
||||
import { Clock } from 'lucide-react';
|
||||
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) {
|
||||
if (!article.cover_url) return null;
|
||||
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 tags = cleanTags(article.tags, article.category);
|
||||
|
||||
if (featured) {
|
||||
if (effectiveSize === 'hero') {
|
||||
return (
|
||||
<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">
|
||||
@@ -29,7 +80,8 @@ export default function ArticleCard({ article, featured = false }) {
|
||||
</div>
|
||||
<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">
|
||||
{(article.tags || []).slice(0, 3).map(t => (
|
||||
<CategoryBadge category={article.category} />
|
||||
{tags.slice(0, 3).map(t => (
|
||||
<span key={t} className="tag">#{t}</span>
|
||||
))}
|
||||
</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 (
|
||||
<Link href={`/blog/${article.slug}`} className="article-card block group overflow-hidden p-0">
|
||||
<div className="p-3">
|
||||
@@ -66,7 +147,8 @@ export default function ArticleCard({ article, featured = false }) {
|
||||
</div>
|
||||
<div className="p-4 sm:p-5 pt-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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user