feat: AI invitation card generator MVP

React + TS + Tailwind + Framer Motion SPA per TRD:
- 6 editable templates (elegant, classic, modern, romantic, festive, minimal)
- Editable event fields (type, names, date, time, location, message, RSVP)
- Background modes: gradient presets, solid color, AI (OpenAI Images)
- Real-time animated preview with Framer Motion
- Export to PNG (html-to-image) and PDF (jsPDF)
- Responsive mobile design
- Russian UI
- OpenAI API key stored locally in browser, calls api.openai.com directly
This commit is contained in:
venera 2026-05-19 11:17:27 +05:00
parent 4aeaac7a26
commit 7107f42792
25 changed files with 4214 additions and 0 deletions

1
.gitignore vendored
View File

@ -20,6 +20,7 @@ dist/
build/ build/
out/ out/
target/ target/
*.tsbuildinfo
# data # data
*.sqlite* *.sqlite*

16
TRD.md
View File

@ -0,0 +1,16 @@
Generate invitation cards with AI
- Editable event fields
- Multiple styles
- Export PNG and PDF
- Responsive mobile design
- AI-generated backgrounds
- Real-time preview
- Elegant animations
- Russian language support
Use:
- React
- TailwindCSS
- Framer Motion
- Node.js backend
- OpenAI API

1
favicon.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f0abfc"/><stop offset="1" stop-color="#7c3aed"/></linearGradient></defs><rect x="6" y="10" width="52" height="44" rx="6" fill="url(#g)"/><path d="M14 18 L32 36 L50 18" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 413 B

19
index.html Normal file
View File

