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:
@@ -1,12 +1,9 @@
|
||||
/**
|
||||
* Процедурно-сгенерированная SVG-обложка в стиле сайта.
|
||||
* Не требует AI — рендерится сразу, выглядит достойно вместо плоского градиента.
|
||||
*
|
||||
* Идея: используем seed (id статьи) для воспроизводимой композиции.
|
||||
* Каждая статья получает свой уникальный, но узнаваемый узор.
|
||||
* Процедурно-сгенерированная SVG-обложка в стиле ZeroPost.
|
||||
* Используется когда настоящая обложка ещё не сгенерирована.
|
||||
* Каждая статья получает уникальный, воспроизводимый узор.
|
||||
*/
|
||||
|
||||
// псевдо-рандом по seed (mulberry32)
|
||||
function rng(seed) {
|
||||
let t = seed + 0x6D2B79F5;
|
||||
return () => {
|
||||
@@ -16,38 +13,37 @@ function rng(seed) {
|
||||
};
|
||||
}
|
||||
|
||||
// Палитры — приглушённые, чтобы не спорить с UI
|
||||
// Только наши бренд-палитры — никакого произвольного фиолетового
|
||||
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: '#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: '#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 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 shapes = [];
|
||||
|
||||
for (let i = 0; i < layers; i++) {
|
||||
const kind = ['curve', 'circle', 'arc', 'rect'][Math.floor(rand() * 4)];
|
||||
const opacity = 0.35 + rand() * 0.5;
|
||||
const kind = ['circle', 'circle', 'arc', 'rect'][Math.floor(rand() * 4)]; // circle чаще
|
||||
const opacity = 0.25 + rand() * 0.45;
|
||||
const colors = [palette.accent, palette.soft, palette.dark];
|
||||
const fill = colors[Math.floor(rand() * colors.length)];
|
||||
shapes.push({ kind, opacity, fill, r: rand });
|
||||
}
|
||||
|
||||
// тег (первый) — мелкая метка в углу
|
||||
const tag = (article?.tags?.[0] || 'zeropost').toString().slice(0, 18);
|
||||
|
||||
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
|
||||
viewBox="0 0 400 225"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
@@ -55,7 +51,6 @@ export default function ArticleCoverSVG({ article, className = '', aspect = '16/
|
||||
aria-hidden="true"
|
||||
>
|
||||
{shapes.map((s, idx) => {
|
||||
// координаты, тоже псевдо-случайно
|
||||
const cx = 60 + s.r() * 320;
|
||||
const cy = 30 + s.r() * 165;
|
||||
const size = 60 + s.r() * 180;
|
||||
@@ -68,67 +63,55 @@ export default function ArticleCoverSVG({ article, className = '', aspect = '16/
|
||||
return (
|
||||
<rect
|
||||
key={idx}
|
||||
x={cx - size / 2}
|
||||
y={cy - size / 2}
|
||||
width={size}
|
||||
height={size * (0.5 + s.r() * 1)}
|
||||
fill={s.fill}
|
||||
opacity={s.opacity}
|
||||
x={cx - size / 2} y={cy - size / 2}
|
||||
width={size} height={size * (0.5 + s.r() * 1)}
|
||||
fill={s.fill} opacity={s.opacity}
|
||||
rx={s.r() > 0.5 ? size / 8 : 0}
|
||||
transform={`rotate(${rot} ${cx} ${cy})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (s.kind === 'arc') {
|
||||
// Полукруг
|
||||
const r = size / 2;
|
||||
return (
|
||||
<path
|
||||
key={idx}
|
||||
d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy} Z`}
|
||||
fill={s.fill}
|
||||
opacity={s.opacity}
|
||||
fill={s.fill} opacity={s.opacity}
|
||||
transform={`rotate(${rot} ${cx} ${cy})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// curve — плавная волна
|
||||
const w = size;
|
||||
const h = size * 0.4;
|
||||
const w = size, h = size * 0.4;
|
||||
return (
|
||||
<path
|
||||
key={idx}
|
||||
d={`M ${cx - w / 2} ${cy} Q ${cx} ${cy - h}, ${cx + w / 2} ${cy} T ${cx + w * 1.5} ${cy}`}
|
||||
stroke={s.fill}
|
||||
strokeWidth={6 + s.r() * 16}
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
opacity={s.opacity}
|
||||
d={`M ${cx - w/2} ${cy} Q ${cx} ${cy - h}, ${cx + w/2} ${cy} T ${cx + w*1.5} ${cy}`}
|
||||
stroke={s.fill} strokeWidth={6 + s.r() * 16}
|
||||
strokeLinecap="round" fill="none" opacity={s.opacity}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Мелкие точки-частицы — добавляют детализации */}
|
||||
{Array.from({ length: 14 }).map((_, i) => (
|
||||
{/* Точки-частицы */}
|
||||
{Array.from({ length: 12 }).map((_, i) => (
|
||||
<circle
|
||||
key={`d-${i}`}
|
||||
cx={rand() * 400}
|
||||
cy={rand() * 225}
|
||||
r={1 + rand() * 2}
|
||||
fill={palette.dark}
|
||||
opacity={0.18 + rand() * 0.18}
|
||||
cx={rand() * 400} cy={rand() * 225}
|
||||
r={1 + rand() * 2.5}
|
||||
fill={palette.dark} opacity={0.12 + rand() * 0.15}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{/* тэг в углу */}
|
||||
<div className="absolute inset-0 flex items-end p-3 sm:p-4 pointer-events-none">
|
||||
<div
|
||||
className="text-[10px] sm:text-xs font-mono tracking-wider uppercase px-2 py-0.5 rounded-md"
|
||||
style={{ background: 'rgba(255,255,255,0.65)', color: palette.dark, backdropFilter: 'blur(4px)' }}
|
||||
>
|
||||
#{tag}
|
||||
</div>
|
||||
{/* Аватар Зеро в правом нижнем углу вместо текстового тега */}
|
||||
<div className="absolute bottom-3 right-3 pointer-events-none">
|
||||
<img
|
||||
src="/uploads/zero-avatar.webp"
|
||||
alt=""
|
||||
className="w-8 h-8 rounded-lg opacity-70"
|
||||
style={{ boxShadow: '0 1px 4px rgba(0,0,0,0.15)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user