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
119 lines
4.3 KiB
JavaScript
119 lines
4.3 KiB
JavaScript
/**
|
|
* Процедурно-сгенерированная SVG-обложка в стиле ZeroPost.
|
|
* Используется когда настоящая обложка ещё не сгенерирована.
|
|
* Каждая статья получает уникальный, воспроизводимый узор.
|
|
*/
|
|
|
|
function rng(seed) {
|
|
let t = seed + 0x6D2B79F5;
|
|
return () => {
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
// Только наши бренд-палитры — никакого произвольного фиолетового
|
|
const PALETTES = [
|
|
{ bg: '#ecfdf5', accent: '#10b981', soft: '#a7f3d0', dark: '#065f46' }, // emerald (основной)
|
|
{ bg: '#f0fdfa', accent: '#14b8a6', soft: '#99f6e4', dark: '#115e59' }, // teal
|
|
{ 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
|
|
];
|
|
|
|
export default function ArticleCoverSVG({ article, className = '', aspect = '16/9' }) {
|
|
const seed = (article?.id || 1) * 9301 + 49297;
|
|
const rand = rng(seed);
|
|
|
|
// Выбор палитры по 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 = ['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 });
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`relative overflow-hidden rounded-xl ${className}`}
|
|
style={{ aspectRatio: aspect, background: palette.bg }}
|
|
>
|
|
<svg
|
|
viewBox="0 0 400 225"
|
|
preserveAspectRatio="xMidYMid slice"
|
|
className="absolute inset-0 w-full h-full"
|
|
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;
|
|
const rot = s.r() * 360;
|
|
|
|
if (s.kind === 'circle') {
|
|
return <circle key={idx} cx={cx} cy={cy} r={size / 2} fill={s.fill} opacity={s.opacity} />;
|
|
}
|
|
if (s.kind === 'rect') {
|
|
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}
|
|
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}
|
|
transform={`rotate(${rot} ${cx} ${cy})`}
|
|
/>
|
|
);
|
|
}
|
|
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}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* Точки-частицы */}
|
|
{Array.from({ length: 12 }).map((_, i) => (
|
|
<circle
|
|
key={`d-${i}`}
|
|
cx={rand() * 400} cy={rand() * 225}
|
|
r={1 + rand() * 2.5}
|
|
fill={palette.dark} opacity={0.12 + rand() * 0.15}
|
|
/>
|
|
))}
|
|
</svg>
|
|
|
|
{/* Аватар Зеро в правом нижнем углу вместо текстового тега */}
|
|
<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>
|
|
);
|
|
}
|