Конечный цвет
{eff.b}
setBg({...eff, b:c})}/>
setBg({...eff, angle: v})}
min={0} max={360} step={1} suffix="°" />
>
)}
{mode === 'photo' && (
)}
{mode === 'search' && (
)}
{mode === 'pattern' && (
<>
Узор
{PATTERNS.map(p => (
setBg({...eff, scale:v})}
min={4} max={40} step={1} suffix="px"/>
Цвет фона
setBg({...eff, bg: c})}/>
Цвет узора
setBg({...eff, fg: c})} palette={TEXT_COLOR_PALETTE}/>
>
)}
{mode === 'blocks' && (
)}
>
);
}
/* ============================================================
Tab: Templates
============================================================ */
const SAVED_TEMPLATES = [
{ id:'t1', name:'Дизайн-вестник', date:'вчера',
presetId:'magazine', content:{kicker:'ВЫПУСК №24', title:'Тихая революция в дизайне', subtitle:'Как маленькие студии меняют рынок'} },
{ id:'t2', name:'Серия эссе', date:'2 дня',
presetId:'book', content:{kicker:'ESSAYS', title:'О тишине', subtitle:'Заметки на полях недели'} },
{ id:'t3', name:'Tech Weekly', date:'3 дня',
presetId:'tech', content:{kicker:'release · 04.06', title:'Линейный\nи быстрый', subtitle:'Новый редактор уже доступен в beta'} },
{ id:'t4', name:'Lookbook', date:'неделю',
presetId:'gradient', content:{kicker:'LOOKBOOK', title:'Мягкий май', subtitle:'Палитра, которая возвращает дыхание'} },
{ id:'t5', name:'Editorial', date:'2 нед.',
presetId:'editorial', content:{kicker:'ESSAY', title:'Anatomy of\na Quiet Year', subtitle:'на полях 2026 года'} },
{ id:'t6', name:'Punch!', date:'месяц',
presetId:'brutalist', content:{kicker:'NEW DROP', title:'BIG NEWS', subtitle:'и громче не будет'} },
];
function TabTemplates({ apply, content, presetId }) {
return (
<>
Сохранённые шаблоны
+ Сохранить текущий
{SAVED_TEMPLATES.map(t => (
apply(t)}>
{t.name}
{t.date}
))}
>
);
}
/* ============================================================
Blocks constructor — add / select / drag / recolor / delete
============================================================ */
const BLOCK_PALETTE = [
'#5552E0','#F2C84B','#E2231A','#16A34A','#161616','#FFFFFF',
'#FFB1A5','#9D9AFF','#1F3D2E','#C9B66E','#3B5BDB','#8B89F7',
];
function BlocksEditor({ eff, setBg }) {
const shapes = eff.shapes || [];
const [sel, setSel] = useState(0);
const stageRef = useRef(null);
// refs so the once-attached drag listener always reads the latest data
const dragRef = useRef(null);
const shapesRef = useRef(shapes);
const effRef = useRef(eff);
const setBgRef = useRef(setBg);
shapesRef.current = shapes;
effRef.current = eff;
setBgRef.current = setBg;
const setShapes = (next) => setBg({ ...eff, shapes: next });
const updateShape = (i, patch) => {
const next = shapes.slice();
next[i] = { ...next[i], ...patch };
setShapes(next);
};
const addShape = (kind) => {
const base = kind === 'circle'
? { x:35, y:30, w:30, h:30, color:'#5552E0', r:'circle' }
: { x:25, y:35, w:50, h:30, color:'#F2C84B', r:6 };
const next = shapes.concat([base]);
setShapes(next);
setSel(next.length - 1);
};
const removeShape = (i) => {
const next = shapes.slice(); next.splice(i, 1);
setShapes(next);
setSel(Math.max(0, i - 1));
};
const onDown = (i) => (e) => {
e.stopPropagation();
setSel(i);
const rect = stageRef.current.getBoundingClientRect();
const s = shapes[i];
const sxPx = (s.x / 100) * rect.width;
const syPx = (s.y / 100) * rect.height;
dragRef.current = { i, dx: e.clientX - sxPx, dy: e.clientY - syPx };
};
useEffect(() => {
const onMove = (e) => {
const d = dragRef.current;
if (!d) return;
const rect = stageRef.current.getBoundingClientRect();
const s = shapesRef.current[d.i];
if (!s) return;
let x = ((e.clientX - d.dx) / rect.width) * 100;
let y = ((e.clientY - d.dy) / rect.height) * 100;
x = Math.max(0, Math.min(100 - s.w, x));
y = Math.max(0, Math.min(100 - s.h, y));
const next = shapesRef.current.slice();
next[d.i] = { ...s, x, y };
setBgRef.current({ ...effRef.current, shapes: next });
};
const onUp = () => { dragRef.current = null; };
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
return () => {
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
}, []);
const cur = shapes[sel];
return (
<>
Конструктор фигур
{shapes.length} шт.
{shapes.map((s, i) => (
))}
{cur && (
<>
Цвет фигуры
updateShape(sel, {color: c})}/>
updateShape(sel, {w: v})}
min={5} max={100} step={1} suffix="%"/>
updateShape(sel, {h: v})}
min={5} max={100} step={1} suffix="%"/>
updateShape(sel, {rot: v})}
min={-180} max={180} step={1} suffix="°"/>
{cur.r !== 'circle' && (
updateShape(sel, {r: v})}
min={0} max={50} step={1} suffix="px"/>
)}
>
)}
Цвет фона
setBg({...eff, bg: c})}/>
>
);
}
/* ============================================================
Photo upload (file or drag-drop, plus the picsum seed swatches)
============================================================ */
function PhotoUpload({ eff, setBg }) {
const inputRef = React.useRef(null);
const onFile = (file) => {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = () => setBg({ mode:'photo', url: reader.result });
reader.readAsDataURL(file);
};
return (
<>
e.preventDefault()}
onDrop={e => { e.preventDefault(); onFile(e.dataTransfer.files?.[0]); }}>
Перетащите фото или
onFile(e.target.files?.[0])}/>
JPG / PNG / WebP, до 10 МБ
{['beauticard-1','beauticard-2','beauticard-3'].map(seed => (
))}
Примеры с picsum.photos. Загруженный файл показывается в превью сразу.
>
);
}
/* ============================================================
Unsplash search (real API; user supplies their own Access Key)
============================================================ */
function UnsplashSearch({ eff, setBg }) {
const [key, setKey] = useState(localStorage.getItem('unsplash_key') || '');
const [showKeyForm, setShowKeyForm] = useState(!key);
const [query, setQuery] = useState(eff.query || '');
const [results, setResults] = useState([]);
const [status, setStatus] = useState('');
const saveKey = () => {
const k = key.trim();
if (!k) return;
localStorage.setItem('unsplash_key', k);
setShowKeyForm(false);
setStatus('Ключ сохранён. Введите запрос.');
};
const search = async (q) => {
const finalQ = (q ?? query).trim();
const k = localStorage.getItem('unsplash_key');
if (!k) { setShowKeyForm(true); return; }
if (!finalQ) return;
setQuery(finalQ);
setStatus('Ищу…');
setResults([]);
try {
const res = await fetch(
`https://api.unsplash.com/search/photos?query=${encodeURIComponent(finalQ)}&per_page=18&content_filter=high`,
{ headers: { Authorization: `Client-ID ${k}` } }
);
if (!res.ok) {
setStatus(res.status === 401 ? 'Неверный Access Key — обновите.' : `Ошибка ${res.status}`);
return;
}
const data = await res.json();
if (!data.results || data.results.length === 0) {
setStatus('Ничего не найдено.');
return;
}
setResults(data.results);
setStatus(`Найдено: ${data.total}. Клик — выбрать.`);
} catch (e) {
setStatus('Сетевая ошибка.');
}
};
const pick = async (photo) => {
setStatus('Загружаю фото…');
const utm = '?utm_source=beauticard&utm_medium=referral';
const attribution = `Photo by ${photo.user.name} on Unsplash`;
try {
const r = await fetch(photo.urls.regular, { mode: 'cors' });
const b = await r.blob();
const dataUrl = await new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = () => reject(fr.error);
fr.readAsDataURL(b);
});
setBg({ mode:'photo', url: dataUrl, attribution, authorUrl: photo.user.links.html + utm });
setStatus('Готово.');
} catch {
// Fallback: set the URL directly (download may fail due to CORS)
setBg({ mode:'photo', url: photo.urls.regular, attribution, authorUrl: photo.user.links.html + utm });
setStatus('Готово (без оффлайн-копии).');
}
};
if (showKeyForm) {
return (
);
}
return (
<>
setQuery(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') search(); }}
placeholder="Например: горы, минимализм, кофе"/>
{['минимализм','горы','офис','кофе','город','природа'].map(q => (
))}
{status}
{ localStorage.removeItem('unsplash_key'); setKey(''); setShowKeyForm(true); }}>
сменить ключ
{results.length > 0 && (
{results.map(p => (
))}
)}
>
);
}
Object.assign(window, {
Icon, Slider, Seg, ColorChips,
TabStyle, TabText, TabTypography, TabBackground, TabTemplates,
});