Files
Nik (Claude) 334b2f51df 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
2026-06-07 14:04:09 +03:00

173 lines
6.9 KiB
JavaScript

import Link from 'next/link';
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;
}
/**
* 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 (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">
<div className="p-3 sm:p-5 sm:col-span-2">
{img ? (
<img
src={img}
alt={article.title}
className="w-full aspect-[16/9] sm:aspect-square object-cover rounded-xl"
loading="eager"
/>
) : (
<ArticleCoverSVG article={article} aspect="16/9" className="sm:!aspect-square" />
)}
</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">
<CategoryBadge category={article.category} />
{tags.slice(0, 3).map(t => (
<span key={t} className="tag">#{t}</span>
))}
</div>
<h2 className="text-xl sm:text-3xl font-bold mb-3 leading-tight ink group-hover:accent transition-colors">
{article.title}
</h2>
{article.excerpt && (
<p className="mute text-sm sm:text-base leading-relaxed line-clamp-3 mb-4">
{article.excerpt}
</p>
)}
<div className="flex items-center gap-3 text-xs 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>
</div>
</Link>
);
}
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">
{img ? (
<img src={img} alt={article.title} className="w-full aspect-[16/9] object-cover rounded-lg" loading="lazy" />
) : (
<ArticleCoverSVG article={article} />
)}
</div>
<div className="p-4 sm:p-5 pt-2">
<div className="flex flex-wrap items-center gap-2 mb-2">
<CategoryBadge category={article.category} />
{tags.slice(0, 2).map(t => (
<span key={t} className="tag">#{t}</span>
))}
</div>
<h3 className="text-base sm:text-lg font-semibold mb-2 ink group-hover:accent transition-colors leading-snug line-clamp-2">
{article.title}
</h3>
{article.excerpt && (
<p className="mute text-sm line-clamp-2 mb-4">{article.excerpt}</p>
)}
<div className="flex items-center gap-3 text-xs 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>
);
}