email-builder-pro 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INTEGRATION.md +250 -0
- package/PUBLISH.md +116 -0
- package/README.md +158 -0
- package/app/globals.css +46 -0
- package/components/Canvas.tsx +78 -0
- package/components/ComponentPalette.tsx +297 -0
- package/components/ComponentRenderer.tsx +496 -0
- package/components/DraggableComponent.tsx +84 -0
- package/components/DroppableComponent.tsx +80 -0
- package/components/EmailBuilder.tsx +59 -0
- package/components/ImageUpload.tsx +186 -0
- package/components/NumberInput.tsx +73 -0
- package/components/PreviewPanel.tsx +125 -0
- package/components/PropertiesPanel.tsx +1386 -0
- package/components/TemplateManager.tsx +104 -0
- package/components/Toolbar.tsx +242 -0
- package/lib/store.ts +198 -0
- package/package.json +76 -0
- package/postcss.config.js +8 -0
- package/src/index.tsx +42 -0
- package/tailwind.config.js +29 -0
- package/types/email.ts +22 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import ComponentPalette from './ComponentPalette';
|
|
5
|
+
import Canvas from './Canvas';
|
|
6
|
+
import PropertiesPanel from './PropertiesPanel';
|
|
7
|
+
import PreviewPanel from './PreviewPanel';
|
|
8
|
+
import Toolbar from './Toolbar';
|
|
9
|
+
import TemplateManager from './TemplateManager';
|
|
10
|
+
import { useEmailBuilder } from '@/lib/store';
|
|
11
|
+
|
|
12
|
+
export default function EmailBuilder() {
|
|
13
|
+
const [showPreview, setShowPreview] = useState(false);
|
|
14
|
+
const [showTemplates, setShowTemplates] = useState(false);
|
|
15
|
+
const { components } = useEmailBuilder();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="h-screen flex flex-col bg-gray-50">
|
|
19
|
+
<Toolbar
|
|
20
|
+
onPreview={() => setShowPreview(!showPreview)}
|
|
21
|
+
onTemplates={() => setShowTemplates(!showTemplates)}
|
|
22
|
+
showPreview={showPreview}
|
|
23
|
+
/>
|
|
24
|
+
|
|
25
|
+
<div className="flex-1 flex overflow-hidden">
|
|
26
|
+
{/* Component Palette */}
|
|
27
|
+
<div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
|
|
28
|
+
<ComponentPalette />
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{/* Main Canvas */}
|
|
32
|
+
<div className="flex-1 flex flex-col overflow-hidden">
|
|
33
|
+
<Canvas />
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
{/* Properties Panel */}
|
|
37
|
+
{!showPreview && (
|
|
38
|
+
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto">
|
|
39
|
+
<PropertiesPanel />
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
|
|
43
|
+
{/* Preview Panel */}
|
|
44
|
+
{showPreview && (
|
|
45
|
+
<div className="w-1/2 bg-white border-l border-gray-200 overflow-y-auto">
|
|
46
|
+
<PreviewPanel />
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
{/* Template Manager Modal */}
|
|
52
|
+
{showTemplates && (
|
|
53
|
+
<TemplateManager onClose={() => setShowTemplates(false)} />
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useRef } from 'react';
|
|
4
|
+
import { Upload, X, Maximize2 } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
interface ImageUploadProps {
|
|
7
|
+
value: string;
|
|
8
|
+
onChange: (url: string) => void;
|
|
9
|
+
onResize?: (width: number, height: number) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export default function ImageUpload({ value, onChange, onResize }: ImageUploadProps) {
|
|
13
|
+
const [isUploading, setIsUploading] = useState(false);
|
|
14
|
+
const [showResizer, setShowResizer] = useState(false);
|
|
15
|
+
const [resizeWidth, setResizeWidth] = useState(400);
|
|
16
|
+
const [resizeHeight, setResizeHeight] = useState(300);
|
|
17
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
19
|
+
|
|
20
|
+
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
21
|
+
const file = e.target.files?.[0];
|
|
22
|
+
if (!file) return;
|
|
23
|
+
|
|
24
|
+
if (!file.type.startsWith('image/')) {
|
|
25
|
+
alert('Please select an image file');
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
setIsUploading(true);
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// Convert to base64 for preview (in production, upload to server)
|
|
33
|
+
const reader = new FileReader();
|
|
34
|
+
reader.onload = (event) => {
|
|
35
|
+
const base64 = event.target?.result as string;
|
|
36
|
+
onChange(base64);
|
|
37
|
+
|
|
38
|
+
// Get image dimensions
|
|
39
|
+
const img = new window.Image();
|
|
40
|
+
img.onload = () => {
|
|
41
|
+
setResizeWidth(img.width);
|
|
42
|
+
setResizeHeight(img.height);
|
|
43
|
+
};
|
|
44
|
+
img.src = base64;
|
|
45
|
+
setIsUploading(false);
|
|
46
|
+
};
|
|
47
|
+
reader.readAsDataURL(file);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.error('Error uploading image:', error);
|
|
50
|
+
setIsUploading(false);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const handleResize = () => {
|
|
55
|
+
if (!value || !canvasRef.current) return;
|
|
56
|
+
|
|
57
|
+
const img = new window.Image();
|
|
58
|
+
img.onload = () => {
|
|
59
|
+
const canvas = canvasRef.current!;
|
|
60
|
+
canvas.width = resizeWidth;
|
|
61
|
+
canvas.height = resizeHeight;
|
|
62
|
+
const ctx = canvas.getContext('2d');
|
|
63
|
+
if (ctx) {
|
|
64
|
+
ctx.drawImage(img, 0, 0, resizeWidth, resizeHeight);
|
|
65
|
+
const resizedDataUrl = canvas.toDataURL('image/png');
|
|
66
|
+
onChange(resizedDataUrl);
|
|
67
|
+
if (onResize) {
|
|
68
|
+
onResize(resizeWidth, resizeHeight);
|
|
69
|
+
}
|
|
70
|
+
setShowResizer(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
img.src = value;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div className="space-y-4">
|
|
78
|
+
<div>
|
|
79
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
80
|
+
Image
|
|
81
|
+
</label>
|
|
82
|
+
{value ? (
|
|
83
|
+
<div className="relative group">
|
|
84
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
85
|
+
<img
|
|
86
|
+
src={value}
|
|
87
|
+
alt="Preview"
|
|
88
|
+
className="w-full h-32 object-cover rounded-md border border-gray-300"
|
|
89
|
+
/>
|
|
90
|
+
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-50 transition-all rounded-md flex items-center justify-center gap-2">
|
|
91
|
+
<button
|
|
92
|
+
onClick={() => fileInputRef.current?.click()}
|
|
93
|
+
className="opacity-0 group-hover:opacity-100 p-2 bg-white rounded-md hover:bg-gray-100 transition-all"
|
|
94
|
+
>
|
|
95
|
+
<Upload size={16} />
|
|
96
|
+
</button>
|
|
97
|
+
<button
|
|
98
|
+
onClick={() => setShowResizer(true)}
|
|
99
|
+
className="opacity-0 group-hover:opacity-100 p-2 bg-white rounded-md hover:bg-gray-100 transition-all"
|
|
100
|
+
>
|
|
101
|
+
<Maximize2 size={16} />
|
|
102
|
+
</button>
|
|
103
|
+
<button
|
|
104
|
+
onClick={() => onChange('')}
|
|
105
|
+
className="opacity-0 group-hover:opacity-100 p-2 bg-white rounded-md hover:bg-gray-100 transition-all"
|
|
106
|
+
>
|
|
107
|
+
<X size={16} />
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
) : (
|
|
112
|
+
<div
|
|
113
|
+
onClick={() => fileInputRef.current?.click()}
|
|
114
|
+
className="border-2 border-dashed border-gray-300 rounded-md p-8 text-center cursor-pointer hover:border-primary-400 transition-colors"
|
|
115
|
+
>
|
|
116
|
+
<Upload className="mx-auto mb-2 text-gray-400" size={24} />
|
|
117
|
+
<p className="text-sm text-gray-600">
|
|
118
|
+
{isUploading ? 'Uploading...' : 'Click to upload image'}
|
|
119
|
+
</p>
|
|
120
|
+
</div>
|
|
121
|
+
)}
|
|
122
|
+
<input
|
|
123
|
+
ref={fileInputRef}
|
|
124
|
+
type="file"
|
|
125
|
+
accept="image/*"
|
|
126
|
+
onChange={handleFileSelect}
|
|
127
|
+
className="hidden"
|
|
128
|
+
/>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{showResizer && value && (
|
|
132
|
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
133
|
+
<div className="bg-white rounded-lg p-6 w-full max-w-2xl">
|
|
134
|
+
<h3 className="text-lg font-semibold mb-4">Resize Image</h3>
|
|
135
|
+
<div className="grid grid-cols-2 gap-4 mb-4">
|
|
136
|
+
<div>
|
|
137
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
138
|
+
Width (px)
|
|
139
|
+
</label>
|
|
140
|
+
<input
|
|
141
|
+
type="number"
|
|
142
|
+
value={resizeWidth}
|
|
143
|
+
onChange={(e) => setResizeWidth(parseInt(e.target.value) || 0)}
|
|
144
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
<div>
|
|
148
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
149
|
+
Height (px)
|
|
150
|
+
</label>
|
|
151
|
+
<input
|
|
152
|
+
type="number"
|
|
153
|
+
value={resizeHeight}
|
|
154
|
+
onChange={(e) => setResizeHeight(parseInt(e.target.value) || 0)}
|
|
155
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
156
|
+
/>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div className="mb-4 border border-gray-300 rounded-md p-4 bg-gray-50">
|
|
160
|
+
<canvas
|
|
161
|
+
ref={canvasRef}
|
|
162
|
+
className="max-w-full h-auto mx-auto"
|
|
163
|
+
style={{ maxHeight: '400px' }}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
<div className="flex justify-end gap-2">
|
|
167
|
+
<button
|
|
168
|
+
onClick={() => setShowResizer(false)}
|
|
169
|
+
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md"
|
|
170
|
+
>
|
|
171
|
+
Cancel
|
|
172
|
+
</button>
|
|
173
|
+
<button
|
|
174
|
+
onClick={handleResize}
|
|
175
|
+
className="px-4 py-2 text-sm font-medium bg-primary-500 text-white rounded-md hover:bg-primary-600"
|
|
176
|
+
>
|
|
177
|
+
Apply Resize
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ChevronUp, ChevronDown } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
interface NumberInputProps {
|
|
6
|
+
label: string;
|
|
7
|
+
value: number;
|
|
8
|
+
onChange: (value: number) => void;
|
|
9
|
+
min?: number;
|
|
10
|
+
max?: number;
|
|
11
|
+
step?: number;
|
|
12
|
+
unit?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function NumberInput({
|
|
16
|
+
label,
|
|
17
|
+
value,
|
|
18
|
+
onChange,
|
|
19
|
+
min = 0,
|
|
20
|
+
max = 1000,
|
|
21
|
+
step = 1,
|
|
22
|
+
unit = '',
|
|
23
|
+
}: NumberInputProps) {
|
|
24
|
+
const handleIncrement = () => {
|
|
25
|
+
const newValue = Math.min(value + step, max);
|
|
26
|
+
onChange(newValue);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const handleDecrement = () => {
|
|
30
|
+
const newValue = Math.max(value - step, min);
|
|
31
|
+
onChange(newValue);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div className="mb-4">
|
|
36
|
+
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
37
|
+
{label} {unit && `(${unit})`}
|
|
38
|
+
</label>
|
|
39
|
+
<div className="flex items-center gap-1">
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
onClick={handleDecrement}
|
|
43
|
+
disabled={value <= min}
|
|
44
|
+
className="p-1.5 border border-gray-300 rounded-l-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
45
|
+
>
|
|
46
|
+
<ChevronDown size={14} />
|
|
47
|
+
</button>
|
|
48
|
+
<input
|
|
49
|
+
type="number"
|
|
50
|
+
value={value}
|
|
51
|
+
onChange={(e) => {
|
|
52
|
+
const val = parseInt(e.target.value) || 0;
|
|
53
|
+
onChange(Math.max(min, Math.min(val, max)));
|
|
54
|
+
}}
|
|
55
|
+
min={min}
|
|
56
|
+
max={max}
|
|
57
|
+
step={step}
|
|
58
|
+
className="flex-1 px-3 py-2 border-t border-b border-gray-300 text-sm text-center focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
59
|
+
/>
|
|
60
|
+
<button
|
|
61
|
+
type="button"
|
|
62
|
+
onClick={handleIncrement}
|
|
63
|
+
disabled={value >= max}
|
|
64
|
+
className="p-1.5 border border-gray-300 rounded-r-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
65
|
+
>
|
|
66
|
+
<ChevronUp size={14} />
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEmailBuilder } from '@/lib/store';
|
|
4
|
+
import ComponentRenderer from './ComponentRenderer';
|
|
5
|
+
import {
|
|
6
|
+
Html,
|
|
7
|
+
Head,
|
|
8
|
+
Body,
|
|
9
|
+
Container,
|
|
10
|
+
Preview,
|
|
11
|
+
Tailwind,
|
|
12
|
+
} from '@react-email/components';
|
|
13
|
+
|
|
14
|
+
export default function PreviewPanel() {
|
|
15
|
+
const { components } = useEmailBuilder();
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<div className="h-full flex flex-col">
|
|
19
|
+
<div className="p-4 border-b border-gray-200 bg-white">
|
|
20
|
+
<h2 className="text-sm font-semibold text-gray-700">Live Preview</h2>
|
|
21
|
+
<p className="text-xs text-gray-500 mt-1">
|
|
22
|
+
Real-time preview of your email template
|
|
23
|
+
</p>
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
<div className="flex-1 overflow-y-auto bg-gray-100 p-8">
|
|
27
|
+
<div className="max-w-2xl mx-auto bg-white shadow-lg rounded-lg p-8">
|
|
28
|
+
{components.length === 0 ? (
|
|
29
|
+
<div className="text-center py-12">
|
|
30
|
+
<p className="text-gray-400">No components to preview</p>
|
|
31
|
+
</div>
|
|
32
|
+
) : (
|
|
33
|
+
<div className="email-preview">
|
|
34
|
+
{components.map((component, index) => (
|
|
35
|
+
<ComponentRenderer
|
|
36
|
+
key={component.id}
|
|
37
|
+
component={component}
|
|
38
|
+
index={index}
|
|
39
|
+
/>
|
|
40
|
+
))}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{/* React Email Preview */}
|
|
47
|
+
<div className="border-t border-gray-200 bg-white p-4">
|
|
48
|
+
<details className="text-sm">
|
|
49
|
+
<summary className="cursor-pointer font-medium text-gray-700 hover:text-gray-900">
|
|
50
|
+
React Email Code Preview
|
|
51
|
+
</summary>
|
|
52
|
+
<div className="mt-4 p-4 bg-gray-50 rounded-md overflow-x-auto">
|
|
53
|
+
<pre className="text-xs text-gray-700">
|
|
54
|
+
{generateReactEmailCode(components)}
|
|
55
|
+
</pre>
|
|
56
|
+
</div>
|
|
57
|
+
</details>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function generateReactEmailCode(components: any[]): string {
|
|
64
|
+
return `import {
|
|
65
|
+
Body,
|
|
66
|
+
Container,
|
|
67
|
+
Head,
|
|
68
|
+
Html,
|
|
69
|
+
Preview,
|
|
70
|
+
Tailwind,
|
|
71
|
+
Text,
|
|
72
|
+
Heading,
|
|
73
|
+
Button,
|
|
74
|
+
Img,
|
|
75
|
+
Hr,
|
|
76
|
+
Section,
|
|
77
|
+
} from '@react-email/components';
|
|
78
|
+
|
|
79
|
+
export default function EmailTemplate() {
|
|
80
|
+
return (
|
|
81
|
+
<Html>
|
|
82
|
+
<Head />
|
|
83
|
+
<Preview>Email Preview</Preview>
|
|
84
|
+
<Tailwind>
|
|
85
|
+
<Body className="mx-auto my-auto bg-white px-2 font-sans">
|
|
86
|
+
<Container className="mx-auto my-[40px] max-w-[600px]">
|
|
87
|
+
${renderComponentsToReactEmail(components, 12)}
|
|
88
|
+
</Container>
|
|
89
|
+
</Body>
|
|
90
|
+
</Tailwind>
|
|
91
|
+
</Html>
|
|
92
|
+
);
|
|
93
|
+
}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function renderComponentsToReactEmail(
|
|
97
|
+
components: any[],
|
|
98
|
+
indent: number
|
|
99
|
+
): string {
|
|
100
|
+
const spaces = ' '.repeat(indent);
|
|
101
|
+
return components
|
|
102
|
+
.map((comp) => {
|
|
103
|
+
switch (comp.type) {
|
|
104
|
+
case 'text':
|
|
105
|
+
return `${spaces}<Text className="text-[${comp.props.fontSize || 16}px] text-[${comp.props.color || '#000000'}] text-${comp.props.textAlign || 'left'}">${comp.props.text || ''}</Text>`;
|
|
106
|
+
case 'heading':
|
|
107
|
+
const HeadingTag = `Heading`;
|
|
108
|
+
return `${spaces}<${HeadingTag} className="text-[${comp.props.fontSize || 24}px] text-[${comp.props.color || '#000000'}] text-${comp.props.textAlign || 'left'}">${comp.props.text || ''}</${HeadingTag}>`;
|
|
109
|
+
case 'button':
|
|
110
|
+
return `${spaces}<Button href="${comp.props.href || '#'}" className="bg-[${comp.props.backgroundColor || '#007bff'}] text-[${comp.props.color || '#ffffff'}] px-[${(comp.props.padding || 12) * 2}px] py-[${comp.props.padding || 12}px] rounded-[${comp.props.borderRadius || 4}px]">${comp.props.text || 'Button'}</Button>`;
|
|
111
|
+
case 'image':
|
|
112
|
+
return `${spaces}<Img src="${comp.props.src || ''}" alt="${comp.props.alt || ''}" width="${comp.props.width || '100%'}" />`;
|
|
113
|
+
case 'divider':
|
|
114
|
+
return `${spaces}<Hr className="border-[${comp.props.color || '#e0e0e0'}] border-t-[${comp.props.height || 1}px]" />`;
|
|
115
|
+
case 'container':
|
|
116
|
+
return `${spaces}<Section className="bg-[${comp.props.backgroundColor || '#ffffff'}] p-[${comp.props.padding || 20}px] m-[${comp.props.margin || 0}px]">\n${comp.children ? renderComponentsToReactEmail(comp.children, indent + 2) : ''}\n${spaces}</Section>`;
|
|
117
|
+
default:
|
|
118
|
+
return '';
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
.filter(Boolean)
|
|
122
|
+
.join('\n');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|