@ -0,0 +1,19 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Venera — Приглашения с ИИ</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Great+Vibes&family=Inter:wght@300;400;500;600;700&family=Playfair+Display:wght@400;600;700;900&family=JetBrains+Mono:wght@400;600&display=swap"
rel="stylesheet"
/>
</head>
<body class="bg-neutral-950">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2945
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@ -0,0 +1,28 @@
{
"name": "venera-invites",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^11.3.19",
"html-to-image": "^1.11.11",
"jspdf": "^2.5.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.10",
"typescript": "^5.5.4",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

171
src/App.tsx Normal file
View File

@ -0,0 +1,171 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { motion } from 'framer-motion';
import { CardPreview } from './components/CardPreview';
import { Editor } from './components/Editor';
import { StylePicker } from './components/StylePicker';
import { BackgroundPanel } from './components/BackgroundPanel';
import { Section } from './components/Section';
import { exportPng, exportPdf } from './lib/export';
import { TEMPLATES } from './styles/templates';
import { CardData, DEFAULT_CARD } from './types';
const STORAGE_KEY = 'venera.card.v1';
function loadCard(): CardData {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return { ...DEFAULT_CARD, ...JSON.parse(raw) };
} catch {}
return DEFAULT_CARD;
}
export default function App() {
const [data, setData] = useState<CardData>(() => loadCard());
const [busy, setBusy] = useState<null | 'png' | 'pdf'>(null);
const cardRef = useRef<HTMLDivElement>(null);
const previewSize = useMemo(() => ({ width: 600, height: 900 }), []);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch {}
}, [data]);
useEffect(() => {
const tpl = TEMPLATES[data.styleId];
setData((d) => {
if (d.bgMode !== 'gradient') return d;
const tplDefault = tpl.defaultGradient;
if (d.bgGradient === tplDefault) return d;
const isPreset = Object.values(TEMPLATES).some(
(t) => t.defaultGradient === d.bgGradient
);
return isPreset ? { ...d, bgGradient: tplDefault } : d;
});
}, [data.styleId]);
function patch(p: Partial<CardData>) {
setData((d) => ({ ...d, ...p }));
}
async function handleExport(kind: 'png' | 'pdf') {
if (!cardRef.current) return;
setBusy(kind);
try {
const filename = `venera-${data.eventType}-${Date.now()}.${kind}`;
if (kind === 'png') await exportPng(cardRef.current, filename);
else await exportPdf(cardRef.current, filename);
} catch (e) {
alert('Не удалось экспортировать: ' + (e as Error).message);
} finally {
setBusy(null);
}
}
function reset() {
if (confirm('Сбросить все поля к примеру?')) setData(DEFAULT_CARD);
}
return (
<div className="min-h-screen text-white">
<div className="fixed inset-0 -z-10 bg-gradient-to-br from-neutral-950 via-violet-950/60 to-fuchsia-950/40" />
<div className="fixed inset-0 -z-10 opacity-30 [background:radial-gradient(60%_50%_at_50%_0%,rgba(217,70,239,0.18),transparent_70%)]" />
<header className="sticky top-0 z-30 backdrop-blur-xl bg-neutral-950/60 border-b border-white/5">
<div className="max-w-7xl mx-auto px-4 md:px-8 py-3 flex items-center justify-between">
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2"
>
<div className="h-9 w-9 rounded-lg bg-gradient-to-br from-fuchsia-400 to-violet-600 flex items-center justify-center text-lg font-bold">
V
</div>
<div className="flex flex-col leading-tight">
<span className="text-base font-semibold">Venera</span>
<span className="text-[10px] text-white/50 tracking-wider uppercase">
Приглашения с ИИ
</span>
</div>
</motion.div>
<div className="flex items-center gap-2">
<button
onClick={reset}
className="hidden sm:block text-xs text-white/60 hover:text-white px-3 py-2 rounded-lg hover:bg-white/5"
>
Сбросить
</button>
<button
onClick={() => handleExport('png')}
disabled={busy !== null}
className="text-xs sm:text-sm bg-white/10 hover:bg-white/15 disabled:opacity-50 px-3 sm:px-4 py-2 rounded-lg border border-white/10 transition"
>
{busy === 'png' ? 'PNG…' : 'PNG'}
</button>
<button
onClick={() => handleExport('pdf')}
disabled={busy !== null}
className="text-xs sm:text-sm bg-gradient-to-r from-fuchsia-500 to-violet-600 hover:from-fuchsia-400 hover:to-violet-500 disabled:opacity-50 px-3 sm:px-4 py-2 rounded-lg font-medium transition shadow-lg shadow-fuchsia-500/20"
>
{busy === 'pdf' ? 'PDF…' : 'Скачать PDF'}
</button>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 md:px-8 py-6 md:py-10">
<div className="grid lg:grid-cols-[420px_1fr] gap-6 lg:gap-10">
<div className="space-y-4 order-2 lg:order-1">
<Section title="Стиль">
<StylePicker
value={data.styleId}
onChange={(id) => patch({ styleId: id })}
/>
</Section>
<Section title="Содержание">
<Editor data={data} onChange={patch} />
</Section>
<Section title="Фон">
<BackgroundPanel data={data} onChange={patch} />
</Section>
</div>
<div className="order-1 lg:order-2 lg:sticky lg:top-24 self-start">
<div className="flex flex-col items-center gap-3">
<div className="text-[11px] uppercase tracking-[0.3em] text-white/40">
Превью в реальном времени
</div>
<div className="overflow-auto max-w-full">
<div
style={{
transform: 'scale(var(--scale, 1))',
transformOrigin: 'top center',
}}
className="origin-top"
>
<CardPreview
ref={cardRef}
data={data}
width={previewSize.width}
height={previewSize.height}
/>
</div>
</div>
<div className="text-[11px] text-white/30 max-w-md text-center mt-2">
Карточка 600×900 px. Экспорт в PNG/PDF идёт в высоком разрешении.
</div>
</div>
</div>
</div>
<footer className="text-center text-[11px] text-white/30 mt-16">
Venera · сделано на React + Tailwind + Framer Motion · фоны от OpenAI
</footer>
</main>
</div>
);
}

View File

