/* ============================================================ BeautiCard — main app shell ============================================================ */ const { useState, useEffect, useRef } = React; const DEFAULT_CONTENT = { kicker: 'ВЫПУСК №24', title: 'Тихая революция в дизайне', subtitle: 'Как маленькие студии меняют большой рынок', }; const DEFAULT_DECO = { brand: 'BEAUTICARD', issue: '№ 24', date: 'МАЙ · 2026', badge: 'НОВОЕ', tagline: 'ДИЗАЙН · КУЛЬТУРА · ТЕХНО', readTime: 'ЧТЕНИЕ · 8 МИН', }; const TABS = [ { id:'style', label:'Стиль' }, { id:'text', label:'Текст' }, { id:'type', label:'Типографика' }, { id:'bg', label:'Фон' }, { id:'tmpl', label:'Шаблоны' }, ]; const ASPECT_ORDER = ['3:4','1:1','16:9','9:16']; function App() { const [tab, setTab] = useState('style'); const [presetId, setPresetId] = useState('magazine'); const [aspect, setAspect] = useState('3:4'); const [content, setContent] = useState(DEFAULT_CONTENT); const [deco, setDeco] = useState(DEFAULT_DECO); const [bgOverride, setBgOverride] = useState(null); const [textOverrides, setTextOverrides] = useState({}); const [layoutOverride, setLayoutOverride] = useState({}); const [docName, setDocName] = useState('Тихая революция'); const [showHistory, setShowHistory] = useState(false); const [toast, setToast] = useState(null); // History const histRef = useRef([{ ts: Date.now(), label: 'Начало работы', snap: snapshot() }]); const [histIdx, setHistIdx] = useState(0); function snapshot() { return JSON.stringify({ presetId, aspect, content, deco, bgOverride, textOverrides, layoutOverride }); } // Track significant changes into history (debounced-ish) useEffect(() => { const t = setTimeout(() => { const snap = snapshot(); const last = histRef.current[histRef.current.length - 1]; if (last && last.snap === snap) return; const label = describeChange(last, { presetId, content, bgOverride, textOverrides, layoutOverride, aspect }); const cut = histRef.current.slice(0, histIdx + 1); cut.push({ ts: Date.now(), label, snap }); histRef.current = cut.slice(-30); setHistIdx(histRef.current.length - 1); }, 350); return () => clearTimeout(t); // eslint-disable-next-line }, [presetId, aspect, content, deco, bgOverride, textOverrides, layoutOverride]); function describeChange(prev, cur) { if (!prev) return 'Изменение'; try { const p = JSON.parse(prev.snap); if (p.presetId !== cur.presetId) return `Стиль → ${PRESETS[cur.presetId].name}`; if (p.aspect !== cur.aspect) return `Формат → ${cur.aspect}`; if (JSON.stringify(p.bgOverride) !== JSON.stringify(cur.bgOverride)) return 'Изменён фон'; if (JSON.stringify(p.layoutOverride) !== JSON.stringify(cur.layoutOverride)) return 'Изменено расположение'; if (JSON.stringify(p.textOverrides) !== JSON.stringify(cur.textOverrides)) return 'Изменена типографика'; if (p.content.title !== cur.content.title) return 'Изменён заголовок'; if (p.content.kicker !== cur.content.kicker) return 'Изменена надпись'; if (p.content.subtitle !== cur.content.subtitle) return 'Изменена подпись'; } catch {} return 'Изменение'; } function restoreSnap(snap) { try { const s = JSON.parse(snap); setPresetId(s.presetId); setAspect(s.aspect); setContent(s.content); if (s.deco) setDeco(s.deco); setBgOverride(s.bgOverride); setTextOverrides(s.textOverrides); setLayoutOverride(s.layoutOverride); } catch {} } function undo() { if (histIdx <= 0) return; const newIdx = histIdx - 1; setHistIdx(newIdx); restoreSnap(histRef.current[newIdx].snap); showToast('Отменено'); } function redo() { if (histIdx >= histRef.current.length - 1) return; const newIdx = histIdx + 1; setHistIdx(newIdx); restoreSnap(histRef.current[newIdx].snap); showToast('Возвращено'); } function showToast(msg) { setToast(msg); setTimeout(() => setToast(null), 1600); } // Pre-fetch Google Fonts CSS so html-to-image can embed it (CSSOM access on // cross-origin stylesheets is blocked by the browser). const fontCssRef = useRef(null); async function getFontEmbedCSS() { if (fontCssRef.current != null) return fontCssRef.current; try { const links = Array.from( document.querySelectorAll('link[rel="stylesheet"][href*="fonts.googleapis.com"]') ); const texts = await Promise.all( links.map(l => fetch(l.href, { mode:'cors' }).then(r => r.ok ? r.text() : '').catch(() => '')) ); fontCssRef.current = texts.join('\n'); } catch { fontCssRef.current = ''; } return fontCssRef.current; } async function downloadPng() { const node = document.querySelector('.cover'); if (!node || typeof htmlToImage === 'undefined') { showToast('Не удалось подготовить файл'); return; } showToast('Готовлю PNG…'); try { await document.fonts.ready; const rect = node.getBoundingClientRect(); const a = ASPECTS[aspect]; const targetW = a.w >= a.h ? 1200 : Math.round(1200 * a.w / a.h); const pixelRatio = targetW / rect.width; const fontEmbedCSS = await getFontEmbedCSS(); const blob = await htmlToImage.toBlob(node, { pixelRatio, fontEmbedCSS }); if (!blob) throw new Error('no blob'); const url = URL.createObjectURL(blob); const link = document.createElement('a'); const stamp = (docName || 'beauticard').replace(/[^\p{L}\p{N}_\-]+/gu, '-').slice(0, 40) || 'beauticard'; link.href = url; link.download = `${stamp}.png`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); showToast('Файл скачан'); } catch (e) { console.error(e); showToast('Ошибка скачивания'); } } // Randomize — pick random style + random small variations function randomize() { const ids = PRESET_ORDER.filter(p => p !== presetId); const next = ids[Math.floor(Math.random() * ids.length)]; setPresetId(next); setTextOverrides({}); setLayoutOverride({}); // 50% chance of bg surprise if (Math.random() < 0.5) { const opts = [ { mode:'gradient', a:'#FFB1A5', b:'#9D9AFF', angle: 135 }, { mode:'gradient', a:'#5552E0', b:'#0B0B0E', angle: 160 }, { mode:'gradient', a:'#1F3D2E', b:'#C9B66E', angle: 145 }, { mode:'mesh', a:'#FFE7A6', b:'#9D9AFF', c:'#FFB1A5' }, null, ]; setBgOverride(opts[Math.floor(Math.random()*opts.length)]); } else { setBgOverride(null); } showToast('Сгенерирован вариант'); } function applyTemplate(t) { setPresetId(t.presetId); setContent(t.content); setDeco(t.deco ? { ...DEFAULT_DECO, ...t.deco } : DEFAULT_DECO); setTextOverrides({}); setLayoutOverride({}); setBgOverride(null); showToast(`Шаблон «${t.name}» применён`); } // Keyboard shortcuts useEffect(() => { const onKey = (e) => { const mod = e.metaKey || e.ctrlKey; if (mod && e.key === 'z' && !e.shiftKey) { e.preventDefault(); undo(); } else if (mod && (e.key === 'y' || (e.shiftKey && e.key === 'Z' || e.shiftKey && e.key === 'z'))) { e.preventDefault(); redo(); } else if (mod && e.key === 'r') { e.preventDefault(); randomize(); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }); const aspectMeta = ASPECTS[aspect]; // Compute pixel size for the preview, fitting in the canvas-mid area // The cover-stage is a flex container; we'll let CSS handle sizing via max-h / max-w return (