```
✨ feat(newsletter): Ajoute l'éditeur de template d'email avec Preact
Crée un nouvel éditeur de template d'email en utilisant Preact et
react-email-editor, et l'intègre au contrôleur et aux vues.
```
This commit is contained in:
@@ -5,6 +5,8 @@ import {ServerCard} from './class/ServerCard'
|
||||
import {AutoCustomer} from './class/AutoCustomer'
|
||||
import {RepeatLine} from './class/RepeatLine'
|
||||
import {OrderCtrl} from './class/OrderCtrl'
|
||||
import {MainframeEmailEditor} from './class/MainframeEmailEditor'
|
||||
import preactCustomElement from './functions/preact'
|
||||
|
||||
|
||||
function script() {
|
||||
@@ -13,6 +15,7 @@ function script() {
|
||||
customElements.define('auto-customer',AutoCustomer,{extends:'button'})
|
||||
customElements.define('repeat-line',RepeatLine,{extends:'div'})
|
||||
customElements.define('order-ctrl',OrderCtrl,{extends:'div'})
|
||||
preactCustomElement("email-builder",MainframeEmailEditor)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -36,3 +36,8 @@ input {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
#ee {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
38
assets/class/MainframeEmailEditor.jsx
Normal file
38
assets/class/MainframeEmailEditor.jsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import EmailEditor, { EditorRef, EmailEditorProps } from 'react-email-editor';
|
||||
import { useRef } from 'preact/hooks';
|
||||
|
||||
export function MainframeEmailEditor() {
|
||||
const emailEditorRef = useRef(null);
|
||||
|
||||
const exportHtml = (e) => {
|
||||
e.preventDefault();
|
||||
const unlayer = emailEditorRef.current?.editor;
|
||||
|
||||
unlayer?.exportHtml((data) => {
|
||||
const { design, html } = data;
|
||||
|
||||
let format = JSON.stringify({
|
||||
design,
|
||||
html
|
||||
})
|
||||
document.body.querySelector('#template_content').setAttribute('value',format);
|
||||
document.body.querySelector('#template_content').value = format;
|
||||
});
|
||||
};
|
||||
const onReady =(unlayer) => {
|
||||
let html = document.body.querySelector('#template_content').getAttribute('value');
|
||||
let data = JSON.parse(html);
|
||||
unlayer.loadDesign(data.design);
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<button class="button" onClick={exportHtml}>Sauvegarde le html</button>
|
||||
</div>
|
||||
<EmailEditor locale="fr-FR" ref={emailEditorRef} onReady={onReady} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
assets/functions/preact.js
Normal file
152
assets/functions/preact.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { h, cloneElement, render, hydrate } from 'preact'
|
||||
|
||||
export default function preactCustomElement (tagName, Component, propNames, options) {
|
||||
function PreactElement () {
|
||||
const inst = Reflect.construct(HTMLElement, [], PreactElement)
|
||||
inst._vdomComponent = Component
|
||||
inst._root = options && options.shadow ? inst.attachShadow({ mode: 'open' }) : inst
|
||||
return inst
|
||||
}
|
||||
PreactElement.prototype = Object.create(HTMLElement.prototype)
|
||||
PreactElement.prototype.constructor = PreactElement
|
||||
PreactElement.prototype.connectedCallback = connectedCallback
|
||||
PreactElement.prototype.attributeChangedCallback = attributeChangedCallback
|
||||
PreactElement.prototype.disconnectedCallback = disconnectedCallback
|
||||
|
||||
propNames = propNames || Component.observedAttributes || Object.keys(Component.propTypes || {})
|
||||
PreactElement.observedAttributes = propNames
|
||||
|
||||
// Keep DOM properties and Preact props in sync
|
||||
propNames.forEach(name => {
|
||||
Object.defineProperty(PreactElement.prototype, name, {
|
||||
get () {
|
||||
return this._vdom.props[name]
|
||||
},
|
||||
set (v) {
|
||||
if (this._vdom) {
|
||||
this.attributeChangedCallback(name, null, v)
|
||||
} else {
|
||||
if (!this._props) this._props = {}
|
||||
this._props[name] = v
|
||||
this.connectedCallback()
|
||||
}
|
||||
|
||||
// Reflect property changes to attributes if the value is a primitive
|
||||
const type = typeof v
|
||||
if (v == null || type === 'string' || type === 'boolean' || type === 'number') {
|
||||
this.setAttribute(name, v)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return customElements.define(tagName || Component.tagName || Component.displayName || Component.name, PreactElement)
|
||||
}
|
||||
|
||||
function ContextProvider (props) {
|
||||
this.getChildContext = () => props.context
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { context, children, ...rest } = props
|
||||
return cloneElement(children, rest)
|
||||
}
|
||||
|
||||
function connectedCallback () {
|
||||
// Obtain a reference to the previous context by pinging the nearest
|
||||
// higher up node that was rendered with Preact. If one Preact component
|
||||
// higher up receives our ping, it will set the `detail` property of
|
||||
// our custom event. This works because events are dispatched
|
||||
// synchronously.
|
||||
const event = new CustomEvent('_preact', {
|
||||
detail: {},
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
this.dispatchEvent(event)
|
||||
const context = event.detail.context
|
||||
|
||||
this._vdom = h(ContextProvider, { ...this._props, context }, toVdom(this, this._vdomComponent))
|
||||
;(this.hasAttribute('hydrate') ? hydrate : render)(this._vdom, this._root)
|
||||
}
|
||||
|
||||
function toCamelCase (str) {
|
||||
return str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : ''))
|
||||
}
|
||||
|
||||
function attributeChangedCallback (name, oldValue, newValue) {
|
||||
if (!this._vdom) return
|
||||
// Attributes use `null` as an empty value whereas `undefined` is more
|
||||
// common in pure JS components, especially with default parameters.
|
||||
// When calling `node.removeAttribute()` we'll receive `null` as the new
|
||||
// value. See issue #50.
|
||||
newValue = newValue == null ? undefined : newValue
|
||||
const props = {}
|
||||
props[name] = newValue
|
||||
props[toCamelCase(name)] = newValue
|
||||
this._vdom = cloneElement(this._vdom, props)
|
||||
render(this._vdom, this._root)
|
||||
}
|
||||
|
||||
function disconnectedCallback () {
|
||||
render((this._vdom = null), this._root)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass an event listener to each `<slot>` that "forwards" the current
|
||||
* context value to the rendered child. The child will trigger a custom
|
||||
* event, where will add the context value to. Because events work
|
||||
* synchronously, the child can immediately pull of the value right
|
||||
* after having fired the event.
|
||||
*/
|
||||
function Slot (props, context) {
|
||||
const ref = r => {
|
||||
if (!r) {
|
||||
this.ref.removeEventListener('_preact', this._listener)
|
||||
} else {
|
||||
this.ref = r
|
||||
if (!this._listener) {
|
||||
this._listener = event => {
|
||||
event.stopPropagation()
|
||||
event.detail.context = context
|
||||
}
|
||||
r.addEventListener('_preact', this._listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
return h('slot', { ...props, ref })
|
||||
}
|
||||
|
||||
function toVdom (element, nodeName) {
|
||||
if (element.nodeType === Node.TEXT_NODE) {
|
||||
const data = element.data
|
||||
element.data = ''
|
||||
return data
|
||||
}
|
||||
if (element.nodeType !== Node.ELEMENT_NODE) return null
|
||||
const children = []
|
||||
const props = {}
|
||||
let i = 0
|
||||
const a = element.attributes
|
||||
const cn = element.childNodes
|
||||
for (i = a.length; i--; ) {
|
||||
if (a[i].name !== 'slot') {
|
||||
props[a[i].name] = a[i].value
|
||||
props[toCamelCase(a[i].name)] = a[i].value
|
||||
}
|
||||
}
|
||||
props.parent = element
|
||||
|
||||
for (i = cn.length; i--; ) {
|
||||
const vnode = toVdom(cn[i], null)
|
||||
// Move slots correctly
|
||||
const name = cn[i].slot
|
||||
if (name) {
|
||||
props[name] = h(Slot, { name }, vnode)
|
||||
} else {
|
||||
children[i] = vnode
|
||||
}
|
||||
}
|
||||
|
||||
// Only wrap the topmost node with a slot
|
||||
const wrappedChildren = nodeName ? h(Slot, null, children) : children
|
||||
return h(nodeName || element.nodeName.toLowerCase(), props, wrappedChildren)
|
||||
}
|
||||
36
migrations/Version20250801135113.php
Normal file
36
migrations/Version20250801135113.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20250801135113 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('ALTER TABLE template ALTER content TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE template ALTER content DROP NOT NULL');
|
||||
$this->addSql('COMMENT ON COLUMN template.content IS NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
$this->addSql('ALTER TABLE template ALTER content TYPE TEXT');
|
||||
$this->addSql('ALTER TABLE template ALTER content SET NOT NULL');
|
||||
$this->addSql('COMMENT ON COLUMN template.content IS \'(DC2Type:array)\'');
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@grafikart/drop-files-element": "^1.0.9",
|
||||
"@grapesjs/studio-sdk": "^1.0.55",
|
||||
"@grapesjs/studio-sdk-plugins": "^1.0.27",
|
||||
"@hotwired/turbo": "^8.0.13",
|
||||
"@preact/preset-vite": "^2.10.2",
|
||||
"@sentry/browser": "^9.34.0",
|
||||
"@tailwindcss/vite": "^4.1.10",
|
||||
"@usewaypoint/email-builder": "^0.0.8",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"react-email-editor": "^1.7.11",
|
||||
"sortablejs": "^1.15.6",
|
||||
"tailwindcss": "^4.1.10"
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
namespace App\Controller\Artemis\Newsletter;
|
||||
|
||||
use App\Entity\Newsletter\Template;
|
||||
use App\Form\Artemis\Newsletter\TemplateType;
|
||||
use App\Repository\Newsletter\TemplateRepository;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
|
||||
@@ -34,4 +38,36 @@ class TemplateController extends AbstractController
|
||||
//button link
|
||||
|
||||
}
|
||||
|
||||
#[Route(path: '/artemis/newsletter/template/add',name: 'artemis_newsletter_template_add',methods: ['GET', 'POST'])]
|
||||
#[Route(path: '/artemis/newsletter/template/{id}',name: 'artemis_newsletter_template_edit',methods: ['GET', 'POST'])]
|
||||
public function templateEditor(?Template $template,EntityManagerInterface $entityManager,Request $request): Response
|
||||
{
|
||||
if(is_null($template)){
|
||||
$template = new Template();
|
||||
}
|
||||
|
||||
|
||||
$form = $this->createForm(TemplateType::class,$template);
|
||||
$form->handleRequest($request);
|
||||
if($form->isSubmitted() && $form->isValid()){
|
||||
$entityManager->persist($template);
|
||||
$entityManager->flush();
|
||||
$this->addFlash("success","Mise à jour effectuée");
|
||||
return $this->redirectToRoute('artemis_newsletter_template_edit',['id'=>$template->getId()]);
|
||||
|
||||
}
|
||||
return $this->render('artemis/newsletter/template/editor.twig', [
|
||||
'form' => $form->createView(),
|
||||
'template' => $template,
|
||||
]);
|
||||
|
||||
|
||||
}
|
||||
|
||||
#[Route(path: '/artemis/newsletter/template/{id}/preview',name: 'artemis_newsletter_template_preview',methods: ['GET', 'POST'])]
|
||||
public function templatePreview(TemplateRepository $templateRepository): Response
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ class Template
|
||||
#[ORM\Column(length: 255)]
|
||||
private ?string $name = null;
|
||||
|
||||
#[ORM\Column(type: Types::ARRAY)]
|
||||
private array $content = [];
|
||||
#[ORM\Column(type: 'text',nullable: true)]
|
||||
private string $content = "";
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
@@ -37,12 +37,12 @@ class Template
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContent(): array
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function setContent(array $content): static
|
||||
public function setContent(string $content): static
|
||||
{
|
||||
$this->content = $content;
|
||||
|
||||
|
||||
29
src/Form/Artemis/Newsletter/TemplateType.php
Normal file
29
src/Form/Artemis/Newsletter/TemplateType.php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form\Artemis\Newsletter;
|
||||
|
||||
use App\Entity\Newsletter\Template;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class TemplateType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->add('name',TextType::class,[
|
||||
'label'=>'Nom',
|
||||
'required'=>true,
|
||||
])
|
||||
->add('content',HiddenType::class,[
|
||||
]);
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefault('data_class',Template::class);
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
</a>
|
||||
|
||||
{% else %}
|
||||
<div class="col-span-4 text-center text-gray-400 dark:text-gray-500 font-bold">Aucune liste trouvée.</div>
|
||||
<div class="col-span-4 text-center text-gray-400 dark:text-gray-500 font-bold">Aucun template trouvée.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
38
templates/artemis/newsletter/template/editor.twig
Normal file
38
templates/artemis/newsletter/template/editor.twig
Normal file
@@ -0,0 +1,38 @@
|
||||
{% extends 'artemis/base.twig' %}
|
||||
{% block title %}Template - {{ template.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-semibold mb-6">Template - {{ template.name }}</h1>
|
||||
{% if template.id is not null %}
|
||||
<div>
|
||||
|
||||
<a href="{{ path('artemis_newsletter_template_preview',{id:template.id}) }}" class="ml-2 px-4 py-2 bg-blue-600 text-white font-medium rounded-md shadow-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
Voir le rendu
|
||||
</a>
|
||||
|
||||
<a href="{{ path('artemis_newsletter_template_edit',{id:template.id,delete :1}) }}" class="ml-2 px-4 py-2 bg-red-600 text-white font-medium rounded-md shadow-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900">
|
||||
Supprimer le template
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="bg-gray-800 rounded-lg shadow p-6 mb-6">
|
||||
{{ form_start(form, {'attr': {'class': 'w-full'}}) }}
|
||||
<div class="mb-4">
|
||||
{{ form_label(form.name, null, {'label_attr': {'class': 'block mb-1 font-medium text-gray-700 dark:text-gray-300'}}) }}
|
||||
{{ form_widget(form.name, {'attr': {
|
||||
'class': 'w-full p-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white'
|
||||
}}) }}
|
||||
{{ form_errors(form.name) }}
|
||||
</div>
|
||||
<email-builder></email-builder>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded transition font-semibold w-full">
|
||||
Sauvegarder
|
||||
</button>
|
||||
{{ form_end(form) }}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{% endblock %}
|
||||
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
|
||||
import { resolve } from 'path';
|
||||
import JavaScriptObfuscator from 'rollup-plugin-javascript-obfuscator';
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import preact from '@preact/preset-vite';
|
||||
|
||||
// Si vous utilisez un framework comme Vue ou React, importez son plugin ici
|
||||
// import vue from '@vitejs/plugin-vue';
|
||||
@@ -82,6 +83,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
// Ajoutez ici les plugins de framework (ex: vue(), react())
|
||||
tailwindcss(),
|
||||
preact(),
|
||||
// --- PLUGIN D'OBSCURCISSEMENT JAVASCRIPT ---
|
||||
// Doit être l'un des derniers plugins pour s'appliquer au code final.
|
||||
// ATTENTION : Ces options sont très agressives et peuvent casser votre code.
|
||||
|
||||
Reference in New Issue
Block a user