@ -0,0 +1,183 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import type { CardData, BackgroundMode } from '../types';
import { GRADIENT_PRESETS } from '../styles/templates';
import { Field, inputClass } from './Field';
import {
generateBackground,
getStoredKey,
setStoredKey,
suggestPrompt,
} from '../lib/openai';
interface Props {
data: CardData;
onChange: (patch: Partial<CardData>) => void;
}
const MODES: { id: BackgroundMode; label: string }[] = [
{ id: 'gradient', label: 'Градиент' },
{ id: 'solid', label: 'Цвет' },
{ id: 'ai', label: '✨ ИИ' },
];
export function BackgroundPanel({ data, onChange }: Props) {
const [apiKey, setApiKey] = useState(getStoredKey());
const [prompt, setPrompt] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function saveKey(k: string) {
setApiKey(k);
setStoredKey(k);
}
async function handleGenerate() {
setError(null);
setLoading(true);
try {
const finalPrompt =
prompt.trim() || suggestPrompt(data.eventType, data.styleId);
const url = await generateBackground({
prompt: finalPrompt,
size: '1024x1792',
});
onChange({ bgImage: url, bgMode: 'ai' });
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}
return (
<div className="space-y-4">
<div className="flex gap-1.5 p-1 bg-white/5 rounded-lg">
{MODES.map((m) => (
<button
key={m.id}
onClick={() => onChange({ bgMode: m.id })}
className={`flex-1 text-xs py-1.5 rounded-md transition ${
data.bgMode === m.id
? 'bg-white/15 text-white'
: 'text-white/60 hover:text-white'
}`}
>
{m.label}
</button>
))}
</div>
{data.bgMode === 'gradient' && (
<div>
<div className="text-[11px] uppercase tracking-[0.18em] text-white/60 mb-2">
Готовые градиенты
</div>
<div className="grid grid-cols-4 gap-2">
{GRADIENT_PRESETS.map((g) => (
<motion.button
key={g.name}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={() => onChange({ bgGradient: g.value })}
className={`aspect-square rounded-lg border-2 ${
data.bgGradient === g.value
? 'border-white'
: 'border-white/10'
}`}
style={{ background: g.value }}
title={g.name}
/>
))}
</div>
</div>
)}
{data.bgMode === 'solid' && (
<Field label="Цвет фона">
<div className="flex items-center gap-2">
<input
type="color"
value={data.bgColor}
onChange={(e) => onChange({ bgColor: e.target.value })}
className="h-10 w-14 rounded cursor-pointer bg-transparent border border-white/10"
/>
<input
className={inputClass}
value={data.bgColor}
onChange={(e) => onChange({ bgColor: e.target.value })}
/>
</div>
</Field>
)}
{data.bgMode === 'ai' && (
<div className="space-y-3">
<Field
label="Ключ OpenAI API"
hint="Хранится локально в браузере. Никуда не отправляется кроме api.openai.com."
>
<input
type="password"
className={inputClass}
placeholder="sk-..."
value={apiKey}
onChange={(e) => saveKey(e.target.value)}
/>
</Field>
<Field
label="Описание фона (промпт)"
hint="Оставьте пустым — подберём автоматически по типу события и стилю."
>
<textarea
className={`${inputClass} min-h-[72px] resize-y`}
placeholder={suggestPrompt(data.eventType, data.styleId)}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</Field>
<button
onClick={handleGenerate}
disabled={loading || !apiKey}
className="w-full bg-gradient-to-r from-fuchsia-500 to-violet-600 hover:from-fuchsia-400 hover:to-violet-500 disabled:opacity-40 disabled:cursor-not-allowed text-white font-medium py-2.5 rounded-lg transition shadow-lg shadow-fuchsia-500/20"
>
{loading ? 'Генерация… (10–25 сек)' : '✨ Сгенерировать фон'}
</button>
{error && (
<div className="text-xs text-red-300 bg-red-500/10 border border-red-500/30 rounded-lg p-2">
{error}
</div>
)}
{data.bgImage && (
<div className="space-y-2">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/60">
Затемнение
</div>
<input
type="range"
min={0}
max={0.8}
step={0.05}
value={data.overlayOpacity}
onChange={(e) =>
onChange({ overlayOpacity: Number(e.target.value) })
}
className="w-full accent-fuchsia-400"
/>
<button
onClick={() => onChange({ bgImage: null, bgMode: 'gradient' })}
className="text-xs text-white/50 hover:text-white/80"
>
Удалить фон
</button>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,141 @@
import { forwardRef } from 'react';
import { motion } from 'framer-motion';
import type { CardData } from '../types';
import { TEMPLATES } from '../styles/templates';
import { Divider } from './Divider';
interface Props {
data: CardData;
width?: number;
height?: number;
}
export const CardPreview = forwardRef<HTMLDivElement, Props>(function CardPreview(
{ data, width = 600, height = 900 },
ref
) {
const tpl = TEMPLATES[data.styleId];
const accent = data.accentColor || tpl.accent;
const textColor = tpl.textColor;
const bgStyle: React.CSSProperties = {};
if (data.bgMode === 'gradient') bgStyle.background = data.bgGradient;
if (data.bgMode === 'solid') bgStyle.background = data.bgColor;
if (data.bgMode === 'ai' && data.bgImage) {
bgStyle.backgroundImage = `url(${data.bgImage})`;
bgStyle.backgroundSize = 'cover';
bgStyle.backgroundPosition = 'center';
}
const overlayBg =
data.bgMode === 'ai'
? `rgba(0,0,0,${data.overlayOpacity})`
: 'transparent';
return (
<div
ref={ref}
className="relative overflow-hidden card-shadow rounded-2xl select-none"
style={{ width, height, ...bgStyle }}
>
<div
className="absolute inset-0"
style={{ background: overlayBg, pointerEvents: 'none' }}
/>
<div
className="absolute inset-6 border pointer-events-none rounded-xl"
style={{ borderColor: `${accent}55` }}
/>
<motion.div
key={data.styleId + data.bgMode + (data.bgImage ?? '')}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
className="absolute inset-0 flex flex-col items-center justify-center px-12 py-14 text-center"
style={{ color: textColor }}
>
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.5 }}
className={`${tpl.fontTitle} ${tpl.titleClass}`}
style={{ color: accent }}
>
{data.title}
</motion.div>
{data.subtitle && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.85 }}
transition={{ delay: 0.2, duration: 0.6 }}
className={`${tpl.fontBody} text-xs tracking-[0.3em] uppercase mt-3`}
>
{data.subtitle}
</motion.div>
)}
<Divider template={tpl} color={accent} />
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.25, duration: 0.7, ease: 'easeOut' }}
className={`${tpl.fontNames} ${tpl.namesClass}`}
>
{data.names}
</motion.div>
<Divider template={tpl} color={accent} />
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 0.92 }}
transition={{ delay: 0.35, duration: 0.6 }}
className={`${tpl.bodyClass} max-w-[80%]`}
>
{data.message}
</motion.p>
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.45, duration: 0.6 }}
className="mt-8 flex flex-col items-center gap-1"
>
<div
className={`${tpl.fontTitle} text-xs uppercase tracking-[0.4em]`}
style={{ color: accent }}
>
{data.date}
</div>
{data.time && (
<div className={`${tpl.fontBody} text-sm opacity-90`}>
{data.time}
</div>
)}
{data.location && (
<div
className={`${tpl.fontBody} text-sm opacity-85 mt-1 max-w-[80%]`}
>
{data.location}
</div>
)}
</motion.div>
{data.rsvp && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 0.7 }}
transition={{ delay: 0.55, duration: 0.6 }}
className={`${tpl.fontBody} text-[11px] tracking-wide mt-8`}
>
{data.rsvp}
</motion.div>
)}
</motion.div>
</div>
);
});

