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,104 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEmailBuilder } from '@/lib/store';
|
|
4
|
+
import { X, Trash2, FolderOpen } from 'lucide-react';
|
|
5
|
+
import { EmailTemplate } from '@/types/email';
|
|
6
|
+
|
|
7
|
+
interface TemplateManagerProps {
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function TemplateManager({ onClose }: TemplateManagerProps) {
|
|
12
|
+
const { templates, loadTemplate, deleteTemplate, clearComponents } =
|
|
13
|
+
useEmailBuilder();
|
|
14
|
+
|
|
15
|
+
const handleLoadTemplate = (template: EmailTemplate) => {
|
|
16
|
+
loadTemplate(template);
|
|
17
|
+
onClose();
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const handleDeleteTemplate = (id: string, e: React.MouseEvent) => {
|
|
21
|
+
e.stopPropagation();
|
|
22
|
+
if (confirm('Are you sure you want to delete this template?')) {
|
|
23
|
+
deleteTemplate(id);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
29
|
+
<div className="bg-white rounded-lg w-full max-w-2xl max-h-[80vh] flex flex-col">
|
|
30
|
+
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
|
31
|
+
<h2 className="text-xl font-semibold text-gray-800">Templates</h2>
|
|
32
|
+
<button
|
|
33
|
+
onClick={onClose}
|
|
34
|
+
className="p-2 hover:bg-gray-100 rounded-md transition-colors"
|
|
35
|
+
>
|
|
36
|
+
<X size={20} />
|
|
37
|
+
</button>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div className="flex-1 overflow-y-auto p-6">
|
|
41
|
+
{templates.length === 0 ? (
|
|
42
|
+
<div className="text-center py-12">
|
|
43
|
+
<FolderOpen size={48} className="mx-auto text-gray-300 mb-4" />
|
|
44
|
+
<p className="text-gray-400 mb-2">No templates saved yet</p>
|
|
45
|
+
<p className="text-sm text-gray-300">
|
|
46
|
+
Create and save templates to see them here
|
|
47
|
+
</p>
|
|
48
|
+
</div>
|
|
49
|
+
) : (
|
|
50
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
51
|
+
{templates.map((template) => (
|
|
52
|
+
<div
|
|
53
|
+
key={template.id}
|
|
54
|
+
className="border border-gray-200 rounded-lg p-4 hover:border-primary-300 hover:shadow-md transition-all cursor-pointer group"
|
|
55
|
+
onClick={() => handleLoadTemplate(template)}
|
|
56
|
+
>
|
|
57
|
+
<div className="flex items-start justify-between mb-2">
|
|
58
|
+
<h3 className="font-medium text-gray-800">
|
|
59
|
+
{template.name}
|
|
60
|
+
</h3>
|
|
61
|
+
<button
|
|
62
|
+
onClick={(e) => handleDeleteTemplate(template.id, e)}
|
|
63
|
+
className="opacity-0 group-hover:opacity-100 p-1 text-red-600 hover:bg-red-50 rounded transition-all"
|
|
64
|
+
>
|
|
65
|
+
<Trash2 size={16} />
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
<p className="text-xs text-gray-500 mb-2">
|
|
69
|
+
{template.components.length} component
|
|
70
|
+
{template.components.length !== 1 ? 's' : ''}
|
|
71
|
+
</p>
|
|
72
|
+
<p className="text-xs text-gray-400">
|
|
73
|
+
Created:{' '}
|
|
74
|
+
{new Date(template.createdAt).toLocaleDateString()}
|
|
75
|
+
</p>
|
|
76
|
+
</div>
|
|
77
|
+
))}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="p-6 border-t border-gray-200 flex justify-end gap-2">
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => {
|
|
85
|
+
clearComponents();
|
|
86
|
+
onClose();
|
|
87
|
+
}}
|
|
88
|
+
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md"
|
|
89
|
+
>
|
|
90
|
+
New Template
|
|
91
|
+
</button>
|
|
92
|
+
<button
|
|
93
|
+
onClick={onClose}
|
|
94
|
+
className="px-4 py-2 text-sm font-medium bg-primary-500 text-white rounded-md hover:bg-primary-600"
|
|
95
|
+
>
|
|
96
|
+
Close
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Eye, Save, Download, FileText, FolderOpen } from 'lucide-react';
|
|
4
|
+
import { useEmailBuilder } from '@/lib/store';
|
|
5
|
+
import { useState } from 'react';
|
|
6
|
+
|
|
7
|
+
interface ToolbarProps {
|
|
8
|
+
onPreview: () => void;
|
|
9
|
+
onTemplates: () => void;
|
|
10
|
+
showPreview: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default function Toolbar({ onPreview, onTemplates, showPreview }: ToolbarProps) {
|
|
14
|
+
const { components, saveTemplate, clearComponents } = useEmailBuilder();
|
|
15
|
+
const [templateName, setTemplateName] = useState('');
|
|
16
|
+
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
|
17
|
+
|
|
18
|
+
const handleSave = () => {
|
|
19
|
+
if (templateName.trim()) {
|
|
20
|
+
saveTemplate(templateName);
|
|
21
|
+
setTemplateName('');
|
|
22
|
+
setShowSaveDialog(false);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const handleExportHTML = () => {
|
|
27
|
+
const html = generateHTML(components);
|
|
28
|
+
const blob = new Blob([html], { type: 'text/html' });
|
|
29
|
+
const url = URL.createObjectURL(blob);
|
|
30
|
+
const a = document.createElement('a');
|
|
31
|
+
a.href = url;
|
|
32
|
+
a.download = 'email-template.html';
|
|
33
|
+
a.click();
|
|
34
|
+
URL.revokeObjectURL(url);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleExportReact = () => {
|
|
38
|
+
const reactCode = generateReactComponent(components);
|
|
39
|
+
const blob = new Blob([reactCode], { type: 'text/tsx' });
|
|
40
|
+
const url = URL.createObjectURL(blob);
|
|
41
|
+
const a = document.createElement('a');
|
|
42
|
+
a.href = url;
|
|
43
|
+
a.download = 'email-template.tsx';
|
|
44
|
+
a.click();
|
|
45
|
+
URL.revokeObjectURL(url);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="h-14 bg-white border-b border-gray-200 flex items-center justify-between px-4 shadow-sm">
|
|
50
|
+
<div className="flex items-center gap-2">
|
|
51
|
+
<h1 className="text-xl font-bold text-gray-800">Email Builder</h1>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div className="flex items-center gap-2">
|
|
55
|
+
<button
|
|
56
|
+
onClick={onTemplates}
|
|
57
|
+
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md flex items-center gap-2"
|
|
58
|
+
>
|
|
59
|
+
<FolderOpen size={16} />
|
|
60
|
+
Templates
|
|
61
|
+
</button>
|
|
62
|
+
|
|
63
|
+
<button
|
|
64
|
+
onClick={() => setShowSaveDialog(true)}
|
|
65
|
+
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md flex items-center gap-2"
|
|
66
|
+
>
|
|
67
|
+
<Save size={16} />
|
|
68
|
+
Save
|
|
69
|
+
</button>
|
|
70
|
+
|
|
71
|
+
<div className="w-px h-6 bg-gray-300 mx-1" />
|
|
72
|
+
|
|
73
|
+
<button
|
|
74
|
+
onClick={handleExportHTML}
|
|
75
|
+
disabled={components.length === 0}
|
|
76
|
+
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
77
|
+
>
|
|
78
|
+
<Download size={16} />
|
|
79
|
+
Export HTML
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
<button
|
|
83
|
+
onClick={handleExportReact}
|
|
84
|
+
disabled={components.length === 0}
|
|
85
|
+
className="px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
86
|
+
>
|
|
87
|
+
<FileText size={16} />
|
|
88
|
+
Export React
|
|
89
|
+
</button>
|
|
90
|
+
|
|
91
|
+
<div className="w-px h-6 bg-gray-300 mx-1" />
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
onClick={onPreview}
|
|
95
|
+
className={`px-3 py-1.5 text-sm font-medium rounded-md flex items-center gap-2 ${
|
|
96
|
+
showPreview
|
|
97
|
+
? 'bg-primary-500 text-white'
|
|
98
|
+
: 'text-gray-700 hover:bg-gray-100'
|
|
99
|
+
}`}
|
|
100
|
+
>
|
|
101
|
+
<Eye size={16} />
|
|
102
|
+
Preview
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
{showSaveDialog && (
|
|
107
|
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
108
|
+
<div className="bg-white rounded-lg p-6 w-96">
|
|
109
|
+
<h2 className="text-lg font-semibold mb-4">Save Template</h2>
|
|
110
|
+
<input
|
|
111
|
+
type="text"
|
|
112
|
+
value={templateName}
|
|
113
|
+
onChange={(e) => setTemplateName(e.target.value)}
|
|
114
|
+
placeholder="Template name"
|
|
115
|
+
className="w-full px-3 py-2 border border-gray-300 rounded-md mb-4"
|
|
116
|
+
autoFocus
|
|
117
|
+
onKeyDown={(e) => {
|
|
118
|
+
if (e.key === 'Enter') handleSave();
|
|
119
|
+
if (e.key === 'Escape') setShowSaveDialog(false);
|
|
120
|
+
}}
|
|
121
|
+
/>
|
|
122
|
+
<div className="flex justify-end gap-2">
|
|
123
|
+
<button
|
|
124
|
+
onClick={() => setShowSaveDialog(false)}
|
|
125
|
+
className="px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md"
|
|
126
|
+
>
|
|
127
|
+
Cancel
|
|
128
|
+
</button>
|
|
129
|
+
<button
|
|
130
|
+
onClick={handleSave}
|
|
131
|
+
disabled={!templateName.trim()}
|
|
132
|
+
className="px-4 py-2 text-sm font-medium bg-primary-500 text-white rounded-md hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
133
|
+
>
|
|
134
|
+
Save
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function generateHTML(components: any[]): string {
|
|
145
|
+
// This is a simplified version - in production, you'd want a more robust HTML generator
|
|
146
|
+
return `<!DOCTYPE html>
|
|
147
|
+
<html>
|
|
148
|
+
<head>
|
|
149
|
+
<meta charset="utf-8">
|
|
150
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
151
|
+
<title>Email Template</title>
|
|
152
|
+
</head>
|
|
153
|
+
<body>
|
|
154
|
+
${renderComponentsToHTML(components)}
|
|
155
|
+
</body>
|
|
156
|
+
</html>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function renderComponentsToHTML(components: any[]): string {
|
|
160
|
+
return components.map(comp => {
|
|
161
|
+
switch (comp.type) {
|
|
162
|
+
case 'container':
|
|
163
|
+
return `<div style="${getStyles(comp.props)}">${comp.children ? renderComponentsToHTML(comp.children) : ''}</div>`;
|
|
164
|
+
case 'text':
|
|
165
|
+
return `<p style="${getStyles(comp.props)}">${comp.props.text || ''}</p>`;
|
|
166
|
+
case 'heading':
|
|
167
|
+
return `<h${comp.props.level || 1} style="${getStyles(comp.props)}">${comp.props.text || ''}</h${comp.props.level || 1}>`;
|
|
168
|
+
case 'button':
|
|
169
|
+
return `<a href="${comp.props.href || '#'}" style="${getStyles(comp.props)}">${comp.props.text || 'Button'}</a>`;
|
|
170
|
+
case 'image':
|
|
171
|
+
return `<img src="${comp.props.src || ''}" alt="${comp.props.alt || ''}" style="${getStyles(comp.props)}" />`;
|
|
172
|
+
case 'divider':
|
|
173
|
+
return `<hr style="${getStyles(comp.props)}" />`;
|
|
174
|
+
default:
|
|
175
|
+
return '';
|
|
176
|
+
}
|
|
177
|
+
}).join('');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getStyles(props: any): string {
|
|
181
|
+
const styles: string[] = [];
|
|
182
|
+
if (props.backgroundColor) styles.push(`background-color: ${props.backgroundColor}`);
|
|
183
|
+
if (props.color) styles.push(`color: ${props.color}`);
|
|
184
|
+
if (props.padding) styles.push(`padding: ${props.padding}px`);
|
|
185
|
+
if (props.margin) styles.push(`margin: ${props.margin}px`);
|
|
186
|
+
if (props.fontSize) styles.push(`font-size: ${props.fontSize}px`);
|
|
187
|
+
if (props.textAlign) styles.push(`text-align: ${props.textAlign}`);
|
|
188
|
+
return styles.join('; ');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function generateReactComponent(components: any[]): string {
|
|
192
|
+
return `import {
|
|
193
|
+
Body,
|
|
194
|
+
Container,
|
|
195
|
+
Head,
|
|
196
|
+
Html,
|
|
197
|
+
Preview,
|
|
198
|
+
Tailwind,
|
|
199
|
+
} from '@react-email/components';
|
|
200
|
+
|
|
201
|
+
export default function EmailTemplate() {
|
|
202
|
+
return (
|
|
203
|
+
<Html>
|
|
204
|
+
<Head />
|
|
205
|
+
<Preview>Email Preview</Preview>
|
|
206
|
+
<Tailwind>
|
|
207
|
+
<Body className="mx-auto my-auto bg-white px-2 font-sans">
|
|
208
|
+
<Container className="mx-auto my-[40px] max-w-[600px]">
|
|
209
|
+
${renderComponentsToReact(components)}
|
|
210
|
+
</Container>
|
|
211
|
+
</Body>
|
|
212
|
+
</Tailwind>
|
|
213
|
+
</Html>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function renderComponentsToReact(components: any[]): string {
|
|
220
|
+
return components.map(comp => {
|
|
221
|
+
switch (comp.type) {
|
|
222
|
+
case 'text':
|
|
223
|
+
return `<Text className="${getClassName(comp.props)}">${comp.props.text || ''}</Text>`;
|
|
224
|
+
case 'heading':
|
|
225
|
+
return `<Heading className="${getClassName(comp.props)}">${comp.props.text || ''}</Heading>`;
|
|
226
|
+
case 'button':
|
|
227
|
+
return `<Button href="${comp.props.href || '#'}" className="${getClassName(comp.props)}">${comp.props.text || 'Button'}</Button>`;
|
|
228
|
+
case 'image':
|
|
229
|
+
return `<Img src="${comp.props.src || ''}" alt="${comp.props.alt || ''}" className="${getClassName(comp.props)}" />`;
|
|
230
|
+
default:
|
|
231
|
+
return '';
|
|
232
|
+
}
|
|
233
|
+
}).join('\n ');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getClassName(props: any): string {
|
|
237
|
+
const classes: string[] = [];
|
|
238
|
+
if (props.textAlign) classes.push(`text-${props.textAlign}`);
|
|
239
|
+
if (props.fontSize) classes.push(`text-[${props.fontSize}px]`);
|
|
240
|
+
return classes.join(' ');
|
|
241
|
+
}
|
|
242
|
+
|
package/lib/store.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import { EmailComponent, EmailTemplate } from '@/types/email';
|
|
3
|
+
|
|
4
|
+
interface EmailBuilderState {
|
|
5
|
+
components: EmailComponent[];
|
|
6
|
+
selectedComponent: string | null;
|
|
7
|
+
templates: EmailTemplate[];
|
|
8
|
+
currentTemplate: EmailTemplate | null;
|
|
9
|
+
|
|
10
|
+
// Actions
|
|
11
|
+
addComponent: (component: EmailComponent, index?: number, parentId?: string | null) => void;
|
|
12
|
+
removeComponent: (id: string) => void;
|
|
13
|
+
updateComponent: (id: string, props: Partial<EmailComponent>) => void;
|
|
14
|
+
selectComponent: (id: string | null) => void;
|
|
15
|
+
moveComponent: (fromIndex: number, toIndex: number) => void;
|
|
16
|
+
clearComponents: () => void;
|
|
17
|
+
saveTemplate: (name: string) => void;
|
|
18
|
+
loadTemplate: (template: EmailTemplate) => void;
|
|
19
|
+
deleteTemplate: (id: string) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const defaultTemplate: EmailTemplate = {
|
|
23
|
+
id: 'default',
|
|
24
|
+
name: 'New Template',
|
|
25
|
+
components: [],
|
|
26
|
+
createdAt: new Date().toISOString(),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Helper function to recursively find and update components
|
|
30
|
+
const findAndUpdateComponent = (
|
|
31
|
+
components: EmailComponent[],
|
|
32
|
+
id: string,
|
|
33
|
+
updater: (comp: EmailComponent) => EmailComponent
|
|
34
|
+
): EmailComponent[] => {
|
|
35
|
+
return components.map((comp) => {
|
|
36
|
+
if (comp.id === id) {
|
|
37
|
+
return updater(comp);
|
|
38
|
+
}
|
|
39
|
+
if (comp.children) {
|
|
40
|
+
return {
|
|
41
|
+
...comp,
|
|
42
|
+
children: findAndUpdateComponent(comp.children, id, updater),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return comp;
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Helper function to recursively find and remove components
|
|
50
|
+
const findAndRemoveComponent = (
|
|
51
|
+
components: EmailComponent[],
|
|
52
|
+
id: string
|
|
53
|
+
): EmailComponent[] => {
|
|
54
|
+
return components
|
|
55
|
+
.filter((comp) => comp.id !== id)
|
|
56
|
+
.map((comp) => {
|
|
57
|
+
if (comp.children) {
|
|
58
|
+
return {
|
|
59
|
+
...comp,
|
|
60
|
+
children: findAndRemoveComponent(comp.children, id),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return comp;
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
// Helper function to find a component by ID
|
|
68
|
+
const findComponent = (
|
|
69
|
+
components: EmailComponent[],
|
|
70
|
+
id: string
|
|
71
|
+
): EmailComponent | null => {
|
|
72
|
+
for (const comp of components) {
|
|
73
|
+
if (comp.id === id) {
|
|
74
|
+
return comp;
|
|
75
|
+
}
|
|
76
|
+
if (comp.children) {
|
|
77
|
+
const found = findComponent(comp.children, id);
|
|
78
|
+
if (found) return found;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const useEmailBuilder = create<EmailBuilderState>((set) => ({
|
|
85
|
+
components: [],
|
|
86
|
+
selectedComponent: null,
|
|
87
|
+
templates: [],
|
|
88
|
+
currentTemplate: null,
|
|
89
|
+
|
|
90
|
+
addComponent: (component, index, parentId) =>
|
|
91
|
+
set((state) => {
|
|
92
|
+
// Check if component with this ID already exists
|
|
93
|
+
const componentExists = (comps: EmailComponent[], id: string): boolean => {
|
|
94
|
+
for (const comp of comps) {
|
|
95
|
+
if (comp.id === id) return true;
|
|
96
|
+
if (comp.children && componentExists(comp.children, id)) return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
if (componentExists(state.components, component.id)) {
|
|
102
|
+
// Component already exists, don't add again
|
|
103
|
+
return state;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (parentId) {
|
|
107
|
+
// Add to nested component
|
|
108
|
+
const updatedComponents = findAndUpdateComponent(
|
|
109
|
+
state.components,
|
|
110
|
+
parentId,
|
|
111
|
+
(parent) => {
|
|
112
|
+
const children = parent.children || [];
|
|
113
|
+
// Check for duplicate in children
|
|
114
|
+
if (children.some((child) => child.id === component.id)) {
|
|
115
|
+
return parent;
|
|
116
|
+
}
|
|
117
|
+
const newChildren = [...children];
|
|
118
|
+
if (index !== undefined) {
|
|
119
|
+
newChildren.splice(index, 0, component);
|
|
120
|
+
} else {
|
|
121
|
+
newChildren.push(component);
|
|
122
|
+
}
|
|
123
|
+
return { ...parent, children: newChildren };
|
|
124
|
+
}
|
|
125
|
+
);
|
|
126
|
+
return { components: updatedComponents };
|
|
127
|
+
} else {
|
|
128
|
+
// Add to root level
|
|
129
|
+
// Check for duplicate at root level
|
|
130
|
+
if (state.components.some((c) => c.id === component.id)) {
|
|
131
|
+
return state;
|
|
132
|
+
}
|
|
133
|
+
const newComponents = [...state.components];
|
|
134
|
+
if (index !== undefined) {
|
|
135
|
+
newComponents.splice(index, 0, component);
|
|
136
|
+
} else {
|
|
137
|
+
newComponents.push(component);
|
|
138
|
+
}
|
|
139
|
+
return { components: newComponents };
|
|
140
|
+
}
|
|
141
|
+
}),
|
|
142
|
+
|
|
143
|
+
removeComponent: (id) =>
|
|
144
|
+
set((state) => ({
|
|
145
|
+
components: findAndRemoveComponent(state.components, id),
|
|
146
|
+
selectedComponent: state.selectedComponent === id ? null : state.selectedComponent,
|
|
147
|
+
})),
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
updateComponent: (id, props) =>
|
|
151
|
+
set((state) => ({
|
|
152
|
+
components: findAndUpdateComponent(state.components, id, (comp) => ({
|
|
153
|
+
...comp,
|
|
154
|
+
...props,
|
|
155
|
+
})),
|
|
156
|
+
})),
|
|
157
|
+
|
|
158
|
+
selectComponent: (id) => set({ selectedComponent: id }),
|
|
159
|
+
|
|
160
|
+
moveComponent: (fromIndex, toIndex) =>
|
|
161
|
+
set((state) => {
|
|
162
|
+
const newComponents = [...state.components];
|
|
163
|
+
const [removed] = newComponents.splice(fromIndex, 1);
|
|
164
|
+
newComponents.splice(toIndex, 0, removed);
|
|
165
|
+
return { components: newComponents };
|
|
166
|
+
}),
|
|
167
|
+
|
|
168
|
+
clearComponents: () => set({ components: [], selectedComponent: null }),
|
|
169
|
+
|
|
170
|
+
saveTemplate: (name) =>
|
|
171
|
+
set((state) => {
|
|
172
|
+
const newTemplate: EmailTemplate = {
|
|
173
|
+
id: Date.now().toString(),
|
|
174
|
+
name,
|
|
175
|
+
components: state.components,
|
|
176
|
+
createdAt: new Date().toISOString(),
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
templates: [...state.templates, newTemplate],
|
|
180
|
+
currentTemplate: newTemplate,
|
|
181
|
+
};
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
loadTemplate: (template) =>
|
|
185
|
+
set({
|
|
186
|
+
components: template.components,
|
|
187
|
+
currentTemplate: template,
|
|
188
|
+
selectedComponent: null,
|
|
189
|
+
}),
|
|
190
|
+
|
|
191
|
+
deleteTemplate: (id) =>
|
|
192
|
+
set((state) => ({
|
|
193
|
+
templates: state.templates.filter((t) => t.id !== id),
|
|
194
|
+
currentTemplate:
|
|
195
|
+
state.currentTemplate?.id === id ? null : state.currentTemplate,
|
|
196
|
+
})),
|
|
197
|
+
}));
|
|
198
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "email-builder-pro",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A fully functional drag-and-drop email template builder with React and Next.js",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"types": "src/index.tsx",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"components",
|
|
10
|
+
"lib",
|
|
11
|
+
"types",
|
|
12
|
+
"app/globals.css",
|
|
13
|
+
"tailwind.config.js",
|
|
14
|
+
"postcss.config.js",
|
|
15
|
+
"README.md",
|
|
16
|
+
"INTEGRATION.md",
|
|
17
|
+
"PUBLISH.md"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"email",
|
|
21
|
+
"email-builder",
|
|
22
|
+
"drag-drop",
|
|
23
|
+
"react",
|
|
24
|
+
"nextjs",
|
|
25
|
+
"template",
|
|
26
|
+
"email-template",
|
|
27
|
+
"react-email",
|
|
28
|
+
"visual-editor"
|
|
29
|
+
],
|
|
30
|
+
"author": "pranaykodam@gmail.com",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/pranay213/email-builder.git"
|
|
35
|
+
},
|
|
36
|
+
"private": false,
|
|
37
|
+
"scripts": {
|
|
38
|
+
"dev": "next dev",
|
|
39
|
+
"build": "next build",
|
|
40
|
+
"start": "next start",
|
|
41
|
+
"lint": "next lint",
|
|
42
|
+
"email:dev": "email dev",
|
|
43
|
+
"email:export": "email export",
|
|
44
|
+
"prepublishOnly": "npm run build",
|
|
45
|
+
"publish:package": "bash scripts/publish.sh",
|
|
46
|
+
"publish": "npm publish --access public"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@react-email/components": "^1.0.2",
|
|
50
|
+
"next": "^14.0.0",
|
|
51
|
+
"react": "^18.2.0",
|
|
52
|
+
"react-dom": "^18.2.0",
|
|
53
|
+
"react-dnd": "^16.0.1",
|
|
54
|
+
"react-dnd-html5-backend": "^16.0.1",
|
|
55
|
+
"lucide-react": "^0.294.0",
|
|
56
|
+
"zustand": "^4.4.7"
|
|
57
|
+
},
|
|
58
|
+
"peerDependencies": {
|
|
59
|
+
"react": "^18.2.0",
|
|
60
|
+
"react-dom": "^18.2.0",
|
|
61
|
+
"next": "^14.0.0"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@react-email/preview-server": "^5.1.0",
|
|
65
|
+
"@types/node": "^20.10.0",
|
|
66
|
+
"@types/react": "^18.2.0",
|
|
67
|
+
"@types/react-dom": "^18.2.0",
|
|
68
|
+
"react-email": "^5.1.0",
|
|
69
|
+
"typescript": "^5.3.0",
|
|
70
|
+
"tailwindcss": "^3.4.0",
|
|
71
|
+
"postcss": "^8.4.32",
|
|
72
|
+
"autoprefixer": "^10.4.16",
|
|
73
|
+
"eslint": "^8.55.0",
|
|
74
|
+
"eslint-config-next": "^14.0.0"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Main entry point for the email-builder package
|
|
2
|
+
// This file exports all public APIs
|
|
3
|
+
|
|
4
|
+
'use client';
|
|
5
|
+
|
|
6
|
+
import { DndProvider } from 'react-dnd';
|
|
7
|
+
import { HTML5Backend } from 'react-dnd-html5-backend';
|
|
8
|
+
import EmailBuilderComponent from '../components/EmailBuilder';
|
|
9
|
+
|
|
10
|
+
export interface EmailBuilderProps {
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Default export - Main EmailBuilder component
|
|
15
|
+
export default function EmailBuilder(props?: EmailBuilderProps) {
|
|
16
|
+
return (
|
|
17
|
+
<DndProvider backend={HTML5Backend}>
|
|
18
|
+
<div className={props?.className}>
|
|
19
|
+
<EmailBuilderComponent />
|
|
20
|
+
</div>
|
|
21
|
+
</DndProvider>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Named exports for advanced usage
|
|
26
|
+
export { default as EmailBuilderCore } from '../components/EmailBuilder';
|
|
27
|
+
export { default as Canvas } from '../components/Canvas';
|
|
28
|
+
export { default as ComponentPalette } from '../components/ComponentPalette';
|
|
29
|
+
export { default as PropertiesPanel } from '../components/PropertiesPanel';
|
|
30
|
+
export { default as PreviewPanel } from '../components/PreviewPanel';
|
|
31
|
+
export { default as Toolbar } from '../components/Toolbar';
|
|
32
|
+
export { default as TemplateManager } from '../components/TemplateManager';
|
|
33
|
+
export { default as ComponentRenderer } from '../components/ComponentRenderer';
|
|
34
|
+
export { default as ImageUpload } from '../components/ImageUpload';
|
|
35
|
+
export { default as NumberInput } from '../components/NumberInput';
|
|
36
|
+
|
|
37
|
+
// Export types
|
|
38
|
+
export * from '../types/email';
|
|
39
|
+
|
|
40
|
+
// Export store hook
|
|
41
|
+
export { useEmailBuilder } from '../lib/store';
|
|
42
|
+
|