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:
parent
4aeaac7a26
commit
7107f42792
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,6 +20,7 @@ dist/
|
|||||||
build/
|
build/
|
||||||
out/
|
out/
|
||||||
target/
|
target/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
# data
|
# data
|
||||||
*.sqlite*
|
*.sqlite*
|
||||||
|
|||||||
16
TRD.md
16
TRD.md
@ -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
1
favicon.svg
Normal 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
19
index.html
Normal 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
2945
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
package.json
Normal file
28
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
171
src/App.tsx
Normal file
171
src/App.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
183
src/components/BackgroundPanel.tsx
Normal file
183
src/components/BackgroundPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
141
src/components/CardPreview.tsx
Normal file
141
src/components/CardPreview.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
59
src/components/Divider.tsx
Normal file
59
src/components/Divider.tsx
Normal 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
118
src/components/Editor.tsx
Normal 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
24
src/components/Field.tsx
Normal 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';
|
||||||
40
src/components/Section.tsx
Normal file
40
src/components/Section.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/StylePicker.tsx
Normal file
42
src/components/StylePicker.tsx
Normal 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
38
src/index.css
Normal 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
39
src/lib/export.ts
Normal 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
96
src/lib/openai.ts
Normal 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
10
src/main.tsx
Normal 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
128
src/styles/templates.ts
Normal 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
56
src/types.ts
Normal 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
16
tailwind.config.js
Normal 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
20
tsconfig.app.json
Normal 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
6
tsconfig.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
11
vite.config.ts
Normal file
11
vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user