View File

@ -0,0 +1,59 @@
import type { StyleTemplate } from '../styles/templates';
interface Props {
template: StyleTemplate;
color: string;
}
export function Divider({ template, color }: Props) {
if (template.divider === 'none') return null;
if (template.divider === 'line') {
return (
<div className="flex items-center justify-center my-6">
<div className="h-px w-32" style={{ background: color }} />
</div>
);
}
if (template.divider === 'dot') {
return (
<div className="flex items-center justify-center my-6 gap-2">
<span className="h-px w-12" style={{ background: color }} />
<span
className="block h-2 w-2 rounded-full"
style={{ background: color }}
/>
<span className="h-px w-12" style={{ background: color }} />
</div>
);
}
if (template.divider === 'star') {
return (
<div className="flex items-center justify-center my-6 gap-3" style={{ color }}>
<span className="h-px w-10" style={{ background: color }} />
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l2.39 7.36H22l-6.19 4.5L18.2 21 12 16.5 5.8 21l2.39-7.14L2 9.36h7.61L12 2z" />
</svg>
<span className="h-px w-10" style={{ background: color }} />
</div>
);
}
return (
<div className="flex items-center justify-center my-6 gap-3" style={{ color }}>
<span className="h-px w-12" style={{ background: color }} />
<svg width="22" height="14" viewBox="0 0 44 14" fill="none">
<path
d="M2 7 C 8 1, 14 1, 22 7 S 36 13, 42 7"
stroke={color}
strokeWidth="1"
fill="none"
/>
<circle cx="22" cy="7" r="1.6" fill={color} />
</svg>
<span className="h-px w-12" style={{ background: color }} />
</div>
);
}

