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:
Serreau Jovann
2025-08-02 10:45:16 +02:00
parent c8a408dc15
commit 5cf3da1488
14 changed files with 2536 additions and 5 deletions

View File

@@ -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)
}

View File

@@ -36,3 +36,8 @@ input {
padding: 0.5rem;
}
}
#ee {
display: block;
height: 100%;
}

View 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
View 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)
}

BIN
bun.lockb

Binary file not shown.

View 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)\'');
}
}

View File

@@ -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"
}

View File

@@ -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
{
}
}

View File

@@ -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;

View 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);
}
}

View File

@@ -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 %}

View 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 %}

View File

@@ -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.

2187
yarn.lock Normal file

File diff suppressed because it is too large Load Diff