334b2f51df
- Журнальная главная: 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
173 lines
6.9 KiB
JavaScript
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>
|
|
);
|
|
}
|