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/
|
||||
out/
|
||||
target/
|
||||
*.tsbuildinfo
|
||||
|
||||
# data
|
||||
*.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