118
src/components/Editor.tsx Normal file
View File

@ -0,0 +1,118 @@
import type { CardData, EventType } from '../types';
import { Field, inputClass } from './Field';
interface Props {
data: CardData;
onChange: (patch: Partial<CardData>) => void;
}
const EVENT_TYPES: { value: EventType; label: string }[] = [
{ value: 'wedding', label: 'Свадьба' },
{ value: 'birthday', label: 'День рождения' },
{ value: 'anniversary', label: 'Юбилей' },
{ value: 'corporate', label: 'Корпоратив' },
{ value: 'baby', label: 'Baby shower' },
{ value: 'other', label: 'Другое' },
];
export function Editor({ data, onChange }: Props) {
return (
<div className="space-y-4">
<Field label="Тип события">
<select
className={inputClass}
value={data.eventType}
onChange={(e) => onChange({ eventType: e.target.value as EventType })}
>
{EVENT_TYPES.map((t) => (
<option key={t.value} value={t.value} className="bg-neutral-900">
{t.label}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Заголовок">
<input
className={inputClass}
value={data.title}
onChange={(e) => onChange({ title: e.target.value })}
/>
</Field>
<Field label="Подзаголовок">
<input
className={inputClass}
value={data.subtitle}
onChange={(e) => onChange({ subtitle: e.target.value })}
/>
</Field>
</div>
<Field label="Имена / виновники">
<input
className={inputClass}
value={data.names}
onChange={(e) => onChange({ names: e.target.value })}
/>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Дата">
<input
className={inputClass}
value={data.date}
onChange={(e) => onChange({ date: e.target.value })}
/>
</Field>
<Field label="Время">
<input
className={inputClass}
value={data.time}
onChange={(e) => onChange({ time: e.target.value })}
/>
</Field>
</div>
<Field label="Место">
<input
className={inputClass}
value={data.location}
onChange={(e) => onChange({ location: e.target.value })}
/>
</Field>
<Field label="Сообщение">
<textarea
className={`${inputClass} min-h-[88px] resize-y`}
value={data.message}
onChange={(e) => onChange({ message: e.target.value })}
/>
</Field>
<Field label="RSVP / контакты">
<input
className={inputClass}
value={data.rsvp}
onChange={(e) => onChange({ rsvp: e.target.value })}
/>
</Field>
<Field label="Цвет акцента">
<div className="flex items-center gap-2">
<input
type="color"
value={data.accentColor}
onChange={(e) => onChange({ accentColor: e.target.value })}
className="h-9 w-12 rounded cursor-pointer bg-transparent border border-white/10"
/>
<input
className={inputClass}
value={data.accentColor}
onChange={(e) => onChange({ accentColor: e.target.value })}
/>
</div>
</Field>
</div>
);
}

24
src/components/Field.tsx Normal file
View File

@ -0,0 +1,24 @@
import { ReactNode } from 'react';
interface Props {
label: string;
hint?: string;
children: ReactNode;
}
export function Field({ label, hint, children }: Props) {
return (
<label className="block">
<div className="text-[11px] uppercase tracking-[0.18em] text-white/60 mb-1.5">
{label}
</div>
{children}
{hint && (
<div className="text-[11px] text-white/40 mt-1">{hint}</div>
)}
</label>
);
}
export const inputClass =
'w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-fuchsia-400/60 focus:bg-white/[0.07] transition';

View File

@ -0,0 +1,40 @@
import { ReactNode, useState } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface Props {
title: string;
defaultOpen?: boolean;
children: ReactNode;
}
export function Section({ title, defaultOpen = true, children }: Props) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className="glass rounded-2xl overflow-hidden">
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 text-left hover:bg-white/[0.03]"
>
<span className="text-sm font-medium text-white">{title}</span>
<motion.span
animate={{ rotate: open ? 180 : 0 }}
className="text-white/40 text-xs"
>
▾
</motion.span>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
>
<div className="px-4 pb-4">{children}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

View File

@ -0,0 +1,42 @@
import { motion } from 'framer-motion';
import type { StyleId } from '../types';
import { TEMPLATE_LIST } from '../styles/templates';
interface Props {
value: StyleId;
onChange: (id: StyleId) => void;
}
export function StylePicker({ value, onChange }: Props) {
return (
<div className="grid grid-cols-2 gap-2">
{TEMPLATE_LIST.map((t) => {
const active = t.id === value;
return (
<motion.button
key={t.id}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.97 }}
onClick={() => onChange(t.id)}
className={`relative rounded-xl border p-3 text-left overflow-hidden transition ${
active
? 'border-fuchsia-400/70 bg-fuchsia-500/10'
: 'border-white/10 bg-white/[0.02] hover:border-white/20'
}`}
>
<div
className="absolute inset-0 opacity-30"
style={{ background: t.defaultGradient }}
/>
<div className="relative">
<div className="text-sm font-medium text-white">{t.name}</div>
<div className="text-[11px] text-white/60 mt-0.5 leading-tight">
{t.description}
</div>
</div>
</motion.button>
);
})}
</div>
);
}

38
src/index.css Normal file
View File

@ -0,0 +1,38 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body,
#root {
height: 100%;
}
body {
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
/* Hide number input spinners */
input[type='number']::-webkit-outer-spin-button,
input[type='number']::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.glass {
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.card-shadow {
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6),
0 10px 30px -10px rgba(0, 0, 0, 0.4);
}
@media print {
body {
background: white;
}
}

39
src/lib/export.ts Normal file
View File

@ -0,0 +1,39 @@
import { toPng } from 'html-to-image';
import { jsPDF } from 'jspdf';
async function nodeToPng(node: HTMLElement, scale = 2): Promise<string> {
await document.fonts.ready;
return toPng(node, {
cacheBust: true,
pixelRatio: scale,
backgroundColor: '#ffffff',
});
}
function triggerDownload(dataUrl: string, filename: string) {
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
export async function exportPng(node: HTMLElement, filename = 'invitation.png') {
const url = await nodeToPng(node, 2.5);
triggerDownload(url, filename);
}
export async function exportPdf(node: HTMLElement, filename = 'invitation.pdf') {
const url = await nodeToPng(node, 3);
const rect = node.getBoundingClientRect();
const orientation = rect.width > rect.height ? 'landscape' : 'portrait';
const pdf = new jsPDF({
orientation,
unit: 'px',
format: [rect.width, rect.height],
hotfixes: ['px_scaling'],
});
pdf.addImage(url, 'PNG', 0, 0, rect.width, rect.height, undefined, 'FAST');
pdf.save(filename);
}

96
src/lib/openai.ts Normal file
View File

@ -0,0 +1,96 @@
const KEY_STORAGE = 'venera.openai.key';
export function getStoredKey(): string {
return localStorage.getItem(KEY_STORAGE) ?? '';
}
export function setStoredKey(key: string): void {
if (key) localStorage.setItem(KEY_STORAGE, key);
else localStorage.removeItem(KEY_STORAGE);
}
export interface GenerateOpts {
prompt: string;
size?: '1024x1024' | '1024x1792' | '1792x1024';
model?: 'dall-e-3' | 'dall-e-2' | 'gpt-image-1';
quality?: 'standard' | 'hd';
style?: 'vivid' | 'natural';
}
export async function generateBackground(
opts: GenerateOpts
): Promise<string> {
const key = getStoredKey();
if (!key) throw new Error('Сначала введите ключ OpenAI API.');
const model = opts.model ?? 'dall-e-3';
const body: Record<string, unknown> = {
model,
prompt: opts.prompt,
size: opts.size ?? '1024x1792',
n: 1,
};
if (model === 'dall-e-3') {
body.quality = opts.quality ?? 'standard';
body.style = opts.style ?? 'vivid';
body.response_format = 'b64_json';
} else if (model === 'dall-e-2') {
body.response_format = 'b64_json';
}
const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${key}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text();
let msg = `OpenAI ${res.status}`;
try {
const j = JSON.parse(text);
msg = j.error?.message ?? msg;
} catch {
msg = text || msg;
}
throw new Error(msg);
}
const data = await res.json();
const item = data.data?.[0];
if (item?.b64_json) return `data:image/png;base64,${item.b64_json}`;
if (item?.url) return item.url as string;
throw new Error('Ответ OpenAI без изображения.');
}
const PROMPT_TEMPLATES: Record<string, string> = {
wedding:
'romantic wedding invitation background, soft floral details, elegant abstract, watercolor wash, dreamy bokeh, ultra detailed, no text',
birthday:
'festive birthday celebration background, golden confetti, balloons abstract, vibrant gradient, joyful, no text',
anniversary:
'anniversary celebration background, soft golden glow, delicate floral abstraction, dreamy atmosphere, no text',
corporate:
'minimalist corporate event background, geometric subtle pattern, elegant, professional, no text',
baby:
'baby shower invitation background, pastel colors, soft clouds, gentle stars, dreamy, no text',
other:
'elegant event invitation background, abstract artistic gradient, refined, no text',
};
export function suggestPrompt(eventType: string, styleId: string): string {
const base = PROMPT_TEMPLATES[eventType] ?? PROMPT_TEMPLATES.other;
const styleHint: Record<string, string> = {
elegant: 'luxurious, gold accents, deep jewel tones',
classic: 'monochrome, minimal, refined',
modern: 'bold, contemporary, graphic',
romantic: 'soft pastel, dreamy, painterly',
festive: 'bright, colorful, energetic',
minimal: 'extremely minimal, light, airy, lots of negative space',
};
return `${base}, ${styleHint[styleId] ?? ''}, portrait orientation, vertical poster`;
}

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

128
src/styles/templates.ts Normal file
View File

@ -0,0 +1,128 @@
import type { StyleId } from '../types';
export interface StyleTemplate {
id: StyleId;
name: string;
description: string;
fontTitle: string;
fontBody: string;
fontNames: string;
titleClass: string;
namesClass: string;
bodyClass: string;
divider: 'line' | 'ornament' | 'dot' | 'star' | 'none';
textColor: string;
accent: string;
defaultGradient: string;
}
export const TEMPLATES: Record<StyleId, StyleTemplate> = {
elegant: {
id: 'elegant',
name: 'Элегантный',
description: 'Классическая роскошь, золотые акценты',
fontTitle: 'font-display',
fontBody: 'font-display',
fontNames: 'font-script',
titleClass: 'tracking-[0.4em] uppercase text-sm font-medium',
namesClass: 'text-6xl md:text-7xl leading-tight',
bodyClass: 'font-display text-base leading-relaxed',
divider: 'ornament',
textColor: '#fdf6e3',
accent: '#f5d76e',
defaultGradient:
'linear-gradient(135deg, #1a0f2e 0%, #4c1d95 50%, #831843 100%)',
},
classic: {
id: 'classic',
name: 'Классика',
description: 'Строгая типографика, безупречный баланс',
fontTitle: 'font-display',
fontBody: 'font-display',
fontNames: 'font-display',
titleClass: 'tracking-[0.5em] uppercase text-xs',
namesClass: 'text-5xl md:text-6xl font-light tracking-wide',
bodyClass: 'font-display text-base leading-loose',
divider: 'line',
textColor: '#f5f5f4',
accent: '#d6d3d1',
defaultGradient:
'linear-gradient(160deg, #1c1917 0%, #292524 100%)',
},
modern: {
id: 'modern',
name: 'Современный',
description: 'Смелый шрифт, чёткие линии',
fontTitle: 'font-modern',
fontBody: 'font-sans',
fontNames: 'font-modern',
titleClass: 'uppercase text-xs font-bold tracking-[0.3em]',
namesClass: 'text-6xl md:text-7xl font-black leading-none',
bodyClass: 'font-sans text-sm leading-relaxed',
divider: 'dot',
textColor: '#ffffff',
accent: '#fb7185',
defaultGradient:
'linear-gradient(120deg, #0f172a 0%, #1e293b 50%, #be185d 100%)',
},
romantic: {
id: 'romantic',
name: 'Романтика',
description: 'Нежные пастельные оттенки, рукописный стиль',
fontTitle: 'font-display',
fontBody: 'font-display',
fontNames: 'font-script',
titleClass: 'italic text-sm tracking-wider',
namesClass: 'text-7xl md:text-8xl leading-tight',
bodyClass: 'font-display italic text-base leading-relaxed',
divider: 'ornament',
textColor: '#3f2e3e',
accent: '#c084fc',
defaultGradient:
'linear-gradient(135deg, #fdf2f8 0%, #fae8ff 50%, #fce7f3 100%)',
},
festive: {
id: 'festive',
name: 'Праздничный',
description: 'Яркие цвета, звёздные акценты',
fontTitle: 'font-modern',
fontBody: 'font-sans',
fontNames: 'font-modern',
titleClass: 'uppercase font-bold tracking-[0.3em] text-sm',
namesClass: 'text-6xl md:text-7xl font-black',
bodyClass: 'font-sans text-base',
divider: 'star',
textColor: '#fffbeb',
accent: '#fde047',
defaultGradient:
'linear-gradient(135deg, #7c2d12 0%, #be185d 50%, #581c87 100%)',
},
minimal: {
id: 'minimal',
name: 'Минимализм',
description: 'Чистое пространство, тонкая линия',
fontTitle: 'font-sans',
fontBody: 'font-sans',
fontNames: 'font-sans',
titleClass: 'uppercase text-[10px] tracking-[0.5em] font-medium',
namesClass: 'text-4xl md:text-5xl font-light tracking-tight',
bodyClass: 'font-sans text-sm leading-relaxed font-light',
divider: 'line',
textColor: '#0a0a0a',
accent: '#737373',
defaultGradient: '#fafaf9',
},
};
export const TEMPLATE_LIST = Object.values(TEMPLATES);
export const GRADIENT_PRESETS: { name: string; value: string }[] = [
{ name: 'Аметист', value: 'linear-gradient(135deg, #2a1454 0%, #6d28d9 50%, #db2777 100%)' },
{ name: 'Закат', value: 'linear-gradient(135deg, #f97316 0%, #db2777 50%, #7c3aed 100%)' },
{ name: 'Океан', value: 'linear-gradient(135deg, #0c4a6e 0%, #0369a1 50%, #06b6d4 100%)' },
{ name: 'Лес', value: 'linear-gradient(135deg, #064e3b 0%, #047857 50%, #84cc16 100%)' },
{ name: 'Роза', value: 'linear-gradient(135deg, #fdf2f8 0%, #fae8ff 50%, #fce7f3 100%)' },
{ name: 'Полночь', value: 'linear-gradient(160deg, #020617 0%, #1e1b4b 100%)' },
{ name: 'Золото', value: 'linear-gradient(135deg, #78350f 0%, #b45309 50%, #fbbf24 100%)' },
{ name: 'Лаванда', value: 'linear-gradient(135deg, #4c1d95 0%, #7e22ce 50%, #c084fc 100%)' },
];

56
src/types.ts Normal file
View File

@ -0,0 +1,56 @@
export type StyleId =
| 'elegant'
| 'classic'
| 'modern'
| 'romantic'
| 'festive'
| 'minimal';
export type EventType =
| 'wedding'
| 'birthday'
| 'anniversary'
| 'corporate'
| 'baby'
| 'other';
export type BackgroundMode = 'gradient' | 'solid' | 'ai';
export interface CardData {
eventType: EventType;
title: string;
subtitle: string;
names: string;
date: string;
time: string;
location: string;
message: string;
rsvp: string;
styleId: StyleId;
bgMode: BackgroundMode;
bgColor: string;
bgGradient: string;
bgImage: string | null;
overlayOpacity: number;
accentColor: string;
}
export const DEFAULT_CARD: CardData = {
eventType: 'wedding',
title: 'Приглашение',
subtitle: 'на торжество',
names: 'Алия & Данияр',
date: '15 июня 2026',
time: '17:00',
location: 'Ресторан «Венера», Алматы',
message:
'С большим волнением и радостью приглашаем вас разделить с нами один из самых счастливых дней нашей жизни.',
rsvp: 'Подтвердите присутствие: +7 700 000 00 00',
styleId: 'elegant',
bgMode: 'gradient',
bgColor: '#1a0f2e',
bgGradient: 'linear-gradient(135deg, #2a1454 0%, #6d28d9 50%, #db2777 100%)',
bgImage: null,
overlayOpacity: 0.35,
accentColor: '#f5d76e',
};

16
tailwind.config.js Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
fontFamily: {
display: ['"Cormorant Garamond"', 'serif'],
sans: ['Inter', 'system-ui', 'sans-serif'],
script: ['"Great Vibes"', 'cursive'],
modern: ['"Playfair Display"', 'serif'],
mono: ['"JetBrains Mono"', 'monospace'],
},
},
},
plugins: [],
};

20
tsconfig.app.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" }
]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
base: './',
build: {
outDir: 'dist',
assetsDir: 'assets',
},
});